Quality-of-life enhancements: CSV import/export, opening balances, interest rates, mobile UX
- CSV import/export for Units, Projects, and Vendors with match-on-name/number upsert - Cash Flow report toggle for Cash Only vs Cash + Investments - Per-account and bulk opening balance setting with as-of date - Interest rate field on normal accounts with estimated monthly/annual interest display - Mobile sidebar auto-close on navigation - Shared CSV parsing/export utility extracted to frontend/src/utils/csv.ts DB migration needed for existing tenants: ALTER TABLE accounts ADD COLUMN IF NOT EXISTS interest_rate DECIMAL(6,4); Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ export class TenantSchemaService {
|
|||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
is_system BOOLEAN DEFAULT FALSE,
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
is_primary BOOLEAN DEFAULT FALSE,
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
interest_rate DECIMAL(6,4),
|
||||||
balance DECIMAL(15,2) DEFAULT 0.00,
|
balance DECIMAL(15,2) DEFAULT 0.00,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ export class AccountsController {
|
|||||||
return this.accountsService.setPrimary(id);
|
return this.accountsService.setPrimary(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bulk-opening-balances')
|
||||||
|
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
||||||
|
bulkSetOpeningBalances(
|
||||||
|
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
||||||
|
) {
|
||||||
|
return this.accountsService.bulkSetOpeningBalances(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/opening-balance')
|
||||||
|
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
||||||
|
setOpeningBalance(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
|
) {
|
||||||
|
return this.accountsService.setOpeningBalance(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/adjust-balance')
|
@Post(':id/adjust-balance')
|
||||||
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
||||||
adjustBalance(
|
adjustBalance(
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export class AccountsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertResult = await this.tenant.query(
|
const insertResult = await this.tenant.query(
|
||||||
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable)
|
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable, interest_rate)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[
|
[
|
||||||
dto.accountNumber,
|
dto.accountNumber,
|
||||||
@@ -66,6 +66,7 @@ export class AccountsService {
|
|||||||
dto.fundType,
|
dto.fundType,
|
||||||
dto.parentAccountId || null,
|
dto.parentAccountId || null,
|
||||||
dto.is1099Reportable || false,
|
dto.is1099Reportable || false,
|
||||||
|
dto.interestRate || null,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const accountId = Array.isArray(insertResult[0]) ? insertResult[0][0].id : insertResult[0].id;
|
const accountId = Array.isArray(insertResult[0]) ? insertResult[0][0].id : insertResult[0].id;
|
||||||
@@ -172,6 +173,7 @@ export class AccountsService {
|
|||||||
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
|
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
|
||||||
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
||||||
if (dto.isPrimary !== undefined) { sets.push(`is_primary = $${idx++}`); params.push(dto.isPrimary); }
|
if (dto.isPrimary !== undefined) { sets.push(`is_primary = $${idx++}`); params.push(dto.isPrimary); }
|
||||||
|
if (dto.interestRate !== undefined) { sets.push(`interest_rate = $${idx++}`); params.push(dto.interestRate); }
|
||||||
|
|
||||||
if (!sets.length) return account;
|
if (!sets.length) return account;
|
||||||
|
|
||||||
@@ -204,7 +206,30 @@ export class AccountsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) {
|
async setOpeningBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) {
|
||||||
|
return this.adjustBalance(id, dto, 'opening_balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkSetOpeningBalances(dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) {
|
||||||
|
let processed = 0, skipped = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const entry of dto.entries) {
|
||||||
|
try {
|
||||||
|
const result = await this.setOpeningBalance(entry.accountId, {
|
||||||
|
targetBalance: entry.targetBalance,
|
||||||
|
asOfDate: dto.asOfDate,
|
||||||
|
});
|
||||||
|
if (result.message === 'No adjustment needed') skipped++;
|
||||||
|
else processed++;
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`${entry.accountId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { processed, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }, entryType = 'adjustment') {
|
||||||
const account = await this.findOne(id);
|
const account = await this.findOne(id);
|
||||||
|
|
||||||
// Get current balance for this account using trial balance logic
|
// Get current balance for this account using trial balance logic
|
||||||
@@ -282,16 +307,20 @@ export class AccountsService {
|
|||||||
const equityDebit = targetCredit > 0 ? targetCredit : 0;
|
const equityDebit = targetCredit > 0 ? targetCredit : 0;
|
||||||
const equityCredit = targetDebit > 0 ? targetDebit : 0;
|
const equityCredit = targetDebit > 0 ? targetDebit : 0;
|
||||||
|
|
||||||
const memo = dto.memo || `Balance adjustment to ${dto.targetBalance}`;
|
const defaultMemo = entryType === 'opening_balance'
|
||||||
|
? `Opening balance for ${account.name}`
|
||||||
|
: `Balance adjustment to ${dto.targetBalance}`;
|
||||||
|
const memo = dto.memo || defaultMemo;
|
||||||
|
|
||||||
// Create journal entry
|
// Create journal entry
|
||||||
const jeRows = await this.tenant.query(
|
const jeRows = await this.tenant.query(
|
||||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
VALUES ($1, $2, 'adjustment', $3, true, NOW(), $4)
|
VALUES ($1, $2, $3, $4, true, NOW(), $5)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
dto.asOfDate,
|
dto.asOfDate,
|
||||||
memo,
|
memo,
|
||||||
|
entryType,
|
||||||
fiscalPeriodId,
|
fiscalPeriodId,
|
||||||
'00000000-0000-0000-0000-000000000000',
|
'00000000-0000-0000-0000-000000000000',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -36,4 +36,8 @@ export class CreateAccountDto {
|
|||||||
@ApiProperty({ required: false, default: 0 })
|
@ApiProperty({ required: false, default: 0 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
initialBalance?: number;
|
initialBalance?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
|
||||||
|
@IsOptional()
|
||||||
|
interestRate?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,8 @@ export class UpdateAccountDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
|
||||||
|
@IsOptional()
|
||||||
|
interestRate?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
@@ -13,12 +14,22 @@ export class ProjectsController {
|
|||||||
@Get()
|
@Get()
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
|
@Get('export')
|
||||||
|
async exportCSV(@Res() res: Response) {
|
||||||
|
const csv = await this.service.exportCSV();
|
||||||
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
||||||
|
res.send(csv);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('planning')
|
@Get('planning')
|
||||||
findForPlanning() { return this.service.findForPlanning(); }
|
findForPlanning() { return this.service.findForPlanning(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,87 @@ export class ProjectsService {
|
|||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportCSV(): Promise<string> {
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`SELECT name, description, category, estimated_cost, actual_cost, fund_source,
|
||||||
|
useful_life_years, remaining_life_years, condition_rating,
|
||||||
|
last_replacement_date, next_replacement_date, planned_date,
|
||||||
|
target_year, target_month, status, priority, notes
|
||||||
|
FROM projects WHERE is_active = true ORDER BY name`,
|
||||||
|
);
|
||||||
|
const headers = ['*name', 'description', '*category', '*estimated_cost', 'actual_cost', 'fund_source',
|
||||||
|
'useful_life_years', 'remaining_life_years', 'condition_rating',
|
||||||
|
'last_replacement_date', 'next_replacement_date', 'planned_date',
|
||||||
|
'target_year', 'target_month', 'status', 'priority', 'notes'];
|
||||||
|
const keys = headers.map(h => h.replace(/^\*/, ''));
|
||||||
|
const lines = [headers.join(',')];
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push(keys.map((k) => {
|
||||||
|
let v = r[k] ?? '';
|
||||||
|
if (v instanceof Date) v = v.toISOString().split('T')[0];
|
||||||
|
const s = String(v);
|
||||||
|
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
|
}).join(','));
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async importCSV(rows: any[]) {
|
||||||
|
let created = 0, updated = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const name = (row.name || '').trim();
|
||||||
|
if (!name) { errors.push(`Row ${i + 1}: missing name (required)`); continue; }
|
||||||
|
if (!row.category) { errors.push(`Row ${i + 1}: missing category (required)`); continue; }
|
||||||
|
if (!row.estimated_cost) { errors.push(`Row ${i + 1}: missing estimated_cost (required)`); continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await this.tenant.query('SELECT id FROM projects WHERE name = $1 AND is_active = true', [name]);
|
||||||
|
if (existing.length) {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [existing[0].id];
|
||||||
|
let idx = 2;
|
||||||
|
const fields = ['description', 'category', 'estimated_cost', 'actual_cost', 'fund_source',
|
||||||
|
'useful_life_years', 'remaining_life_years', 'condition_rating',
|
||||||
|
'last_replacement_date', 'next_replacement_date', 'planned_date',
|
||||||
|
'target_year', 'target_month', 'status', 'priority', 'notes'];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (row[f] !== undefined && row[f] !== '') {
|
||||||
|
sets.push(`${f} = $${idx++}`);
|
||||||
|
params.push(row[f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sets.length) {
|
||||||
|
sets.push('updated_at = NOW()');
|
||||||
|
await this.tenant.query(`UPDATE projects SET ${sets.join(', ')} WHERE id = $1`, params);
|
||||||
|
}
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO projects (name, description, category, estimated_cost, actual_cost, fund_source,
|
||||||
|
useful_life_years, remaining_life_years, condition_rating,
|
||||||
|
last_replacement_date, next_replacement_date, planned_date,
|
||||||
|
target_year, target_month, status, priority, notes)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
|
||||||
|
[name, row.description || null, row.category, parseFloat(row.estimated_cost) || 0,
|
||||||
|
row.actual_cost || null, row.fund_source || 'reserve',
|
||||||
|
row.useful_life_years || null, row.remaining_life_years || null,
|
||||||
|
row.condition_rating || null, row.last_replacement_date || null,
|
||||||
|
row.next_replacement_date || null, row.planned_date || null,
|
||||||
|
row.target_year || null, row.target_month || null,
|
||||||
|
row.status || 'planned', row.priority || 3, row.notes || null],
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Row ${i + 1} (${name}): ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { imported: created + updated, created, updated, errors };
|
||||||
|
}
|
||||||
|
|
||||||
async updatePlannedDate(id: string, planned_date: string) {
|
async updatePlannedDate(id: string, planned_date: string) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
|
|||||||
@@ -29,11 +29,17 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow')
|
@Get('cash-flow')
|
||||||
getCashFlowStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
getCashFlowStatement(
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
@Query('includeInvestments') includeInvestments?: string,
|
||||||
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const defaultFrom = `${now.getFullYear()}-01-01`;
|
const defaultFrom = `${now.getFullYear()}-01-01`;
|
||||||
const defaultTo = now.toISOString().split('T')[0];
|
const defaultTo = now.toISOString().split('T')[0];
|
||||||
return this.reportsService.getCashFlowStatement(from || defaultFrom, to || defaultTo);
|
return this.reportsService.getCashFlowStatement(
|
||||||
|
from || defaultFrom, to || defaultTo, includeInvestments === 'true',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('aging')
|
@Get('aging')
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export class ReportsService {
|
|||||||
return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow };
|
return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCashFlowStatement(from: string, to: string) {
|
async getCashFlowStatement(from: string, to: string, includeInvestments = false) {
|
||||||
// Operating activities: income minus expenses from journal entries
|
// Operating activities: income minus expenses from journal entries
|
||||||
const operating = await this.tenant.query(`
|
const operating = await this.tenant.query(`
|
||||||
SELECT a.name, a.account_type,
|
SELECT a.name, a.account_type,
|
||||||
@@ -222,6 +222,11 @@ export class ReportsService {
|
|||||||
ORDER BY a.name
|
ORDER BY a.name
|
||||||
`, [from, to]);
|
`, [from, to]);
|
||||||
|
|
||||||
|
// Asset filter: cash-only vs cash + investment accounts
|
||||||
|
const assetFilter = includeInvestments
|
||||||
|
? `a.account_type = 'asset'`
|
||||||
|
: `a.account_type = 'asset' AND a.name LIKE '%Cash%'`;
|
||||||
|
|
||||||
// Cash beginning and ending balances
|
// Cash beginning and ending balances
|
||||||
const beginCash = await this.tenant.query(`
|
const beginCash = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
|
||||||
@@ -231,7 +236,7 @@ export class ReportsService {
|
|||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
AND je.entry_date < $1
|
AND je.entry_date < $1
|
||||||
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
|
WHERE ${assetFilter} AND a.is_active = true
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
`, [from]);
|
`, [from]);
|
||||||
@@ -244,11 +249,20 @@ export class ReportsService {
|
|||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
AND je.entry_date <= $1
|
AND je.entry_date <= $1
|
||||||
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
|
WHERE ${assetFilter} AND a.is_active = true
|
||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
`, [to]);
|
`, [to]);
|
||||||
|
|
||||||
|
// Include investment_accounts table balances when requested
|
||||||
|
let investmentBalance = 0;
|
||||||
|
if (includeInvestments) {
|
||||||
|
const inv = await this.tenant.query(
|
||||||
|
`SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE is_active = true`,
|
||||||
|
);
|
||||||
|
investmentBalance = parseFloat(inv[0]?.total || '0');
|
||||||
|
}
|
||||||
|
|
||||||
const operatingItems = operating.map((r: any) => ({
|
const operatingItems = operating.map((r: any) => ({
|
||||||
name: r.name, type: r.account_type, amount: parseFloat(r.amount),
|
name: r.name, type: r.account_type, amount: parseFloat(r.amount),
|
||||||
}));
|
}));
|
||||||
@@ -258,11 +272,12 @@ export class ReportsService {
|
|||||||
|
|
||||||
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||||
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
|
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||||
const beginningBalance = parseFloat(beginCash[0]?.balance || '0');
|
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
|
||||||
const endingBalance = parseFloat(endCash[0]?.balance || '0');
|
const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from, to,
|
from, to,
|
||||||
|
include_investments: includeInvestments,
|
||||||
operating_activities: operatingItems,
|
operating_activities: operatingItems,
|
||||||
reserve_activities: reserveItems,
|
reserve_activities: reserveItems,
|
||||||
total_operating: totalOperating.toFixed(2),
|
total_operating: totalOperating.toFixed(2),
|
||||||
@@ -270,6 +285,7 @@ export class ReportsService {
|
|||||||
net_cash_change: (totalOperating + totalReserve).toFixed(2),
|
net_cash_change: (totalOperating + totalReserve).toFixed(2),
|
||||||
beginning_cash: beginningBalance.toFixed(2),
|
beginning_cash: beginningBalance.toFixed(2),
|
||||||
ending_cash: endingBalance.toFixed(2),
|
ending_cash: endingBalance.toFixed(2),
|
||||||
|
investment_balance: investmentBalance.toFixed(2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { UnitsService } from './units.service';
|
import { UnitsService } from './units.service';
|
||||||
|
|
||||||
@@ -13,9 +14,19 @@ export class UnitsController {
|
|||||||
@Get()
|
@Get()
|
||||||
findAll() { return this.unitsService.findAll(); }
|
findAll() { return this.unitsService.findAll(); }
|
||||||
|
|
||||||
|
@Get('export')
|
||||||
|
async exportCSV(@Res() res: Response) {
|
||||||
|
const csv = await this.unitsService.exportCSV();
|
||||||
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
||||||
|
res.send(csv);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,90 @@ export class UnitsService {
|
|||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportCSV(): Promise<string> {
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`SELECT unit_number, address_line1, city, state, zip_code, square_footage, lot_size,
|
||||||
|
owner_name, owner_email, owner_phone, is_rented, monthly_assessment, status
|
||||||
|
FROM units WHERE status != 'inactive' ORDER BY unit_number`,
|
||||||
|
);
|
||||||
|
const headers = ['unit_number', 'address_line1', 'city', 'state', 'zip_code', 'square_footage', 'lot_size',
|
||||||
|
'owner_name', 'owner_email', 'owner_phone', 'is_rented', 'monthly_assessment', 'status'];
|
||||||
|
const lines = [headers.join(',')];
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push(headers.map((h) => {
|
||||||
|
const v = r[h] ?? '';
|
||||||
|
const s = String(v);
|
||||||
|
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
|
}).join(','));
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async importCSV(rows: any[]) {
|
||||||
|
let created = 0, updated = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Resolve default assessment group for new units
|
||||||
|
let defaultGroupId: string | null = null;
|
||||||
|
const defaultGroup = await this.tenant.query(
|
||||||
|
'SELECT id FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1',
|
||||||
|
);
|
||||||
|
if (defaultGroup.length) defaultGroupId = defaultGroup[0].id;
|
||||||
|
else {
|
||||||
|
const anyGroup = await this.tenant.query('SELECT id FROM assessment_groups WHERE is_active = true LIMIT 1');
|
||||||
|
if (anyGroup.length) defaultGroupId = anyGroup[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const unitNumber = (row.unit_number || '').trim();
|
||||||
|
if (!unitNumber) { errors.push(`Row ${i + 1}: missing unit_number`); continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await this.tenant.query('SELECT id FROM units WHERE unit_number = $1', [unitNumber]);
|
||||||
|
if (existing.length) {
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE units SET
|
||||||
|
address_line1 = COALESCE(NULLIF($2, ''), address_line1),
|
||||||
|
city = COALESCE(NULLIF($3, ''), city),
|
||||||
|
state = COALESCE(NULLIF($4, ''), state),
|
||||||
|
zip_code = COALESCE(NULLIF($5, ''), zip_code),
|
||||||
|
square_footage = COALESCE(NULLIF($6, '')::integer, square_footage),
|
||||||
|
lot_size = COALESCE(NULLIF($7, '')::decimal, lot_size),
|
||||||
|
owner_name = COALESCE(NULLIF($8, ''), owner_name),
|
||||||
|
owner_email = COALESCE(NULLIF($9, ''), owner_email),
|
||||||
|
owner_phone = COALESCE(NULLIF($10, ''), owner_phone),
|
||||||
|
is_rented = COALESCE(NULLIF($11, '')::boolean, is_rented),
|
||||||
|
monthly_assessment = COALESCE(NULLIF($12, '')::decimal, monthly_assessment),
|
||||||
|
status = COALESCE(NULLIF($13, ''), status),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[existing[0].id, row.address_line1, row.city, row.state, row.zip_code,
|
||||||
|
row.square_footage, row.lot_size, row.owner_name, row.owner_email,
|
||||||
|
row.owner_phone, row.is_rented, row.monthly_assessment, row.status],
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
if (!defaultGroupId) { errors.push(`Row ${i + 1}: no assessment group available for new unit`); continue; }
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, square_footage, lot_size,
|
||||||
|
owner_name, owner_email, owner_phone, is_rented, monthly_assessment, status, assessment_group_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NULLIF($6, '')::integer, NULLIF($7, '')::decimal,
|
||||||
|
$8, $9, $10, COALESCE(NULLIF($11, '')::boolean, false), COALESCE(NULLIF($12, '')::decimal, 0), COALESCE(NULLIF($13, ''), 'active'), $14)`,
|
||||||
|
[unitNumber, row.address_line1 || null, row.city || null, row.state || null, row.zip_code || null,
|
||||||
|
row.square_footage || '', row.lot_size || '', row.owner_name || null, row.owner_email || null,
|
||||||
|
row.owner_phone || null, row.is_rented || '', row.monthly_assessment || '', row.status || '', defaultGroupId],
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Row ${i + 1} (${unitNumber}): ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported: created + updated, created, updated, errors };
|
||||||
|
}
|
||||||
|
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { VendorsService } from './vendors.service';
|
import { VendorsService } from './vendors.service';
|
||||||
|
|
||||||
@@ -13,6 +14,13 @@ export class VendorsController {
|
|||||||
@Get()
|
@Get()
|
||||||
findAll() { return this.vendorsService.findAll(); }
|
findAll() { return this.vendorsService.findAll(); }
|
||||||
|
|
||||||
|
@Get('export')
|
||||||
|
async exportCSV(@Res() res: Response) {
|
||||||
|
const csv = await this.vendorsService.exportCSV();
|
||||||
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
||||||
|
res.send(csv);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('1099-data')
|
@Get('1099-data')
|
||||||
get1099Data(@Query('year') year: string) {
|
get1099Data(@Query('year') year: string) {
|
||||||
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
||||||
@@ -21,6 +29,9 @@ export class VendorsController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
||||||
|
|
||||||
|
|||||||
64
backend/src/modules/vendors/vendors.service.ts
vendored
64
backend/src/modules/vendors/vendors.service.ts
vendored
@@ -40,6 +40,70 @@ export class VendorsService {
|
|||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportCSV(): Promise<string> {
|
||||||
|
const rows = await this.tenant.query(
|
||||||
|
`SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible
|
||||||
|
FROM vendors WHERE is_active = true ORDER BY name`,
|
||||||
|
);
|
||||||
|
const headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible'];
|
||||||
|
const lines = [headers.join(',')];
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push(headers.map((h) => {
|
||||||
|
const v = r[h] ?? '';
|
||||||
|
const s = String(v);
|
||||||
|
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
|
}).join(','));
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async importCSV(rows: any[]) {
|
||||||
|
let created = 0, updated = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const name = (row.name || '').trim();
|
||||||
|
if (!name) { errors.push(`Row ${i + 1}: missing name (required)`); continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await this.tenant.query('SELECT id FROM vendors WHERE name = $1 AND is_active = true', [name]);
|
||||||
|
if (existing.length) {
|
||||||
|
await this.tenant.query(
|
||||||
|
`UPDATE vendors SET
|
||||||
|
contact_name = COALESCE(NULLIF($2, ''), contact_name),
|
||||||
|
email = COALESCE(NULLIF($3, ''), email),
|
||||||
|
phone = COALESCE(NULLIF($4, ''), phone),
|
||||||
|
address_line1 = COALESCE(NULLIF($5, ''), address_line1),
|
||||||
|
city = COALESCE(NULLIF($6, ''), city),
|
||||||
|
state = COALESCE(NULLIF($7, ''), state),
|
||||||
|
zip_code = COALESCE(NULLIF($8, ''), zip_code),
|
||||||
|
tax_id = COALESCE(NULLIF($9, ''), tax_id),
|
||||||
|
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
|
||||||
|
row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible],
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
|
[name, row.contact_name || null, row.email || null, row.phone || null,
|
||||||
|
row.address_line1 || null, row.city || null, row.state || null,
|
||||||
|
row.zip_code || null, row.tax_id || null,
|
||||||
|
row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false],
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Row ${i + 1} (${name}): ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { imported: created + updated, created, updated, errors };
|
||||||
|
}
|
||||||
|
|
||||||
async get1099Data(year: number) {
|
async get1099Data(year: number) {
|
||||||
return this.tenant.query(`
|
return this.tenant.query(`
|
||||||
SELECT v.*, COALESCE(SUM(p_amounts.amount), 0) as total_paid
|
SELECT v.*, COALESCE(SUM(p_amounts.amount), 0) as total_paid
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Sidebar } from './Sidebar';
|
|||||||
import logoSrc from '../../assets/logo.svg';
|
import logoSrc from '../../assets/logo.svg';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [opened, { toggle }] = useDisclosure();
|
const [opened, { toggle, close }] = useDisclosure();
|
||||||
const { user, currentOrg, logout } = useAuthStore();
|
const { user, currentOrg, logout } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ export function AppLayout() {
|
|||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
<AppShell.Navbar>
|
<AppShell.Navbar>
|
||||||
<Sidebar />
|
<Sidebar onNavigate={close} />
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
|
|||||||
@@ -77,11 +77,20 @@ const navSections = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
interface SidebarProps {
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ onNavigate }: SidebarProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const go = (path: string) => {
|
||||||
|
navigate(path);
|
||||||
|
onNavigate?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea p="sm">
|
<ScrollArea p="sm">
|
||||||
{navSections.map((section, sIdx) => (
|
{navSections.map((section, sIdx) => (
|
||||||
@@ -109,7 +118,7 @@ export function Sidebar() {
|
|||||||
key={child.path}
|
key={child.path}
|
||||||
label={child.label}
|
label={child.label}
|
||||||
active={location.pathname === child.path}
|
active={location.pathname === child.path}
|
||||||
onClick={() => navigate(child.path)}
|
onClick={() => go(child.path)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -119,7 +128,7 @@ export function Sidebar() {
|
|||||||
label={item.label}
|
label={item.label}
|
||||||
leftSection={<item.icon size={18} />}
|
leftSection={<item.icon size={18} />}
|
||||||
active={location.pathname === item.path}
|
active={location.pathname === item.path}
|
||||||
onClick={() => navigate(item.path!)}
|
onClick={() => go(item.path!)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -136,7 +145,7 @@ export function Sidebar() {
|
|||||||
label="Admin Panel"
|
label="Admin Panel"
|
||||||
leftSection={<IconCrown size={18} />}
|
leftSection={<IconCrown size={18} />}
|
||||||
active={location.pathname === '/admin'}
|
active={location.pathname === '/admin'}
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => go('/admin')}
|
||||||
color="red"
|
color="red"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconAdjustments,
|
IconAdjustments,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconCurrencyDollar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
@@ -71,6 +72,7 @@ interface Account {
|
|||||||
is_system: boolean;
|
is_system: boolean;
|
||||||
is_primary: boolean;
|
is_primary: boolean;
|
||||||
balance: string;
|
balance: string;
|
||||||
|
interest_rate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Investment {
|
interface Investment {
|
||||||
@@ -281,6 +283,63 @@ export function AccountsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Opening balance state + mutations ──
|
||||||
|
const [obOpened, { open: openOB, close: closeOB }] = useDisclosure(false);
|
||||||
|
const [bulkOBOpened, { open: openBulkOB, close: closeBulkOB }] = useDisclosure(false);
|
||||||
|
const [obAccount, setOBAccount] = useState<Account | null>(null);
|
||||||
|
const [bulkOBDate, setBulkOBDate] = useState<Date | null>(new Date());
|
||||||
|
const [bulkOBEntries, setBulkOBEntries] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const obForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
targetBalance: 0,
|
||||||
|
asOfDate: new Date() as Date | null,
|
||||||
|
memo: '',
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
targetBalance: (v) => (v !== undefined && v !== null ? null : 'Required'),
|
||||||
|
asOfDate: (v) => (v ? null : 'Required'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openingBalanceMutation = useMutation({
|
||||||
|
mutationFn: (values: { accountId: string; targetBalance: number; asOfDate: string; memo: string }) =>
|
||||||
|
api.post(`/accounts/${values.accountId}/opening-balance`, {
|
||||||
|
targetBalance: values.targetBalance,
|
||||||
|
asOfDate: values.asOfDate,
|
||||||
|
memo: values.memo,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
|
||||||
|
notifications.show({ message: 'Opening balance set successfully', color: 'green' });
|
||||||
|
closeOB();
|
||||||
|
setOBAccount(null);
|
||||||
|
obForm.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Error setting opening balance', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkOBMutation = useMutation({
|
||||||
|
mutationFn: (dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) =>
|
||||||
|
api.post('/accounts/bulk-opening-balances', dto),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
|
||||||
|
const d = res.data;
|
||||||
|
let msg = `Opening balances: ${d.processed} updated, ${d.skipped} unchanged`;
|
||||||
|
if (d.errors?.length) msg += `. ${d.errors.length} error(s)`;
|
||||||
|
notifications.show({ message: msg, color: d.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
|
||||||
|
closeBulkOB();
|
||||||
|
setBulkOBEntries({});
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Error setting opening balances', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── Investment edit form ──
|
// ── Investment edit form ──
|
||||||
const invForm = useForm({
|
const invForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -358,6 +417,7 @@ export function AccountsPage() {
|
|||||||
fundType: account.fund_type,
|
fundType: account.fund_type,
|
||||||
is1099Reportable: account.is_1099_reportable,
|
is1099Reportable: account.is_1099_reportable,
|
||||||
initialBalance: 0,
|
initialBalance: 0,
|
||||||
|
interestRate: parseFloat(account.interest_rate || '0'),
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
@@ -389,6 +449,51 @@ export function AccountsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Opening balance handlers ──
|
||||||
|
const handleSetOpeningBalance = (account: Account) => {
|
||||||
|
setOBAccount(account);
|
||||||
|
const tbEntry = trialBalance.find((tb) => tb.id === account.id);
|
||||||
|
obForm.setValues({
|
||||||
|
targetBalance: parseFloat(tbEntry?.balance || account.balance || '0'),
|
||||||
|
asOfDate: new Date(),
|
||||||
|
memo: '',
|
||||||
|
});
|
||||||
|
openOB();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOBSubmit = (values: { targetBalance: number; asOfDate: Date | null; memo: string }) => {
|
||||||
|
if (!obAccount || !values.asOfDate) return;
|
||||||
|
openingBalanceMutation.mutate({
|
||||||
|
accountId: obAccount.id,
|
||||||
|
targetBalance: values.targetBalance,
|
||||||
|
asOfDate: values.asOfDate.toISOString().split('T')[0],
|
||||||
|
memo: values.memo,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenBulkOB = () => {
|
||||||
|
const entries: Record<string, number> = {};
|
||||||
|
for (const a of accounts.filter((acc) => ['asset', 'liability'].includes(acc.account_type) && acc.is_active && !acc.is_system)) {
|
||||||
|
const tb = trialBalance.find((t) => t.id === a.id);
|
||||||
|
entries[a.id] = parseFloat(tb?.balance || a.balance || '0');
|
||||||
|
}
|
||||||
|
setBulkOBEntries(entries);
|
||||||
|
setBulkOBDate(new Date());
|
||||||
|
openBulkOB();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkOBSubmit = () => {
|
||||||
|
if (!bulkOBDate) return;
|
||||||
|
const entries = Object.entries(bulkOBEntries).map(([accountId, targetBalance]) => ({
|
||||||
|
accountId,
|
||||||
|
targetBalance,
|
||||||
|
}));
|
||||||
|
bulkOBMutation.mutate({
|
||||||
|
asOfDate: bulkOBDate.toISOString().split('T')[0],
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ── Filtering ──
|
// ── Filtering ──
|
||||||
// Only show asset and liability accounts — these represent real cash positions.
|
// Only show asset and liability accounts — these represent real cash positions.
|
||||||
// Income, expense, and equity accounts are internal bookkeeping managed via
|
// Income, expense, and equity accounts are internal bookkeeping managed via
|
||||||
@@ -434,6 +539,21 @@ export function AccountsPage() {
|
|||||||
// Net position = assets + investments - liabilities
|
// Net position = assets + investments - liabilities
|
||||||
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
||||||
|
|
||||||
|
// ── Estimated monthly interest across all accounts with rates ──
|
||||||
|
const estMonthlyInterest = accounts
|
||||||
|
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
||||||
|
.reduce((sum, a) => {
|
||||||
|
const bal = parseFloat(a.balance || '0');
|
||||||
|
const rate = parseFloat(a.interest_rate || '0');
|
||||||
|
return sum + (bal * (rate / 100) / 12);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// ── Opening balance modal: current balance ──
|
||||||
|
const obCurrentBalance = obAccount
|
||||||
|
? parseFloat(trialBalance.find((tb) => tb.id === obAccount.id)?.balance || obAccount.balance || '0')
|
||||||
|
: 0;
|
||||||
|
const obAdjustmentAmount = (obForm.values.targetBalance || 0) - obCurrentBalance;
|
||||||
|
|
||||||
// ── Adjust modal: current balance from trial balance ──
|
// ── Adjust modal: current balance from trial balance ──
|
||||||
const adjustCurrentBalance = adjustingAccount
|
const adjustCurrentBalance = adjustingAccount
|
||||||
? parseFloat(
|
? parseFloat(
|
||||||
@@ -463,6 +583,9 @@ export function AccountsPage() {
|
|||||||
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
onChange={(e) => setShowArchived(e.currentTarget.checked)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<Button variant="light" leftSection={<IconCurrencyDollar size={16} />} onClick={handleOpenBulkOB}>
|
||||||
|
Set Opening Balances
|
||||||
|
</Button>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
Add Account
|
Add Account
|
||||||
</Button>
|
</Button>
|
||||||
@@ -490,6 +613,12 @@ export function AccountsPage() {
|
|||||||
<Text size="xs" c="dimmed">Net Position</Text>
|
<Text size="xs" c="dimmed">Net Position</Text>
|
||||||
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
{estMonthlyInterest > 0 && (
|
||||||
|
<Card withBorder p="xs">
|
||||||
|
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
||||||
|
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
@@ -545,6 +674,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
onSetOpeningBalance={handleSetOpeningBalance}
|
||||||
/>
|
/>
|
||||||
{investments.filter(i => i.is_active).length > 0 && (
|
{investments.filter(i => i.is_active).length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -562,6 +692,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
onSetOpeningBalance={handleSetOpeningBalance}
|
||||||
/>
|
/>
|
||||||
{operatingInvestments.length > 0 && (
|
{operatingInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -579,6 +710,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
onSetOpeningBalance={handleSetOpeningBalance}
|
||||||
/>
|
/>
|
||||||
{reserveInvestments.length > 0 && (
|
{reserveInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -596,6 +728,7 @@ export function AccountsPage() {
|
|||||||
onArchive={archiveMutation.mutate}
|
onArchive={archiveMutation.mutate}
|
||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
|
onSetOpeningBalance={handleSetOpeningBalance}
|
||||||
isArchivedView
|
isArchivedView
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -729,6 +862,15 @@ export function AccountsPage() {
|
|||||||
{/* Regular account fields */}
|
{/* Regular account fields */}
|
||||||
{!isInvestmentType(form.values.accountType) && (
|
{!isInvestmentType(form.values.accountType) && (
|
||||||
<>
|
<>
|
||||||
|
<NumberInput
|
||||||
|
label="Interest Rate (%)"
|
||||||
|
description="Annual interest rate for this account"
|
||||||
|
decimalScale={4}
|
||||||
|
suffix="%"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
{...form.getInputProps('interestRate')}
|
||||||
|
/>
|
||||||
<Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
|
<Switch label="1099 Reportable" {...form.getInputProps('is1099Reportable', { type: 'checkbox' })} />
|
||||||
{!editing && (
|
{!editing && (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -804,6 +946,126 @@ export function AccountsPage() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Opening Balance Modal */}
|
||||||
|
<Modal opened={obOpened} onClose={closeOB} title="Set Opening Balance" size="md" closeOnClickOutside={false}>
|
||||||
|
{obAccount && (
|
||||||
|
<form onSubmit={obForm.onSubmit(handleOBSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Account: <strong>{obAccount.account_number} - {obAccount.name}</strong>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Current Balance"
|
||||||
|
value={fmt(obCurrentBalance)}
|
||||||
|
readOnly
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Target Opening Balance"
|
||||||
|
description="The balance this account should have as of the selected date"
|
||||||
|
required
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
thousandSeparator=","
|
||||||
|
allowNegative
|
||||||
|
{...obForm.getInputProps('targetBalance')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label="As-of Date"
|
||||||
|
description="The date the balance should be effective"
|
||||||
|
required
|
||||||
|
clearable
|
||||||
|
{...obForm.getInputProps('asOfDate')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Memo"
|
||||||
|
placeholder="Optional memo"
|
||||||
|
{...obForm.getInputProps('memo')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color={obAdjustmentAmount >= 0 ? 'blue' : 'orange'} variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
Adjustment: <strong>{fmt(obAdjustmentAmount)}</strong>
|
||||||
|
{obAdjustmentAmount > 0 && ' (increase)'}
|
||||||
|
{obAdjustmentAmount < 0 && ' (decrease)'}
|
||||||
|
{obAdjustmentAmount === 0 && ' (no change)'}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button type="submit" loading={openingBalanceMutation.isPending}>
|
||||||
|
Set Opening Balance
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Bulk Opening Balance Modal */}
|
||||||
|
<Modal opened={bulkOBOpened} onClose={closeBulkOB} title="Set Opening Balances" size="xl" closeOnClickOutside={false}>
|
||||||
|
<Stack>
|
||||||
|
<DateInput
|
||||||
|
label="As-of Date"
|
||||||
|
description="All opening balances will be effective as of this date"
|
||||||
|
required
|
||||||
|
value={bulkOBDate}
|
||||||
|
onChange={setBulkOBDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Acct #</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Current Balance</Table.Th>
|
||||||
|
<Table.Th ta="right">Target Balance</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{accounts
|
||||||
|
.filter((a) => ['asset', 'liability'].includes(a.account_type) && a.is_active && !a.is_system)
|
||||||
|
.map((a) => {
|
||||||
|
const currentBal = parseFloat(trialBalance.find((t) => t.id === a.id)?.balance || a.balance || '0');
|
||||||
|
return (
|
||||||
|
<Table.Tr key={a.id}>
|
||||||
|
<Table.Td>{a.account_number}</Table.Td>
|
||||||
|
<Table.Td>{a.name}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">{a.account_type}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">{a.fund_type}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">{fmt(currentBal)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<NumberInput
|
||||||
|
size="xs"
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
thousandSeparator=","
|
||||||
|
allowNegative
|
||||||
|
value={bulkOBEntries[a.id] ?? currentBal}
|
||||||
|
onChange={(v) => setBulkOBEntries((prev) => ({ ...prev, [a.id]: Number(v) || 0 }))}
|
||||||
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Button onClick={handleBulkOBSubmit} loading={bulkOBMutation.isPending}>
|
||||||
|
Apply Opening Balances
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Investment Edit Modal */}
|
{/* Investment Edit Modal */}
|
||||||
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
||||||
{editingInvestment && (
|
{editingInvestment && (
|
||||||
@@ -888,6 +1150,7 @@ function AccountTable({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onSetPrimary,
|
onSetPrimary,
|
||||||
onAdjustBalance,
|
onAdjustBalance,
|
||||||
|
onSetOpeningBalance,
|
||||||
isArchivedView = false,
|
isArchivedView = false,
|
||||||
}: {
|
}: {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
@@ -895,8 +1158,11 @@ function AccountTable({
|
|||||||
onArchive: (a: Account) => void;
|
onArchive: (a: Account) => void;
|
||||||
onSetPrimary: (id: string) => void;
|
onSetPrimary: (id: string) => void;
|
||||||
onAdjustBalance: (a: Account) => void;
|
onAdjustBalance: (a: Account) => void;
|
||||||
|
onSetOpeningBalance: (a: Account) => void;
|
||||||
isArchivedView?: boolean;
|
isArchivedView?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
@@ -907,6 +1173,9 @@ function AccountTable({
|
|||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
<Table.Th>Fund</Table.Th>
|
<Table.Th>Fund</Table.Th>
|
||||||
<Table.Th ta="right">Balance</Table.Th>
|
<Table.Th ta="right">Balance</Table.Th>
|
||||||
|
{hasRates && <Table.Th ta="right">Rate</Table.Th>}
|
||||||
|
{hasRates && <Table.Th ta="right">Est. Monthly</Table.Th>}
|
||||||
|
{hasRates && <Table.Th ta="right">Est. Annual</Table.Th>}
|
||||||
<Table.Th>1099</Table.Th>
|
<Table.Th>1099</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
<Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -914,89 +1183,117 @@ function AccountTable({
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{accounts.length === 0 && (
|
{accounts.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={8}>
|
<Table.Td colSpan={hasRates ? 11 : 8}>
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
|
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => {
|
||||||
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
|
const rate = parseFloat(a.interest_rate || '0');
|
||||||
<Table.Td>
|
const balance = parseFloat(a.balance || '0');
|
||||||
{a.is_primary && (
|
const estAnnual = rate > 0 ? balance * (rate / 100) : 0;
|
||||||
<Tooltip label="Primary account">
|
const estMonthly = estAnnual / 12;
|
||||||
<IconStarFilled size={16} style={{ color: 'var(--mantine-color-yellow-5)' }} />
|
return (
|
||||||
</Tooltip>
|
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
|
||||||
|
<Table.Td>
|
||||||
|
{a.is_primary && (
|
||||||
|
<Tooltip label="Primary account">
|
||||||
|
<IconStarFilled size={16} style={{ color: 'var(--mantine-color-yellow-5)' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td fw={500}>{a.account_number}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{a.name}</Text>
|
||||||
|
{a.description && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{a.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
|
||||||
|
{a.account_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||||
|
{a.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{fmt(a.balance)}
|
||||||
|
</Table.Td>
|
||||||
|
{hasRates && (
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{rate > 0 ? `${rate.toFixed(2)}%` : '-'}
|
||||||
|
</Table.Td>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
{hasRates && (
|
||||||
<Table.Td fw={500}>{a.account_number}</Table.Td>
|
<Table.Td ta="right" ff="monospace">
|
||||||
<Table.Td>
|
{rate > 0 ? fmt(estMonthly) : '-'}
|
||||||
<div>
|
</Table.Td>
|
||||||
<Text size="sm">{a.name}</Text>
|
)}
|
||||||
{a.description && (
|
{hasRates && (
|
||||||
<Text size="xs" c="dimmed">
|
<Table.Td ta="right" ff="monospace">
|
||||||
{a.description}
|
{rate > 0 ? fmt(estAnnual) : '-'}
|
||||||
</Text>
|
</Table.Td>
|
||||||
)}
|
)}
|
||||||
</div>
|
<Table.Td>
|
||||||
</Table.Td>
|
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
||||||
<Table.Td>
|
</Table.Td>
|
||||||
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
|
<Table.Td>
|
||||||
{a.account_type}
|
<Group gap={4}>
|
||||||
</Badge>
|
{!a.is_system && (
|
||||||
</Table.Td>
|
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
||||||
<Table.Td>
|
<ActionIcon
|
||||||
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
variant="subtle"
|
||||||
{a.fund_type}
|
color="yellow"
|
||||||
</Badge>
|
onClick={() => onSetPrimary(a.id)}
|
||||||
</Table.Td>
|
>
|
||||||
<Table.Td ta="right" ff="monospace">
|
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
||||||
{fmt(a.balance)}
|
</ActionIcon>
|
||||||
</Table.Td>
|
</Tooltip>
|
||||||
<Table.Td>
|
)}
|
||||||
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
|
{!a.is_system && (
|
||||||
</Table.Td>
|
<Tooltip label="Set Opening Balance">
|
||||||
<Table.Td>
|
<ActionIcon variant="subtle" color="teal" onClick={() => onSetOpeningBalance(a)}>
|
||||||
<Group gap={4}>
|
<IconCurrencyDollar size={16} />
|
||||||
{!a.is_system && (
|
</ActionIcon>
|
||||||
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
|
</Tooltip>
|
||||||
<ActionIcon
|
)}
|
||||||
variant="subtle"
|
{!a.is_system && (
|
||||||
color="yellow"
|
<Tooltip label="Adjust Balance">
|
||||||
onClick={() => onSetPrimary(a.id)}
|
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
||||||
>
|
<IconAdjustments size={16} />
|
||||||
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Edit account">
|
||||||
|
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
||||||
|
<IconEdit size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
{!a.is_system && (
|
||||||
{!a.is_system && (
|
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
||||||
<Tooltip label="Adjust Balance">
|
<ActionIcon
|
||||||
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
|
variant="subtle"
|
||||||
<IconAdjustments size={16} />
|
color={a.is_active ? 'gray' : 'green'}
|
||||||
</ActionIcon>
|
onClick={() => onArchive(a)}
|
||||||
</Tooltip>
|
>
|
||||||
)}
|
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
||||||
<Tooltip label="Edit account">
|
</ActionIcon>
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
|
</Tooltip>
|
||||||
<IconEdit size={16} />
|
)}
|
||||||
</ActionIcon>
|
</Group>
|
||||||
</Tooltip>
|
</Table.Td>
|
||||||
{!a.is_system && (
|
</Table.Tr>
|
||||||
<Tooltip label={a.is_active ? 'Archive account' : 'Restore account'}>
|
);
|
||||||
<ActionIcon
|
})}
|
||||||
variant="subtle"
|
|
||||||
color={a.is_active ? 'gray' : 'green'}
|
|
||||||
onClick={() => onArchive(a)}
|
|
||||||
>
|
|
||||||
{a.is_active ? <IconArchive size={16} /> : <IconArchiveOff size={16} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
|
||||||
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
|
||||||
@@ -8,9 +8,10 @@ import { DateInput } from '@mantine/dates';
|
|||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -75,6 +76,7 @@ export function ProjectsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
@@ -191,6 +193,42 @@ export function ProjectsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: async (rows: Record<string, string>[]) => {
|
||||||
|
const { data } = await api.post('/projects/import', rows);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
let msg = `Imported: ${data.created} created, ${data.updated} updated`;
|
||||||
|
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`;
|
||||||
|
notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
|
||||||
|
},
|
||||||
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/projects/export', { responseType: 'blob' });
|
||||||
|
downloadBlob(response.data, 'projects.csv');
|
||||||
|
} catch { notifications.show({ message: 'Export failed', color: 'red' }); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; }
|
||||||
|
importMutation.mutate(rows);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
// ---- Handlers ----
|
// ---- Handlers ----
|
||||||
|
|
||||||
const handleEdit = (p: Project) => {
|
const handleEdit = (p: Project) => {
|
||||||
@@ -279,9 +317,19 @@ export function ProjectsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Projects</Title>
|
<Title order={2}>Projects</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Group>
|
||||||
+ Add Project
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.length === 0}>
|
||||||
</Button>
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
|
loading={importMutation.isPending}>
|
||||||
|
Import CSV
|
||||||
|
</Button>
|
||||||
|
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
|
+ Add Project
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Stack, Text, Card, Loader, Center, Divider,
|
Title, Table, Group, Stack, Text, Card, Loader, Center, Divider,
|
||||||
Badge, SimpleGrid, TextInput, Button, ThemeIcon,
|
Badge, SimpleGrid, TextInput, Button, ThemeIcon, SegmentedControl,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,7 @@ interface ReserveActivity {
|
|||||||
interface CashFlowData {
|
interface CashFlowData {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
include_investments: boolean;
|
||||||
operating_activities: OperatingActivity[];
|
operating_activities: OperatingActivity[];
|
||||||
reserve_activities: ReserveActivity[];
|
reserve_activities: ReserveActivity[];
|
||||||
total_operating: string;
|
total_operating: string;
|
||||||
@@ -31,6 +32,7 @@ interface CashFlowData {
|
|||||||
net_cash_change: string;
|
net_cash_change: string;
|
||||||
beginning_cash: string;
|
beginning_cash: string;
|
||||||
ending_cash: string;
|
ending_cash: string;
|
||||||
|
investment_balance: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CashFlowPage() {
|
export function CashFlowPage() {
|
||||||
@@ -42,11 +44,16 @@ export function CashFlowPage() {
|
|||||||
const [toDate, setToDate] = useState(todayStr);
|
const [toDate, setToDate] = useState(todayStr);
|
||||||
const [queryFrom, setQueryFrom] = useState(yearStart);
|
const [queryFrom, setQueryFrom] = useState(yearStart);
|
||||||
const [queryTo, setQueryTo] = useState(todayStr);
|
const [queryTo, setQueryTo] = useState(todayStr);
|
||||||
|
const [balanceMode, setBalanceMode] = useState<string>('cash');
|
||||||
|
|
||||||
|
const includeInvestments = balanceMode === 'all';
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<CashFlowData>({
|
const { data, isLoading } = useQuery<CashFlowData>({
|
||||||
queryKey: ['cash-flow', queryFrom, queryTo],
|
queryKey: ['cash-flow', queryFrom, queryTo, includeInvestments],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/reports/cash-flow?from=${queryFrom}&to=${queryTo}`);
|
const params = new URLSearchParams({ from: queryFrom, to: queryTo });
|
||||||
|
if (includeInvestments) params.set('includeInvestments', 'true');
|
||||||
|
const { data } = await api.get(`/reports/cash-flow?${params}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -63,6 +70,7 @@ export function CashFlowPage() {
|
|||||||
const totalReserve = parseFloat(data?.total_reserve || '0');
|
const totalReserve = parseFloat(data?.total_reserve || '0');
|
||||||
const beginningCash = parseFloat(data?.beginning_cash || '0');
|
const beginningCash = parseFloat(data?.beginning_cash || '0');
|
||||||
const endingCash = parseFloat(data?.ending_cash || '0');
|
const endingCash = parseFloat(data?.ending_cash || '0');
|
||||||
|
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
|
||||||
|
|
||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
@@ -95,6 +103,19 @@ export function CashFlowPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Text size="sm" fw={500}>Balance view:</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
value={balanceMode}
|
||||||
|
onChange={setBalanceMode}
|
||||||
|
data={[
|
||||||
|
{ label: 'Cash Only', value: 'cash' },
|
||||||
|
{ label: 'Cash + Investments', value: 'all' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||||
<Card withBorder p="md">
|
<Card withBorder p="md">
|
||||||
@@ -102,7 +123,7 @@ export function CashFlowPage() {
|
|||||||
<ThemeIcon variant="light" color="blue" size="sm">
|
<ThemeIcon variant="light" color="blue" size="sm">
|
||||||
<IconWallet size={14} />
|
<IconWallet size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Beginning Cash</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Beginning {balanceLabel}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace">{fmt(beginningCash)}</Text>
|
<Text fw={700} size="xl" ff="monospace">{fmt(beginningCash)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -133,7 +154,7 @@ export function CashFlowPage() {
|
|||||||
<ThemeIcon variant="light" color="teal" size="sm">
|
<ThemeIcon variant="light" color="teal" size="sm">
|
||||||
<IconCash size={14} />
|
<IconCash size={14} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending Cash</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending {balanceLabel}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
|
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -162,11 +183,7 @@ export function CashFlowPage() {
|
|||||||
<Table.Tr key={`${a.name}-${idx}`}>
|
<Table.Tr key={`${a.name}-${idx}`}>
|
||||||
<Table.Td>{a.name}</Table.Td>
|
<Table.Td>{a.name}</Table.Td>
|
||||||
<Table.Td ta="center">
|
<Table.Td ta="center">
|
||||||
<Badge
|
<Badge size="xs" variant="light" color={a.type === 'income' ? 'green' : 'red'}>
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color={a.type === 'income' ? 'green' : 'red'}
|
|
||||||
>
|
|
||||||
{a.type}
|
{a.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -241,11 +258,11 @@ export function CashFlowPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group justify="space-between" px="sm">
|
<Group justify="space-between" px="sm">
|
||||||
<Text fw={700} size="lg">Beginning Cash</Text>
|
<Text fw={700} size="lg">Beginning {balanceLabel}</Text>
|
||||||
<Text fw={700} size="lg" ff="monospace">{fmt(data?.beginning_cash || '0')}</Text>
|
<Text fw={700} size="lg" ff="monospace">{fmt(data?.beginning_cash || '0')}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between" px="sm">
|
<Group justify="space-between" px="sm">
|
||||||
<Text fw={700} size="xl">Ending Cash</Text>
|
<Text fw={700} size="xl">Ending {balanceLabel}</Text>
|
||||||
<Text fw={700} size="xl" ff="monospace" c="teal">
|
<Text fw={700} size="xl" ff="monospace" c="teal">
|
||||||
{fmt(data?.ending_cash || '0')}
|
{fmt(data?.ending_cash || '0')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||||
Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert,
|
Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert,
|
||||||
@@ -6,9 +6,10 @@ import {
|
|||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
|
||||||
interface Unit {
|
interface Unit {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,6 +40,7 @@ export function UnitsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||||
queryKey: ['units'],
|
queryKey: ['units'],
|
||||||
@@ -91,6 +93,20 @@ export function UnitsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: async (rows: Record<string, string>[]) => {
|
||||||
|
const { data } = await api.post('/units/import', rows);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['units'] });
|
||||||
|
let msg = `Imported: ${data.created} created, ${data.updated} updated`;
|
||||||
|
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`;
|
||||||
|
notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
|
||||||
|
},
|
||||||
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); },
|
||||||
|
});
|
||||||
|
|
||||||
const handleEdit = (u: Unit) => {
|
const handleEdit = (u: Unit) => {
|
||||||
setEditing(u);
|
setEditing(u);
|
||||||
form.setValues({
|
form.setValues({
|
||||||
@@ -105,13 +121,32 @@ export function UnitsPage() {
|
|||||||
const handleNew = () => {
|
const handleNew = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.reset();
|
form.reset();
|
||||||
// Pre-populate with default group
|
if (defaultGroup) form.setFieldValue('assessment_group_id', defaultGroup.id);
|
||||||
if (defaultGroup) {
|
|
||||||
form.setFieldValue('assessment_group_id', defaultGroup.id);
|
|
||||||
}
|
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/units/export', { responseType: 'blob' });
|
||||||
|
downloadBlob(response.data, 'units.csv');
|
||||||
|
} catch { notifications.show({ message: 'Export failed', color: 'red' }); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; }
|
||||||
|
importMutation.mutate(rows);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
const filtered = units.filter((u) =>
|
const filtered = units.filter((u) =>
|
||||||
!search || u.unit_number.toLowerCase().includes(search.toLowerCase()) ||
|
!search || u.unit_number.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
(u.owner_name || '').toLowerCase().includes(search.toLowerCase())
|
(u.owner_name || '').toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -123,13 +158,23 @@ export function UnitsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Units / Homeowners</Title>
|
<Title order={2}>Units / Homeowners</Title>
|
||||||
{hasGroups ? (
|
<Group>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.length === 0}>
|
||||||
) : (
|
Export CSV
|
||||||
<Tooltip label="Create an assessment group first">
|
</Button>
|
||||||
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
</Tooltip>
|
loading={importMutation.isPending}>
|
||||||
)}
|
Import CSV
|
||||||
|
</Button>
|
||||||
|
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
{hasGroups ? (
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
|
||||||
|
) : (
|
||||||
|
<Tooltip label="Create an assessment group first">
|
||||||
|
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{!hasGroups && (
|
{!hasGroups && (
|
||||||
|
|||||||
54
frontend/src/pages/vendors/VendorsPage.tsx
vendored
54
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||||
Switch, Badge, ActionIcon, Text, Loader, Center,
|
Switch, Badge, ActionIcon, Text, Loader, Center,
|
||||||
@@ -6,9 +6,10 @@ import {
|
|||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
id: string; name: string; contact_name: string; email: string; phone: string;
|
id: string; name: string; contact_name: string; email: string; phone: string;
|
||||||
@@ -21,6 +22,7 @@ export function VendorsPage() {
|
|||||||
const [editing, setEditing] = useState<Vendor | null>(null);
|
const [editing, setEditing] = useState<Vendor | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||||
queryKey: ['vendors'],
|
queryKey: ['vendors'],
|
||||||
@@ -46,6 +48,42 @@ export function VendorsPage() {
|
|||||||
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: async (rows: Record<string, string>[]) => {
|
||||||
|
const { data } = await api.post('/vendors/import', rows);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
||||||
|
let msg = `Imported: ${data.created} created, ${data.updated} updated`;
|
||||||
|
if (data.errors?.length) msg += `. ${data.errors.length} error(s): ${data.errors.slice(0, 3).join('; ')}`;
|
||||||
|
notifications.show({ message: msg, color: data.errors?.length ? 'yellow' : 'green', autoClose: 10000 });
|
||||||
|
},
|
||||||
|
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' }); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/vendors/export', { responseType: 'blob' });
|
||||||
|
downloadBlob(response.data, 'vendors.csv');
|
||||||
|
} catch { notifications.show({ message: 'Export failed', color: 'red' }); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
if (!text) { notifications.show({ message: 'Could not read file', color: 'red' }); return; }
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (!rows.length) { notifications.show({ message: 'No data rows found', color: 'red' }); return; }
|
||||||
|
importMutation.mutate(rows);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (v: Vendor) => {
|
const handleEdit = (v: Vendor) => {
|
||||||
setEditing(v);
|
setEditing(v);
|
||||||
form.setValues({
|
form.setValues({
|
||||||
@@ -65,7 +103,17 @@ export function VendorsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Vendors</Title>
|
<Title order={2}>Vendors</Title>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
|
<Group>
|
||||||
|
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={vendors.length === 0}>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => fileInputRef.current?.click()}
|
||||||
|
loading={importMutation.isPending}>
|
||||||
|
Import CSV
|
||||||
|
</Button>
|
||||||
|
<input type="file" ref={fileInputRef} accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
|
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
|
||||||
value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
|
value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
|
||||||
|
|||||||
84
frontend/src/utils/csv.ts
Normal file
84
frontend/src/utils/csv.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Shared CSV parsing and export utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Parse CSV text into an array of objects keyed by lowercase header names. */
|
||||||
|
export function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.trim().split('\n');
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
|
// Strip leading * from headers (used to mark required fields)
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().replace(/^\*/, '').toLowerCase());
|
||||||
|
const rows: Record<string, string>[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
// Handle quoted fields containing commas
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let j = 0; j < line.length; j++) {
|
||||||
|
const ch = line[j];
|
||||||
|
if (ch === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
} else if (ch === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((h, idx) => {
|
||||||
|
row[h] = values[idx] || '';
|
||||||
|
});
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an array of objects to CSV text and trigger a browser download. */
|
||||||
|
export function downloadCSV(rows: Record<string, any>[], headers: string[], filename: string) {
|
||||||
|
const csvLines = [headers.join(',')];
|
||||||
|
for (const row of rows) {
|
||||||
|
const values = headers.map((h) => {
|
||||||
|
const key = h.replace(/^\*/, '').toLowerCase();
|
||||||
|
const val = row[key] ?? '';
|
||||||
|
const str = String(val);
|
||||||
|
// Quote values that contain commas or quotes
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
});
|
||||||
|
csvLines.push(values.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([csvLines.join('\n')], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download a blob response from the API as a file. */
|
||||||
|
export function downloadBlob(data: Blob, filename: string, type = 'text/csv') {
|
||||||
|
const blob = new Blob([data], { type });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user