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:
2026-02-25 09:13:51 -05:00
parent 32af961173
commit 45a267d787
21 changed files with 1015 additions and 128 deletions

View File

@@ -45,6 +45,7 @@ export class TenantSchemaService {
is_active BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
is_primary BOOLEAN DEFAULT FALSE,
interest_rate DECIMAL(6,4),
balance DECIMAL(15,2) DEFAULT 0.00,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),

View File

@@ -32,6 +32,23 @@ export class AccountsController {
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')
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
adjustBalance(

View File

@@ -55,8 +55,8 @@ export class AccountsService {
}
const insertResult = await this.tenant.query(
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`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, $8)
RETURNING id`,
[
dto.accountNumber,
@@ -66,6 +66,7 @@ export class AccountsService {
dto.fundType,
dto.parentAccountId || null,
dto.is1099Reportable || false,
dto.interestRate || null,
],
);
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.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.interestRate !== undefined) { sets.push(`interest_rate = $${idx++}`); params.push(dto.interestRate); }
if (!sets.length) return account;
@@ -204,7 +206,30 @@ export class AccountsService {
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);
// Get current balance for this account using trial balance logic
@@ -282,16 +307,20 @@ export class AccountsService {
const equityDebit = targetCredit > 0 ? targetCredit : 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
const jeRows = await this.tenant.query(
`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 *`,
[
dto.asOfDate,
memo,
entryType,
fiscalPeriodId,
'00000000-0000-0000-0000-000000000000',
],

View File

@@ -36,4 +36,8 @@ export class CreateAccountDto {
@ApiProperty({ required: false, default: 0 })
@IsOptional()
initialBalance?: number;
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
@IsOptional()
interestRate?: number;
}

View File

@@ -41,4 +41,8 @@ export class UpdateAccountDto {
@IsBoolean()
@IsOptional()
isPrimary?: boolean;
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
@IsOptional()
interestRate?: number;
}

View File

@@ -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 { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProjectsService } from './projects.service';
@@ -13,12 +14,22 @@ export class ProjectsController {
@Get()
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')
findForPlanning() { return this.service.findForPlanning(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post('import')
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
@Post()
create(@Body() dto: any) { return this.service.create(dto); }

View File

@@ -176,6 +176,87 @@ export class ProjectsService {
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) {
await this.findOne(id);
const rows = await this.tenant.query(

View File

@@ -29,11 +29,17 @@ export class ReportsController {
}
@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 defaultFrom = `${now.getFullYear()}-01-01`;
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')

View File

@@ -178,7 +178,7 @@ export class ReportsService {
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
const operating = await this.tenant.query(`
SELECT a.name, a.account_type,
@@ -222,6 +222,11 @@ export class ReportsService {
ORDER BY a.name
`, [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
const beginCash = await this.tenant.query(`
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
AND je.is_posted = true AND je.is_void = false
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
) sub
`, [from]);
@@ -244,11 +249,20 @@ export class ReportsService {
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
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
) sub
`, [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) => ({
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 totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
const beginningBalance = parseFloat(beginCash[0]?.balance || '0');
const endingBalance = parseFloat(endCash[0]?.balance || '0');
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0);
const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance;
return {
from, to,
include_investments: includeInvestments,
operating_activities: operatingItems,
reserve_activities: reserveItems,
total_operating: totalOperating.toFixed(2),
@@ -270,6 +285,7 @@ export class ReportsService {
net_cash_change: (totalOperating + totalReserve).toFixed(2),
beginning_cash: beginningBalance.toFixed(2),
ending_cash: endingBalance.toFixed(2),
investment_balance: investmentBalance.toFixed(2),
};
}

View File

@@ -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 { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UnitsService } from './units.service';
@@ -13,9 +14,19 @@ export class UnitsController {
@Get()
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')
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
@Post('import')
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
@Post()
create(@Body() dto: any) { return this.unitsService.create(dto); }

View File

@@ -73,6 +73,90 @@ export class UnitsService {
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) {
await this.findOne(id);

View File

@@ -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 { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VendorsService } from './vendors.service';
@@ -13,6 +14,13 @@ export class VendorsController {
@Get()
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')
get1099Data(@Query('year') year: string) {
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
@@ -21,6 +29,9 @@ export class VendorsController {
@Get(':id')
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
@Post('import')
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
@Post()
create(@Body() dto: any) { return this.vendorsService.create(dto); }

View File

@@ -40,6 +40,70 @@ export class VendorsService {
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) {
return this.tenant.query(`
SELECT v.*, COALESCE(SUM(p_amounts.amount), 0) as total_paid

View File

@@ -14,7 +14,7 @@ import { Sidebar } from './Sidebar';
import logoSrc from '../../assets/logo.svg';
export function AppLayout() {
const [opened, { toggle }] = useDisclosure();
const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout } = useAuthStore();
const navigate = useNavigate();
@@ -98,7 +98,7 @@ export function AppLayout() {
</AppShell.Header>
<AppShell.Navbar>
<Sidebar />
<Sidebar onNavigate={close} />
</AppShell.Navbar>
<AppShell.Main>

View File

@@ -77,11 +77,20 @@ const navSections = [
},
];
export function Sidebar() {
interface SidebarProps {
onNavigate?: () => void;
}
export function Sidebar({ onNavigate }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
const go = (path: string) => {
navigate(path);
onNavigate?.();
};
return (
<ScrollArea p="sm">
{navSections.map((section, sIdx) => (
@@ -109,7 +118,7 @@ export function Sidebar() {
key={child.path}
label={child.label}
active={location.pathname === child.path}
onClick={() => navigate(child.path)}
onClick={() => go(child.path)}
/>
))}
</NavLink>
@@ -119,7 +128,7 @@ export function Sidebar() {
label={item.label}
leftSection={<item.icon size={18} />}
active={location.pathname === item.path}
onClick={() => navigate(item.path!)}
onClick={() => go(item.path!)}
/>
),
)}
@@ -136,7 +145,7 @@ export function Sidebar() {
label="Admin Panel"
leftSection={<IconCrown size={18} />}
active={location.pathname === '/admin'}
onClick={() => navigate('/admin')}
onClick={() => go('/admin')}
color="red"
/>
</>

View File

@@ -37,6 +37,7 @@ import {
IconStarFilled,
IconAdjustments,
IconInfoCircle,
IconCurrencyDollar,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -71,6 +72,7 @@ interface Account {
is_system: boolean;
is_primary: boolean;
balance: string;
interest_rate: string | null;
}
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 ──
const invForm = useForm({
initialValues: {
@@ -358,6 +417,7 @@ export function AccountsPage() {
fundType: account.fund_type,
is1099Reportable: account.is_1099_reportable,
initialBalance: 0,
interestRate: parseFloat(account.interest_rate || '0'),
});
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 ──
// Only show asset and liability accounts — these represent real cash positions.
// Income, expense, and equity accounts are internal bookkeeping managed via
@@ -434,6 +539,21 @@ export function AccountsPage() {
// Net position = assets + investments - liabilities
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 ──
const adjustCurrentBalance = adjustingAccount
? parseFloat(
@@ -463,6 +583,9 @@ export function AccountsPage() {
onChange={(e) => setShowArchived(e.currentTarget.checked)}
size="sm"
/>
<Button variant="light" leftSection={<IconCurrencyDollar size={16} />} onClick={handleOpenBulkOB}>
Set Opening Balances
</Button>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
@@ -490,6 +613,12 @@ export function AccountsPage() {
<Text size="xs" c="dimmed">Net Position</Text>
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
</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>
<Group>
@@ -545,6 +674,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
onSetOpeningBalance={handleSetOpeningBalance}
/>
{investments.filter(i => i.is_active).length > 0 && (
<>
@@ -562,6 +692,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
onSetOpeningBalance={handleSetOpeningBalance}
/>
{operatingInvestments.length > 0 && (
<>
@@ -579,6 +710,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
onSetOpeningBalance={handleSetOpeningBalance}
/>
{reserveInvestments.length > 0 && (
<>
@@ -596,6 +728,7 @@ export function AccountsPage() {
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
onSetOpeningBalance={handleSetOpeningBalance}
isArchivedView
/>
</Tabs.Panel>
@@ -729,6 +862,15 @@ export function AccountsPage() {
{/* Regular account fields */}
{!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' })} />
{!editing && (
<NumberInput
@@ -804,6 +946,126 @@ export function AccountsPage() {
)}
</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 */}
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
{editingInvestment && (
@@ -888,6 +1150,7 @@ function AccountTable({
onArchive,
onSetPrimary,
onAdjustBalance,
onSetOpeningBalance,
isArchivedView = false,
}: {
accounts: Account[];
@@ -895,8 +1158,11 @@ function AccountTable({
onArchive: (a: Account) => void;
onSetPrimary: (id: string) => void;
onAdjustBalance: (a: Account) => void;
onSetOpeningBalance: (a: Account) => void;
isArchivedView?: boolean;
}) {
const hasRates = accounts.some((a) => a.interest_rate && parseFloat(a.interest_rate) > 0);
return (
<Table striped highlightOnHover>
<Table.Thead>
@@ -907,6 +1173,9 @@ function AccountTable({
<Table.Th>Type</Table.Th>
<Table.Th>Fund</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></Table.Th>
</Table.Tr>
@@ -914,14 +1183,19 @@ function AccountTable({
<Table.Tbody>
{accounts.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Table.Td colSpan={hasRates ? 11 : 8}>
<Text ta="center" c="dimmed" py="lg">
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
</Text>
</Table.Td>
</Table.Tr>
)}
{accounts.map((a) => (
{accounts.map((a) => {
const rate = parseFloat(a.interest_rate || '0');
const balance = parseFloat(a.balance || '0');
const estAnnual = rate > 0 ? balance * (rate / 100) : 0;
const estMonthly = estAnnual / 12;
return (
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
<Table.Td>
{a.is_primary && (
@@ -954,6 +1228,21 @@ function AccountTable({
<Table.Td ta="right" ff="monospace">
{fmt(a.balance)}
</Table.Td>
{hasRates && (
<Table.Td ta="right">
{rate > 0 ? `${rate.toFixed(2)}%` : '-'}
</Table.Td>
)}
{hasRates && (
<Table.Td ta="right" ff="monospace">
{rate > 0 ? fmt(estMonthly) : '-'}
</Table.Td>
)}
{hasRates && (
<Table.Td ta="right" ff="monospace">
{rate > 0 ? fmt(estAnnual) : '-'}
</Table.Td>
)}
<Table.Td>
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td>
@@ -970,6 +1259,13 @@ function AccountTable({
</ActionIcon>
</Tooltip>
)}
{!a.is_system && (
<Tooltip label="Set Opening Balance">
<ActionIcon variant="subtle" color="teal" onClick={() => onSetOpeningBalance(a)}>
<IconCurrencyDollar size={16} />
</ActionIcon>
</Tooltip>
)}
{!a.is_system && (
<Tooltip label="Adjust Balance">
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
@@ -996,7 +1292,8 @@ function AccountTable({
</Group>
</Table.Td>
</Table.Tr>
))}
);
})}
</Table.Tbody>
</Table>
);

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
@@ -8,9 +8,10 @@ import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
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 api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
// ---------------------------------------------------------------------------
// Types & constants
@@ -75,6 +76,7 @@ export function ProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Project | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
// ---- 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 ----
const handleEdit = (p: Project) => {
@@ -279,10 +317,20 @@ export function ProjectsPage() {
{/* Header */}
<Group justify="space-between">
<Title order={2}>Projects</Title>
<Group>
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={projects.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={handleNew}>
+ Add Project
</Button>
</Group>
</Group>
{/* Summary Cards */}
<SimpleGrid cols={{ base: 1, sm: 3 }}>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import {
Title, Table, Group, Stack, Text, Card, Loader, Center, Divider,
Badge, SimpleGrid, TextInput, Button, ThemeIcon,
Badge, SimpleGrid, TextInput, Button, ThemeIcon, SegmentedControl,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
@@ -24,6 +24,7 @@ interface ReserveActivity {
interface CashFlowData {
from: string;
to: string;
include_investments: boolean;
operating_activities: OperatingActivity[];
reserve_activities: ReserveActivity[];
total_operating: string;
@@ -31,6 +32,7 @@ interface CashFlowData {
net_cash_change: string;
beginning_cash: string;
ending_cash: string;
investment_balance: string;
}
export function CashFlowPage() {
@@ -42,11 +44,16 @@ export function CashFlowPage() {
const [toDate, setToDate] = useState(todayStr);
const [queryFrom, setQueryFrom] = useState(yearStart);
const [queryTo, setQueryTo] = useState(todayStr);
const [balanceMode, setBalanceMode] = useState<string>('cash');
const includeInvestments = balanceMode === 'all';
const { data, isLoading } = useQuery<CashFlowData>({
queryKey: ['cash-flow', queryFrom, queryTo],
queryKey: ['cash-flow', queryFrom, queryTo, includeInvestments],
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;
},
});
@@ -63,6 +70,7 @@ export function CashFlowPage() {
const totalReserve = parseFloat(data?.total_reserve || '0');
const beginningCash = parseFloat(data?.beginning_cash || '0');
const endingCash = parseFloat(data?.ending_cash || '0');
const balanceLabel = includeInvestments ? 'Cash + Investments' : 'Cash';
if (isLoading) return <Center h={300}><Loader /></Center>;
@@ -95,6 +103,19 @@ export function CashFlowPage() {
</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 */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
<Card withBorder p="md">
@@ -102,7 +123,7 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color="blue" size="sm">
<IconWallet size={14} />
</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>
<Text fw={700} size="xl" ff="monospace">{fmt(beginningCash)}</Text>
</Card>
@@ -133,7 +154,7 @@ export function CashFlowPage() {
<ThemeIcon variant="light" color="teal" size="sm">
<IconCash size={14} />
</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>
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
</Card>
@@ -162,11 +183,7 @@ export function CashFlowPage() {
<Table.Tr key={`${a.name}-${idx}`}>
<Table.Td>{a.name}</Table.Td>
<Table.Td ta="center">
<Badge
size="xs"
variant="light"
color={a.type === 'income' ? 'green' : 'red'}
>
<Badge size="xs" variant="light" color={a.type === 'income' ? 'green' : 'red'}>
{a.type}
</Badge>
</Table.Td>
@@ -241,11 +258,11 @@ export function CashFlowPage() {
</Group>
<Divider />
<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>
</Group>
<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">
{fmt(data?.ending_cash || '0')}
</Text>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, TextInput, Modal,
Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert,
@@ -6,9 +6,10 @@ import {
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
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 api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
interface Unit {
id: string;
@@ -39,6 +40,7 @@ export function UnitsPage() {
const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: units = [], isLoading } = useQuery<Unit[]>({
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) => {
setEditing(u);
form.setValues({
@@ -105,13 +121,32 @@ export function UnitsPage() {
const handleNew = () => {
setEditing(null);
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();
};
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) =>
!search || u.unit_number.toLowerCase().includes(search.toLowerCase()) ||
(u.owner_name || '').toLowerCase().includes(search.toLowerCase())
@@ -123,6 +158,15 @@ export function UnitsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Units / Homeowners</Title>
<Group>
<Button variant="light" leftSection={<IconDownload size={16} />} onClick={handleExport} disabled={units.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} />
{hasGroups ? (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
) : (
@@ -131,6 +175,7 @@ export function UnitsPage() {
</Tooltip>
)}
</Group>
</Group>
{!hasGroups && (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import {
Title, Table, Group, Button, Stack, TextInput, Modal,
Switch, Badge, ActionIcon, Text, Loader, Center,
@@ -6,9 +6,10 @@ import {
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
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 api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
interface Vendor {
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 [search, setSearch] = useState('');
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
queryKey: ['vendors'],
@@ -46,6 +48,42 @@ export function VendorsPage() {
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) => {
setEditing(v);
form.setValues({
@@ -65,8 +103,18 @@ export function VendorsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Vendors</Title>
<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>
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
<Table striped highlightOnHover>

84
frontend/src/utils/csv.ts Normal file
View 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);
}