From 0e82e238c15a8413b023bd7f17f99e1c388041a4 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Thu, 26 Feb 2026 18:17:30 -0500 Subject: [PATCH] Bug & tweak sprint: fix financial calculations, add quarterly report, enhance dashboard - Fix Accounts page: include investment accounts in Est. Monthly Interest calc, add Fund column to investment table, split summary cards into Operating/Reserve - Fix Cash Flow: ending balance now respects includeInvestments toggle - Fix Budget Manager: separate operating/reserve income in summary cards - Fix Projects: default sort by planned_date instead of name - Add Vendors: last_negotiated date field with migration, CSV import/export - New Quarterly Financial Report: budget vs actuals, over-budget flagging, YTD - Enhance Dashboard: separate Operating/Reserve fund cards, expanded Quick Stats with monthly interest, YTD interest earned, planned capital spend Co-Authored-By: Claude Opus 4.6 --- backend/src/database/tenant-schema.service.ts | 1 + .../src/modules/projects/projects.service.ts | 2 +- .../src/modules/reports/reports.controller.ts | 16 + .../src/modules/reports/reports.service.ts | 254 +++++++++++++-- .../src/modules/vendors/vendors.service.ts | 27 +- db/migrations/008-vendor-last-negotiated.sql | 16 + frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 1 + frontend/src/pages/accounts/AccountsPage.tsx | 68 ++-- frontend/src/pages/budgets/BudgetsPage.tsx | 22 +- .../src/pages/dashboard/DashboardPage.tsx | 108 +++++-- .../src/pages/reports/QuarterlyReportPage.tsx | 292 ++++++++++++++++++ frontend/src/pages/vendors/VendorsPage.tsx | 17 +- 13 files changed, 738 insertions(+), 88 deletions(-) create mode 100644 db/migrations/008-vendor-last-negotiated.sql create mode 100644 frontend/src/pages/reports/QuarterlyReportPage.tsx diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index 4db1b87..2534f82 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -202,6 +202,7 @@ export class TenantSchemaService { default_account_id UUID REFERENCES "${s}".accounts(id), is_active BOOLEAN DEFAULT TRUE, ytd_payments DECIMAL(15,2) DEFAULT 0.00, + last_negotiated DATE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index 52783c7..392e6c9 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -7,7 +7,7 @@ export class ProjectsService { async findAll() { const projects = await this.tenant.query( - 'SELECT * FROM projects WHERE is_active = true ORDER BY name', + 'SELECT * FROM projects WHERE is_active = true ORDER BY planned_date NULLS LAST, target_year NULLS LAST, target_month NULLS LAST, name', ); return this.computeFunding(projects); } diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index cedeadc..baf1be9 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -66,4 +66,20 @@ export class ReportsController { const mo = Math.min(parseInt(months || '') || 24, 48); return this.reportsService.getCashFlowForecast(yr, mo); } + + @Get('quarterly') + getQuarterlyFinancial( + @Query('year') year?: string, + @Query('quarter') quarter?: string, + ) { + const now = new Date(); + const defaultYear = now.getFullYear(); + // Default to last complete quarter + const currentQuarter = Math.ceil((now.getMonth() + 1) / 3); + const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4; + const defaultQYear = currentQuarter > 1 ? defaultYear : defaultYear - 1; + const yr = parseInt(year || '') || defaultQYear; + const q = Math.min(Math.max(parseInt(quarter || '') || defaultQuarter, 1), 4); + return this.reportsService.getQuarterlyFinancial(yr, q); + } } diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index f8addc1..ad1b52a 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -273,7 +273,8 @@ 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') + (includeInvestments ? investmentBalance : 0); - const endingBalance = parseFloat(endCash[0]?.balance || '0') + investmentBalance; + // Only include investment balances in ending balance when includeInvestments is toggled on + const endingBalance = parseFloat(endCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 0); return { from, to, @@ -444,24 +445,43 @@ export class ReportsService { } async getDashboardKPIs() { - // Total cash: ALL asset accounts (not just those named "Cash") - // Uses proper double-entry balance: debit - credit for assets - const cash = await this.tenant.query(` + // Operating cash (asset accounts, fund_type=operating) + const opCash = await this.tenant.query(` SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance FROM accounts a LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false - WHERE a.account_type = 'asset' AND a.is_active = true + WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true GROUP BY a.id ) sub `); - // Also include investment account current_value in total cash - const investmentCash = await this.tenant.query(` - SELECT COALESCE(SUM(current_value), 0) as total - FROM investment_accounts WHERE is_active = true + // Reserve cash (asset accounts, fund_type=reserve) + const resCash = await this.tenant.query(` + SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( + SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance + FROM accounts a + LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id + LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false + WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true + GROUP BY a.id + ) sub `); - const totalCash = parseFloat(cash[0]?.total || '0') + parseFloat(investmentCash[0]?.total || '0'); + // Investment accounts split by fund type + const opInv = await this.tenant.query(` + SELECT COALESCE(SUM(current_value), 0) as total + FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true + `); + const resInv = await this.tenant.query(` + SELECT COALESCE(SUM(current_value), 0) as total + FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true + `); + + const operatingCash = parseFloat(opCash[0]?.total || '0'); + const reserveCash = parseFloat(resCash[0]?.total || '0'); + const operatingInvestments = parseFloat(opInv[0]?.total || '0'); + const reserveInvestments = parseFloat(resInv[0]?.total || '0'); + const totalCash = operatingCash + reserveCash + operatingInvestments + reserveInvestments; // Receivables: sum of unpaid invoices const ar = await this.tenant.query(` @@ -469,9 +489,7 @@ export class ReportsService { FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off') `); - // Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100) - // The equity accounts track the total reserve fund position via double-entry bookkeeping - // This is the standard HOA approach — every reserve contribution/expenditure flows through equity + // Reserve fund balance via equity accounts + reserve investments const reserves = await this.tenant.query(` SELECT COALESCE(SUM(sub.balance), 0) as total FROM ( SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance @@ -482,17 +500,43 @@ export class ReportsService { GROUP BY a.id ) sub `); - // Add reserve investment account values to the reserve fund total - const reserveInvestments = await this.tenant.query(` - SELECT COALESCE(SUM(current_value), 0) as total - FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true - `); // Delinquent count (overdue invoices) const delinquent = await this.tenant.query(` SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue' `); + // Monthly interest estimate from accounts + investments with rates + const acctInterest = await this.tenant.query(` + SELECT COALESCE(SUM( + (COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) * (a.interest_rate / 100) / 12 + ), 0) as total + FROM accounts a + LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id + LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false + WHERE a.account_type = 'asset' AND a.is_active = true AND a.interest_rate > 0 + GROUP BY a.id + `); + const acctInterestTotal = (acctInterest || []).reduce((s: number, r: any) => s + parseFloat(r.total || '0'), 0); + const invInterest = await this.tenant.query(` + SELECT COALESCE(SUM(current_value * interest_rate / 100 / 12), 0) as total + FROM investment_accounts WHERE is_active = true AND interest_rate > 0 + `); + const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0'); + + // Interest earned YTD from investment accounts + const interestEarned = await this.tenant.query(` + SELECT COALESCE(SUM(interest_earned), 0) as total + FROM investment_accounts WHERE is_active = true + `); + + // Planned capital spend for current year + const currentYear = new Date().getFullYear(); + const capitalSpend = await this.tenant.query(` + SELECT COALESCE(SUM(estimated_cost), 0) as total + FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true + `, [currentYear]); + // Recent transactions const recentTx = await this.tenant.query(` SELECT je.id, je.entry_date, je.description, je.entry_type, @@ -504,9 +548,17 @@ export class ReportsService { return { total_cash: totalCash.toFixed(2), total_receivables: ar[0]?.total || '0.00', - reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2), + reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + reserveInvestments).toFixed(2), delinquent_units: parseInt(delinquent[0]?.count || '0'), recent_transactions: recentTx, + // Enhanced split data + operating_cash: operatingCash.toFixed(2), + reserve_cash: reserveCash.toFixed(2), + operating_investments: operatingInvestments.toFixed(2), + reserve_investments: reserveInvestments.toFixed(2), + est_monthly_interest: estMonthlyInterest.toFixed(2), + interest_earned_ytd: interestEarned[0]?.total || '0.00', + planned_capital_spend: capitalSpend[0]?.total || '0.00', }; } @@ -795,4 +847,168 @@ export class ReportsService { datapoints, }; } + + /** + * Quarterly Financial Report: quarter income statement, YTD income statement, + * budget vs actuals for the quarter and YTD, and over-budget items. + */ + async getQuarterlyFinancial(year: number, quarter: number) { + // Quarter date ranges + const qStartMonths = [1, 4, 7, 10]; + const qEndMonths = [3, 6, 9, 12]; + const qStart = `${year}-${String(qStartMonths[quarter - 1]).padStart(2, '0')}-01`; + const qEndMonth = qEndMonths[quarter - 1]; + const qEndDay = [31, 30, 30, 31][quarter - 1]; // Mar=31, Jun=30, Sep=30, Dec=31 + const qEnd = `${year}-${String(qEndMonth).padStart(2, '0')}-${qEndDay}`; + const ytdStart = `${year}-01-01`; + + // Quarter and YTD income statements (reuse existing method) + const quarterIS = await this.getIncomeStatement(qStart, qEnd); + const ytdIS = await this.getIncomeStatement(ytdStart, qEnd); + + // Budget data for the quarter months + const budgetMonthCols = { + 1: ['jan', 'feb', 'mar'], + 2: ['apr', 'may', 'jun'], + 3: ['jul', 'aug', 'sep'], + 4: ['oct', 'nov', 'dec_amt'], + } as Record; + const ytdMonthCols = { + 1: ['jan', 'feb', 'mar'], + 2: ['jan', 'feb', 'mar', 'apr', 'may', 'jun'], + 3: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep'], + 4: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'], + } as Record; + + const qCols = budgetMonthCols[quarter]; + const ytdCols = ytdMonthCols[quarter]; + + const budgetRows = await this.tenant.query( + `SELECT b.account_id, a.account_number, a.name, a.account_type, a.fund_type, + b.jan, b.feb, b.mar, b.apr, b.may, b.jun, + b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt + FROM budgets b + JOIN accounts a ON a.id = b.account_id + WHERE b.fiscal_year = $1`, [year], + ); + + // Actual amounts per account for the quarter and YTD + const quarterActuals = await this.tenant.query(` + SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type, + CASE + WHEN a.account_type = 'income' + THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) + ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) + END as amount + FROM accounts a + JOIN journal_entry_lines jel ON jel.account_id = a.id + 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 BETWEEN $1 AND $2 + WHERE a.account_type IN ('income', 'expense') AND a.is_active = true + GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type + `, [qStart, qEnd]); + + const ytdActuals = await this.tenant.query(` + SELECT a.id as account_id, a.account_number, a.name, a.account_type, a.fund_type, + CASE + WHEN a.account_type = 'income' + THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) + ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) + END as amount + FROM accounts a + JOIN journal_entry_lines jel ON jel.account_id = a.id + 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 BETWEEN $1 AND $2 + WHERE a.account_type IN ('income', 'expense') AND a.is_active = true + GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type + `, [ytdStart, qEnd]); + + // Build budget vs actual comparison + const actualsByIdQ = new Map(); + for (const a of quarterActuals) { + actualsByIdQ.set(a.account_id, parseFloat(a.amount) || 0); + } + const actualsByIdYTD = new Map(); + for (const a of ytdActuals) { + actualsByIdYTD.set(a.account_id, parseFloat(a.amount) || 0); + } + + const budgetVsActual: any[] = []; + const overBudgetItems: any[] = []; + + for (const b of budgetRows) { + const qBudget = qCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0); + const ytdBudget = ytdCols.reduce((sum: number, col: string) => sum + (parseFloat(b[col]) || 0), 0); + const qActual = actualsByIdQ.get(b.account_id) || 0; + const ytdActual = actualsByIdYTD.get(b.account_id) || 0; + + if (qBudget === 0 && ytdBudget === 0 && qActual === 0 && ytdActual === 0) continue; + + const qVariance = qActual - qBudget; + const ytdVariance = ytdActual - ytdBudget; + const isExpense = b.account_type === 'expense'; + + const item = { + account_id: b.account_id, + account_number: b.account_number, + name: b.name, + account_type: b.account_type, + fund_type: b.fund_type, + quarter_budget: qBudget, + quarter_actual: qActual, + quarter_variance: qVariance, + ytd_budget: ytdBudget, + ytd_actual: ytdActual, + ytd_variance: ytdVariance, + }; + budgetVsActual.push(item); + + // Flag expenses over budget by more than 10% + if (isExpense && qBudget > 0 && qActual > qBudget * 1.1) { + overBudgetItems.push({ + ...item, + variance_pct: ((qActual / qBudget - 1) * 100).toFixed(1), + }); + } + } + + // Also include accounts with actuals but no budget + for (const a of quarterActuals) { + if (!budgetRows.find((b: any) => b.account_id === a.account_id)) { + const ytdActual = actualsByIdYTD.get(a.account_id) || 0; + budgetVsActual.push({ + account_id: a.account_id, + account_number: a.account_number, + name: a.name, + account_type: a.account_type, + fund_type: a.fund_type, + quarter_budget: 0, + quarter_actual: parseFloat(a.amount) || 0, + quarter_variance: parseFloat(a.amount) || 0, + ytd_budget: 0, + ytd_actual: ytdActual, + ytd_variance: ytdActual, + }); + } + } + + // Sort: income first, then expenses, both by account number + budgetVsActual.sort((a: any, b: any) => { + if (a.account_type !== b.account_type) return a.account_type === 'income' ? -1 : 1; + return (a.account_number || '').localeCompare(b.account_number || ''); + }); + + return { + year, + quarter, + quarter_label: `Q${quarter} ${year}`, + date_range: { from: qStart, to: qEnd }, + quarter_income_statement: quarterIS, + ytd_income_statement: ytdIS, + budget_vs_actual: budgetVsActual, + over_budget_items: overBudgetItems, + }; + } } diff --git a/backend/src/modules/vendors/vendors.service.ts b/backend/src/modules/vendors/vendors.service.ts index 643444b..2d6c52f 100644 --- a/backend/src/modules/vendors/vendors.service.ts +++ b/backend/src/modules/vendors/vendors.service.ts @@ -17,10 +17,10 @@ export class VendorsService { async create(dto: any) { const rows = await this.tenant.query( - `INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, + `INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id, last_negotiated) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code, - dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null], + dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null, dto.last_negotiated || null], ); return rows[0]; } @@ -32,24 +32,25 @@ export class VendorsService { email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1), city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code), tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible), - default_account_id = COALESCE($12, default_account_id), updated_at = NOW() + default_account_id = COALESCE($12, default_account_id), last_negotiated = $13, updated_at = NOW() WHERE id = $1 RETURNING *`, [id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, - dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id], + dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id, dto.last_negotiated || null], ); return rows[0]; } async exportCSV(): Promise { const rows = await this.tenant.query( - `SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible + `SELECT name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated 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 headers = ['name', 'contact_name', 'email', 'phone', 'address_line1', 'city', 'state', 'zip_code', 'tax_id', 'is_1099_eligible', 'last_negotiated']; const lines = [headers.join(',')]; for (const r of rows) { lines.push(headers.map((h) => { - const v = r[h] ?? ''; + let v = r[h] ?? ''; + if (v instanceof Date) v = v.toISOString().split('T')[0]; const s = String(v); return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s; }).join(',')); @@ -80,20 +81,22 @@ export class VendorsService { zip_code = COALESCE(NULLIF($8, ''), zip_code), tax_id = COALESCE(NULLIF($9, ''), tax_id), is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible), + last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated), 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], + row.city, row.state, row.zip_code, row.tax_id, row.is_1099_eligible, row.last_negotiated], ); 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)`, + `INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, last_negotiated) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [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], + row.is_1099_eligible === 'true' || row.is_1099_eligible === true || false, + row.last_negotiated || null], ); created++; } diff --git a/db/migrations/008-vendor-last-negotiated.sql b/db/migrations/008-vendor-last-negotiated.sql new file mode 100644 index 0000000..1756033 --- /dev/null +++ b/db/migrations/008-vendor-last-negotiated.sql @@ -0,0 +1,16 @@ +-- Migration: Add last_negotiated date to vendors table +-- Bug & Tweak Sprint + +DO $$ +DECLARE + tenant_schema TEXT; +BEGIN + FOR tenant_schema IN + SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL + LOOP + EXECUTE format( + 'ALTER TABLE %I.vendors ADD COLUMN IF NOT EXISTS last_negotiated DATE', + tenant_schema + ); + END LOOP; +END $$; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 52d275b..ecfaedd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { SankeyPage } from './pages/reports/SankeyPage'; import { CashFlowPage } from './pages/reports/CashFlowPage'; import { AgingReportPage } from './pages/reports/AgingReportPage'; import { YearEndPage } from './pages/reports/YearEndPage'; +import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage'; import { SettingsPage } from './pages/settings/SettingsPage'; import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; @@ -135,6 +136,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 4f56d64..60c82ee 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -74,6 +74,7 @@ const navSections = [ { label: 'Aging Report', path: '/reports/aging' }, { label: 'Sankey Diagram', path: '/reports/sankey' }, { label: 'Year-End', path: '/reports/year-end' }, + { label: 'Quarterly Financial', path: '/reports/quarterly' }, ], }, ], diff --git a/frontend/src/pages/accounts/AccountsPage.tsx b/frontend/src/pages/accounts/AccountsPage.tsx index 5f6a0e3..cde937a 100644 --- a/frontend/src/pages/accounts/AccountsPage.tsx +++ b/frontend/src/pages/accounts/AccountsPage.tsx @@ -434,14 +434,44 @@ 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 + // ── Estimated monthly interest across all accounts + investments with rates ── + const acctMonthlyInterest = 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); + const invMonthlyInterest = investments + .filter((i) => i.is_active && parseFloat(i.interest_rate || '0') > 0) + .reduce((sum, i) => { + const val = parseFloat(i.current_value || i.principal || '0'); + const rate = parseFloat(i.interest_rate || '0'); + return sum + (val * (rate / 100) / 12); + }, 0); + const estMonthlyInterest = acctMonthlyInterest + invMonthlyInterest; + + // ── Per-fund cash and interest breakdowns ── + const operatingCash = accounts + .filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'operating') + .reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0); + const reserveCash = accounts + .filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'reserve') + .reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0); + const opInvTotal = operatingInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0); + const resInvTotal = reserveInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0); + const opMonthlyInterest = accounts + .filter((a) => a.is_active && !a.is_system && a.fund_type === 'operating' && parseFloat(a.interest_rate || '0') > 0) + .reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0) + + operatingInvestments + .filter((i) => parseFloat(i.interest_rate || '0') > 0) + .reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0); + const resMonthlyInterest = accounts + .filter((a) => a.is_active && !a.is_system && a.fund_type === 'reserve' && parseFloat(a.interest_rate || '0') > 0) + .reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0) + + reserveInvestments + .filter((i) => parseFloat(i.interest_rate || '0') > 0) + .reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0); // ── Adjust modal: current balance from trial balance ── const adjustCurrentBalance = adjustingAccount @@ -480,29 +510,25 @@ export function AccountsPage() { - Cash on Hand - {fmt(totalsByType['asset'] || 0)} + Operating Fund + {fmt(operatingCash)} + {opInvTotal > 0 && Investments: {fmt(opInvTotal)}} - {investmentTotal > 0 && ( - - Investments - {fmt(investmentTotal)} - - )} - {(totalsByType['liability'] || 0) > 0 && ( - - Liabilities - {fmt(totalsByType['liability'] || 0)} - - )} - Net Position + Reserve Fund + {fmt(reserveCash)} + {resInvTotal > 0 && Investments: {fmt(resInvTotal)}} + + + Total All Funds = 0 ? 'green' : 'red'}>{fmt(netPosition)} + Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)} {estMonthlyInterest > 0 && ( Est. Monthly Interest {fmt(estMonthlyInterest)} + Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)} )} @@ -1090,6 +1116,7 @@ function InvestmentMiniTable({ Name Institution Type + Fund Principal Current Value Rate @@ -1103,7 +1130,7 @@ function InvestmentMiniTable({ {investments.length === 0 && ( - + No investment accounts @@ -1117,6 +1144,11 @@ function InvestmentMiniTable({ {inv.investment_type} + + + {inv.fund_type} + + {fmt(inv.principal)} {fmt(inv.current_value || inv.principal)} {parseFloat(inv.interest_rate || '0').toFixed(2)}% diff --git a/frontend/src/pages/budgets/BudgetsPage.tsx b/frontend/src/pages/budgets/BudgetsPage.tsx index 6a02b93..1033531 100644 --- a/frontend/src/pages/budgets/BudgetsPage.tsx +++ b/frontend/src/pages/budgets/BudgetsPage.tsx @@ -236,8 +236,12 @@ export function BudgetsPage() { if (isLoading) return
; const incomeLines = budgetData.filter((b) => b.account_type === 'income'); + const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating'); + const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve'); const expenseLines = budgetData.filter((b) => b.account_type === 'expense'); - const totalIncome = incomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); + const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); + const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); + const totalIncome = totalOperatingIncome + totalReserveIncome; const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0); return ( @@ -284,17 +288,23 @@ export function BudgetsPage() { - Total Income - {fmt(totalIncome)} + Operating Income + {fmt(totalOperatingIncome)} + {totalReserveIncome > 0 && ( + + Reserve Income + {fmt(totalReserveIncome)} + + )} Total Expenses {fmt(totalExpense)} - Net - = 0 ? 'green' : 'red'}> - {fmt(totalIncome - totalExpense)} + Net (Operating) + = 0 ? 'green' : 'red'}> + {fmt(totalOperatingIncome - totalExpense)} diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index a40ed6b..aa2f3bb 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -1,12 +1,13 @@ import { Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table, - Badge, Loader, Center, + Badge, Loader, Center, Divider, } from '@mantine/core'; import { IconCash, IconFileInvoice, IconShieldCheck, IconAlertTriangle, + IconBuildingBank, } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '../../stores/authStore'; @@ -20,6 +21,14 @@ interface DashboardData { recent_transactions: { id: string; entry_date: string; description: string; entry_type: string; amount: string; }[]; + // Enhanced split data + operating_cash: string; + reserve_cash: string; + operating_investments: string; + reserve_investments: string; + est_monthly_interest: string; + interest_earned_ytd: string; + planned_capital_spend: string; } export function DashboardPage() { @@ -34,12 +43,8 @@ export function DashboardPage() { const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); - const stats = [ - { title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' }, - { title: 'Total Receivables', value: fmt(data?.total_receivables || '0'), icon: IconFileInvoice, color: 'blue' }, - { title: 'Reserve Fund', value: fmt(data?.reserve_fund_balance || '0'), icon: IconShieldCheck, color: 'violet' }, - { title: 'Delinquent Accounts', value: String(data?.delinquent_units || 0), icon: IconAlertTriangle, color: 'orange' }, - ]; + const opInv = parseFloat(data?.operating_investments || '0'); + const resInv = parseFloat(data?.reserve_investments || '0'); const entryTypeColors: Record = { manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red', @@ -67,23 +72,52 @@ export function DashboardPage() { ) : ( <> - {stats.map((stat) => ( - - -
- - {stat.title} - - - {stat.value} - -
- - - -
-
- ))} + + +
+ Operating Fund + {fmt(data?.operating_cash || '0')} + {opInv > 0 && Investments: {fmt(opInv)}} +
+ + + +
+
+ + +
+ Reserve Fund + {fmt(data?.reserve_cash || '0')} + {resInv > 0 && Investments: {fmt(resInv)}} +
+ + + +
+
+ + +
+ Total Receivables + {fmt(data?.total_receivables || '0')} +
+ + + +
+
+ + +
+ Delinquent Accounts + {String(data?.delinquent_units || 0)} +
+ + + +
+
@@ -120,17 +154,31 @@ export function DashboardPage() { Quick Stats - Cash Position - {fmt(data?.total_cash || '0')} + Operating Cash + {fmt(data?.operating_cash || '0')} + + Reserve Cash + {fmt(data?.reserve_cash || '0')} + + + + Est. Monthly Interest + {fmt(data?.est_monthly_interest || '0')} + + + Interest Earned YTD + {fmt(data?.interest_earned_ytd || '0')} + + + Planned Capital Spend + {fmt(data?.planned_capital_spend || '0')} + + Outstanding AR {fmt(data?.total_receivables || '0')} - - Reserve Funding - {fmt(data?.reserve_fund_balance || '0')} - Delinquent Units diff --git a/frontend/src/pages/reports/QuarterlyReportPage.tsx b/frontend/src/pages/reports/QuarterlyReportPage.tsx new file mode 100644 index 0000000..2937039 --- /dev/null +++ b/frontend/src/pages/reports/QuarterlyReportPage.tsx @@ -0,0 +1,292 @@ +import { useState } from 'react'; +import { + Title, Table, Group, Stack, Text, Card, Loader, Center, + Badge, SimpleGrid, Select, ThemeIcon, Alert, +} from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; +import { + IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar, +} from '@tabler/icons-react'; +import api from '../../services/api'; + +interface BudgetVsActualItem { + account_id: string; + account_number: string; + name: string; + account_type: string; + fund_type: string; + quarter_budget: number; + quarter_actual: number; + quarter_variance: number; + ytd_budget: number; + ytd_actual: number; + ytd_variance: number; + variance_pct?: string; +} + +interface IncomeStatement { + income: { name: string; amount: string; fund_type: string }[]; + expenses: { name: string; amount: string; fund_type: string }[]; + total_income: string; + total_expenses: string; + net_income: string; +} + +interface QuarterlyData { + year: number; + quarter: number; + quarter_label: string; + date_range: { from: string; to: string }; + quarter_income_statement: IncomeStatement; + ytd_income_statement: IncomeStatement; + budget_vs_actual: BudgetVsActualItem[]; + over_budget_items: BudgetVsActualItem[]; +} + +export function QuarterlyReportPage() { + const now = new Date(); + const currentQuarter = Math.ceil((now.getMonth() + 1) / 3); + const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4; + const defaultYear = currentQuarter > 1 ? now.getFullYear() : now.getFullYear() - 1; + + const [year, setYear] = useState(String(defaultYear)); + const [quarter, setQuarter] = useState(String(defaultQuarter)); + + const { data, isLoading } = useQuery({ + queryKey: ['quarterly-report', year, quarter], + queryFn: async () => { + const { data } = await api.get(`/reports/quarterly?year=${year}&quarter=${quarter}`); + return data; + }, + }); + + const fmt = (v: string | number) => + parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + + const yearOptions = Array.from({ length: 5 }, (_, i) => { + const y = now.getFullYear() - 2 + i; + return { value: String(y), label: String(y) }; + }); + + const quarterOptions = [ + { value: '1', label: 'Q1 (Jan-Mar)' }, + { value: '2', label: 'Q2 (Apr-Jun)' }, + { value: '3', label: 'Q3 (Jul-Sep)' }, + { value: '4', label: 'Q4 (Oct-Dec)' }, + ]; + + if (isLoading) return
; + + const qIS = data?.quarter_income_statement; + const ytdIS = data?.ytd_income_statement; + const bva = data?.budget_vs_actual || []; + const overBudget = data?.over_budget_items || []; + + const qRevenue = parseFloat(qIS?.total_income || '0'); + const qExpenses = parseFloat(qIS?.total_expenses || '0'); + const qNet = parseFloat(qIS?.net_income || '0'); + const ytdNet = parseFloat(ytdIS?.net_income || '0'); + + const incomeItems = bva.filter((b) => b.account_type === 'income'); + const expenseItems = bva.filter((b) => b.account_type === 'expense'); + + return ( + + + Quarterly Financial Report + + v && setQuarter(v)} w={160} /> + + + + {data && ( + + {data.quarter_label} · {new Date(data.date_range.from).toLocaleDateString()} – {new Date(data.date_range.to).toLocaleDateString()} + + )} + + {/* Summary Cards */} + + + + + Quarter Revenue + + {fmt(qRevenue)} + + + + + Quarter Expenses + + {fmt(qExpenses)} + + + + = 0 ? 'green' : 'red'} size="sm"> + + + Quarter Net + + = 0 ? 'green' : 'red'}>{fmt(qNet)} + + + + = 0 ? 'green' : 'red'} size="sm"> + + + YTD Net + + = 0 ? 'green' : 'red'}>{fmt(ytdNet)} + + + + {/* Over-Budget Alert */} + {overBudget.length > 0 && ( + + + + Over-Budget Items ({overBudget.length}) + + + + + Account + Fund + Budget + Actual + Over By + % Over + + + + {overBudget.map((item) => ( + + + {item.name} + {item.account_number} + + + + {item.fund_type} + + + {fmt(item.quarter_budget)} + {fmt(item.quarter_actual)} + {fmt(item.quarter_variance)} + + +{item.variance_pct}% + + + ))} + +
+
+ )} + + {/* Budget vs Actuals */} + + Budget vs Actuals + {bva.length === 0 ? ( + No budget or actual data for this quarter. + ) : ( +
+ + + + Account + Fund + Q Budget + Q Actual + Q Variance + YTD Budget + YTD Actual + YTD Variance + + + + {incomeItems.length > 0 && ( + + Income + + )} + {incomeItems.map((item) => ( + + ))} + {incomeItems.length > 0 && ( + + Total Income + {fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))} + {fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))} + {fmt(incomeItems.reduce((s, i) => s + i.quarter_variance, 0))} + {fmt(incomeItems.reduce((s, i) => s + i.ytd_budget, 0))} + {fmt(incomeItems.reduce((s, i) => s + i.ytd_actual, 0))} + {fmt(incomeItems.reduce((s, i) => s + i.ytd_variance, 0))} + + )} + {expenseItems.length > 0 && ( + + Expenses + + )} + {expenseItems.map((item) => ( + + ))} + {expenseItems.length > 0 && ( + + Total Expenses + {fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))} + {fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))} + {fmt(expenseItems.reduce((s, i) => s + i.quarter_variance, 0))} + {fmt(expenseItems.reduce((s, i) => s + i.ytd_budget, 0))} + {fmt(expenseItems.reduce((s, i) => s + i.ytd_actual, 0))} + {fmt(expenseItems.reduce((s, i) => s + i.ytd_variance, 0))} + + )} + +
+
+ )} +
+
+ ); +} + +function BVARow({ item, isExpense }: { item: BudgetVsActualItem; isExpense: boolean }) { + const fmt = (v: number) => + v.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + + // For expenses, over budget (positive variance) is bad (red) + // For income, under budget (negative variance) is bad (red) + const qVarianceColor = isExpense + ? (item.quarter_variance > 0 ? 'red' : 'green') + : (item.quarter_variance < 0 ? 'red' : 'green'); + const ytdVarianceColor = isExpense + ? (item.ytd_variance > 0 ? 'red' : 'green') + : (item.ytd_variance < 0 ? 'red' : 'green'); + + return ( + + + {item.name} + {item.account_number} + + + + {item.fund_type} + + + {fmt(item.quarter_budget)} + {fmt(item.quarter_actual)} + + {fmt(item.quarter_variance)} + + {fmt(item.ytd_budget)} + {fmt(item.ytd_actual)} + + {fmt(item.ytd_variance)} + + + ); +} diff --git a/frontend/src/pages/vendors/VendorsPage.tsx b/frontend/src/pages/vendors/VendorsPage.tsx index 41dcb4a..3e38800 100644 --- a/frontend/src/pages/vendors/VendorsPage.tsx +++ b/frontend/src/pages/vendors/VendorsPage.tsx @@ -3,6 +3,7 @@ import { Title, Table, Group, Button, Stack, TextInput, Modal, Switch, Badge, ActionIcon, Text, Loader, Center, } from '@mantine/core'; +import { DateInput } from '@mantine/dates'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; @@ -15,6 +16,7 @@ interface Vendor { id: string; name: string; contact_name: string; email: string; phone: string; address_line1: string; city: string; state: string; zip_code: string; tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string; + last_negotiated: string | null; } export function VendorsPage() { @@ -34,12 +36,19 @@ export function VendorsPage() { name: '', contact_name: '', email: '', phone: '', address_line1: '', city: '', state: '', zip_code: '', tax_id: '', is_1099_eligible: false, + last_negotiated: null as Date | null, }, validate: { name: (v) => (v.length > 0 ? null : 'Required') }, }); const saveMutation = useMutation({ - mutationFn: (values: any) => editing ? api.put(`/vendors/${editing.id}`, values) : api.post('/vendors', values), + mutationFn: (values: any) => { + const payload = { + ...values, + last_negotiated: values.last_negotiated ? values.last_negotiated.toISOString().split('T')[0] : null, + }; + return editing ? api.put(`/vendors/${editing.id}`, payload) : api.post('/vendors', payload); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vendors'] }); notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' }); @@ -91,6 +100,7 @@ export function VendorsPage() { phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '', state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '', is_1099_eligible: v.is_1099_eligible, + last_negotiated: v.last_negotiated ? new Date(v.last_negotiated) : null, }); open(); }; @@ -122,6 +132,7 @@ export function VendorsPage() { NameContactEmail Phone1099 + Last Negotiated YTD Payments @@ -133,11 +144,12 @@ export function VendorsPage() { {v.email} {v.phone} {v.is_1099_eligible && 1099} + {v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'} ${parseFloat(v.ytd_payments || '0').toFixed(2)} handleEdit(v)}> ))} - {filtered.length === 0 && No vendors yet} + {filtered.length === 0 && No vendors yet}
@@ -157,6 +169,7 @@ export function VendorsPage() { +