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 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:17:30 -05:00
parent 2fed5d6ce1
commit 0e82e238c1
13 changed files with 738 additions and 88 deletions

View File

@@ -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()
)`,

View File

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

View File

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

View File

@@ -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<number, string[]>;
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<number, string[]>;
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<string, number>();
for (const a of quarterActuals) {
actualsByIdQ.set(a.account_id, parseFloat(a.amount) || 0);
}
const actualsByIdYTD = new Map<string, number>();
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,
};
}
}

View File

@@ -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<string> {
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++;
}

View File

@@ -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 $$;

View File

@@ -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() {
<Route path="reports/aging" element={<AgingReportPage />} />
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} />
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} />

View File

@@ -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' },
],
},
],

View File

@@ -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() {
<SimpleGrid cols={{ base: 2, sm: 4 }}>
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Cash on Hand</Text>
<Text fw={700} size="sm" c="green">{fmt(totalsByType['asset'] || 0)}</Text>
<Text size="xs" c="dimmed">Operating Fund</Text>
<Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
</Card>
{investmentTotal > 0 && (
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Investments</Text>
<Text fw={700} size="sm" c="teal">{fmt(investmentTotal)}</Text>
<Text size="xs" c="dimmed">Reserve Fund</Text>
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
</Card>
)}
{(totalsByType['liability'] || 0) > 0 && (
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Liabilities</Text>
<Text fw={700} size="sm" c="red">{fmt(totalsByType['liability'] || 0)}</Text>
</Card>
)}
<Card withBorder p="xs">
<Text size="xs" c="dimmed">Net Position</Text>
<Text size="xs" c="dimmed">Total All Funds</Text>
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
<Text size="xs" c="dimmed">Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)}</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>
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
</Card>
)}
</SimpleGrid>
@@ -1090,6 +1116,7 @@ function InvestmentMiniTable({
<Table.Th>Name</Table.Th>
<Table.Th>Institution</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Principal</Table.Th>
<Table.Th ta="right">Current Value</Table.Th>
<Table.Th ta="right">Rate</Table.Th>
@@ -1103,7 +1130,7 @@ function InvestmentMiniTable({
<Table.Tbody>
{investments.length === 0 && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
</Table.Td>
</Table.Tr>
@@ -1117,6 +1144,11 @@ function InvestmentMiniTable({
{inv.investment_type}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || inv.principal)}</Table.Td>
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>

View File

@@ -236,8 +236,12 @@ export function BudgetsPage() {
if (isLoading) return <Center h={300}><Loader /></Center>;
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() {
<Group>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Income</Text>
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
<Text size="xs" c="dimmed">Operating Income</Text>
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
</Card>
{totalReserveIncome > 0 && (
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Reserve Income</Text>
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
</Card>
)}
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Expenses</Text>
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
</Card>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Net</Text>
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalIncome - totalExpense)}
<Text size="xs" c="dimmed">Net (Operating)</Text>
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalOperatingIncome - totalExpense)}
</Text>
</Card>
</Group>

View File

@@ -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<string, string> = {
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
@@ -67,23 +72,52 @@ export function DashboardPage() {
) : (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
{stats.map((stat) => (
<Card key={stat.title} withBorder padding="lg" radius="md">
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{stat.title}
</Text>
<Text fw={700} size="xl">
{stat.value}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
</div>
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
<stat.icon size={28} />
<ThemeIcon color="green" variant="light" size={48} radius="md">
<IconCash size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Fund</Text>
<Text fw={700} size="xl">{fmt(data?.reserve_cash || '0')}</Text>
{resInv > 0 && <Text size="xs" c="teal">Investments: {fmt(resInv)}</Text>}
</div>
<ThemeIcon color="violet" variant="light" size={48} radius="md">
<IconShieldCheck size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Receivables</Text>
<Text fw={700} size="xl">{fmt(data?.total_receivables || '0')}</Text>
</div>
<ThemeIcon color="blue" variant="light" size={48} radius="md">
<IconFileInvoice size={28} />
</ThemeIcon>
</Group>
</Card>
<Card withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Delinquent Accounts</Text>
<Text fw={700} size="xl">{String(data?.delinquent_units || 0)}</Text>
</div>
<ThemeIcon color="orange" variant="light" size={48} radius="md">
<IconAlertTriangle size={28} />
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }}>
@@ -120,17 +154,31 @@ export function DashboardPage() {
<Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Cash Position</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
<Text size="sm" c="dimmed">Operating Cash</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Reserve Cash</Text>
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
</Group>
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Planned Capital Spend</Text>
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
</Group>
<Divider my={4} />
<Group justify="space-between">
<Text size="sm" c="dimmed">Outstanding AR</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Reserve Funding</Text>
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_fund_balance || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Delinquent Units</Text>
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>

View File

@@ -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<QuarterlyData>({
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 <Center h={300}><Loader /></Center>;
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 (
<Stack>
<Group justify="space-between">
<Title order={2}>Quarterly Financial Report</Title>
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={quarterOptions} value={quarter} onChange={(v) => v && setQuarter(v)} w={160} />
</Group>
</Group>
{data && (
<Text size="sm" c="dimmed">
{data.quarter_label} &middot; {new Date(data.date_range.from).toLocaleDateString()} {new Date(data.date_range.to).toLocaleDateString()}
</Text>
)}
{/* Summary Cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }}>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="green" size="sm"><IconTrendingUp size={14} /></ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Revenue</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c="green">{fmt(qRevenue)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="red" size="sm"><IconTrendingDown size={14} /></ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Expenses</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c="red">{fmt(qExpenses)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color={qNet >= 0 ? 'green' : 'red'} size="sm">
<IconChartBar size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Net</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={qNet >= 0 ? 'green' : 'red'}>{fmt(qNet)}</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color={ytdNet >= 0 ? 'green' : 'red'} size="sm">
<IconChartBar size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>YTD Net</Text>
</Group>
<Text fw={700} size="xl" ff="monospace" c={ytdNet >= 0 ? 'green' : 'red'}>{fmt(ytdNet)}</Text>
</Card>
</SimpleGrid>
{/* Over-Budget Alert */}
{overBudget.length > 0 && (
<Card withBorder>
<Group mb="md">
<IconAlertTriangle size={20} color="var(--mantine-color-orange-6)" />
<Title order={4}>Over-Budget Items ({overBudget.length})</Title>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Budget</Table.Th>
<Table.Th ta="right">Actual</Table.Th>
<Table.Th ta="right">Over By</Table.Th>
<Table.Th ta="right">% Over</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{overBudget.map((item) => (
<Table.Tr key={item.account_id}>
<Table.Td>
<Text size="sm" fw={500}>{item.name}</Text>
<Text size="xs" c="dimmed">{item.account_number}</Text>
</Table.Td>
<Table.Td>
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{item.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_variance)}</Table.Td>
<Table.Td ta="right">
<Badge color="red" variant="light" size="sm">+{item.variance_pct}%</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
{/* Budget vs Actuals */}
<Card withBorder>
<Title order={4} mb="md">Budget vs Actuals</Title>
{bva.length === 0 ? (
<Alert variant="light" color="blue">No budget or actual data for this quarter.</Alert>
) : (
<div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 900 }}>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Q Budget</Table.Th>
<Table.Th ta="right">Q Actual</Table.Th>
<Table.Th ta="right">Q Variance</Table.Th>
<Table.Th ta="right">YTD Budget</Table.Th>
<Table.Th ta="right">YTD Actual</Table.Th>
<Table.Th ta="right">YTD Variance</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{incomeItems.length > 0 && (
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
</Table.Tr>
)}
{incomeItems.map((item) => (
<BVARow key={item.account_id} item={item} isExpense={false} />
))}
{incomeItems.length > 0 && (
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
</Table.Tr>
)}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
</Table.Tr>
)}
{expenseItems.map((item) => (
<BVARow key={item.account_id} item={item} isExpense={true} />
))}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
)}
</Card>
</Stack>
);
}
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 (
<Table.Tr>
<Table.Td>
<Text size="sm">{item.name}</Text>
<Text size="xs" c="dimmed">{item.account_number}</Text>
</Table.Td>
<Table.Td>
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{item.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c={item.quarter_variance !== 0 ? qVarianceColor : undefined}>
{fmt(item.quarter_variance)}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_budget)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_actual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c={item.ytd_variance !== 0 ? ytdVarianceColor : undefined}>
{fmt(item.ytd_variance)}
</Table.Td>
</Table.Tr>
);
}

View File

@@ -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() {
<Table.Tr>
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
<Table.Th>Phone</Table.Th><Table.Th>1099</Table.Th>
<Table.Th>Last Negotiated</Table.Th>
<Table.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@@ -133,11 +144,12 @@ export function VendorsPage() {
<Table.Td>{v.email}</Table.Td>
<Table.Td>{v.phone}</Table.Td>
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
<Table.Td>{v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'}</Table.Td>
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
@@ -157,6 +169,7 @@ export function VendorsPage() {
</Group>
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
<DateInput label="Last Negotiated" clearable placeholder="Select date" {...form.getInputProps('last_negotiated')} />
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>