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

@@ -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),
};
}