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:
@@ -202,6 +202,7 @@ export class TenantSchemaService {
|
|||||||
default_account_id UUID REFERENCES "${s}".accounts(id),
|
default_account_id UUID REFERENCES "${s}".accounts(id),
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
|
ytd_payments DECIMAL(15,2) DEFAULT 0.00,
|
||||||
|
last_negotiated DATE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export class ProjectsService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
const projects = await this.tenant.query(
|
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);
|
return this.computeFunding(projects);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,4 +66,20 @@ export class ReportsController {
|
|||||||
const mo = Math.min(parseInt(months || '') || 24, 48);
|
const mo = Math.min(parseInt(months || '') || 24, 48);
|
||||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,7 +273,8 @@ export class ReportsService {
|
|||||||
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||||
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
|
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
|
||||||
const beginningBalance = parseFloat(beginCash[0]?.balance || '0') + (includeInvestments ? investmentBalance : 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 {
|
return {
|
||||||
from, to,
|
from, to,
|
||||||
@@ -444,24 +445,43 @@ export class ReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDashboardKPIs() {
|
async getDashboardKPIs() {
|
||||||
// Total cash: ALL asset accounts (not just those named "Cash")
|
// Operating cash (asset accounts, fund_type=operating)
|
||||||
// Uses proper double-entry balance: debit - credit for assets
|
const opCash = await this.tenant.query(`
|
||||||
const cash = await this.tenant.query(`
|
|
||||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
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
|
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
|
GROUP BY a.id
|
||||||
) sub
|
) sub
|
||||||
`);
|
`);
|
||||||
// Also include investment account current_value in total cash
|
// Reserve cash (asset accounts, fund_type=reserve)
|
||||||
const investmentCash = await this.tenant.query(`
|
const resCash = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(current_value), 0) as total
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
FROM investment_accounts WHERE is_active = true
|
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
|
// Receivables: sum of unpaid invoices
|
||||||
const ar = await this.tenant.query(`
|
const ar = await this.tenant.query(`
|
||||||
@@ -469,9 +489,7 @@ export class ReportsService {
|
|||||||
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Reserve fund balance: use the reserve equity accounts (fund balance accounts like 3100)
|
// Reserve fund balance via equity accounts + reserve investments
|
||||||
// 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
|
|
||||||
const reserves = await this.tenant.query(`
|
const reserves = await this.tenant.query(`
|
||||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||||
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
|
||||||
@@ -482,17 +500,43 @@ export class ReportsService {
|
|||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) 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)
|
// Delinquent count (overdue invoices)
|
||||||
const delinquent = await this.tenant.query(`
|
const delinquent = await this.tenant.query(`
|
||||||
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
|
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
|
// Recent transactions
|
||||||
const recentTx = await this.tenant.query(`
|
const recentTx = await this.tenant.query(`
|
||||||
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
||||||
@@ -504,9 +548,17 @@ export class ReportsService {
|
|||||||
return {
|
return {
|
||||||
total_cash: totalCash.toFixed(2),
|
total_cash: totalCash.toFixed(2),
|
||||||
total_receivables: ar[0]?.total || '0.00',
|
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'),
|
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
||||||
recent_transactions: recentTx,
|
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,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
backend/src/modules/vendors/vendors.service.ts
vendored
27
backend/src/modules/vendors/vendors.service.ts
vendored
@@ -17,10 +17,10 @@ export class VendorsService {
|
|||||||
|
|
||||||
async create(dto: any) {
|
async create(dto: any) {
|
||||||
const rows = await this.tenant.query(
|
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)
|
`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) RETURNING *`,
|
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.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];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -32,24 +32,25 @@ export class VendorsService {
|
|||||||
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
|
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),
|
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),
|
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 *`,
|
WHERE id = $1 RETURNING *`,
|
||||||
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
|
[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];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportCSV(): Promise<string> {
|
async exportCSV(): Promise<string> {
|
||||||
const rows = await this.tenant.query(
|
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`,
|
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(',')];
|
const lines = [headers.join(',')];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
lines.push(headers.map((h) => {
|
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);
|
const s = String(v);
|
||||||
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
return s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
}).join(','));
|
}).join(','));
|
||||||
@@ -80,20 +81,22 @@ export class VendorsService {
|
|||||||
zip_code = COALESCE(NULLIF($8, ''), zip_code),
|
zip_code = COALESCE(NULLIF($8, ''), zip_code),
|
||||||
tax_id = COALESCE(NULLIF($9, ''), tax_id),
|
tax_id = COALESCE(NULLIF($9, ''), tax_id),
|
||||||
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
|
is_1099_eligible = COALESCE(NULLIF($10, '')::boolean, is_1099_eligible),
|
||||||
|
last_negotiated = COALESCE(NULLIF($11, '')::date, last_negotiated),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[existing[0].id, row.contact_name, row.email, row.phone, row.address_line1,
|
[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++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible)
|
`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)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
[name, row.contact_name || null, row.email || null, row.phone || null,
|
[name, row.contact_name || null, row.email || null, row.phone || null,
|
||||||
row.address_line1 || null, row.city || null, row.state || null,
|
row.address_line1 || null, row.city || null, row.state || null,
|
||||||
row.zip_code || null, row.tax_id || 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++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|||||||
16
db/migrations/008-vendor-last-negotiated.sql
Normal file
16
db/migrations/008-vendor-last-negotiated.sql
Normal 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 $$;
|
||||||
@@ -22,6 +22,7 @@ import { SankeyPage } from './pages/reports/SankeyPage';
|
|||||||
import { CashFlowPage } from './pages/reports/CashFlowPage';
|
import { CashFlowPage } from './pages/reports/CashFlowPage';
|
||||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||||
|
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
||||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||||
@@ -135,6 +136,7 @@ export function App() {
|
|||||||
<Route path="reports/aging" element={<AgingReportPage />} />
|
<Route path="reports/aging" element={<AgingReportPage />} />
|
||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
|
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const navSections = [
|
|||||||
{ label: 'Aging Report', path: '/reports/aging' },
|
{ label: 'Aging Report', path: '/reports/aging' },
|
||||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||||
{ label: 'Year-End', path: '/reports/year-end' },
|
{ label: 'Year-End', path: '/reports/year-end' },
|
||||||
|
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -434,14 +434,44 @@ export function AccountsPage() {
|
|||||||
// Net position = assets + investments - liabilities
|
// Net position = assets + investments - liabilities
|
||||||
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
||||||
|
|
||||||
// ── Estimated monthly interest across all accounts with rates ──
|
// ── Estimated monthly interest across all accounts + investments with rates ──
|
||||||
const estMonthlyInterest = accounts
|
const acctMonthlyInterest = accounts
|
||||||
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
||||||
.reduce((sum, a) => {
|
.reduce((sum, a) => {
|
||||||
const bal = parseFloat(a.balance || '0');
|
const bal = parseFloat(a.balance || '0');
|
||||||
const rate = parseFloat(a.interest_rate || '0');
|
const rate = parseFloat(a.interest_rate || '0');
|
||||||
return sum + (bal * (rate / 100) / 12);
|
return sum + (bal * (rate / 100) / 12);
|
||||||
}, 0);
|
}, 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 ──
|
// ── Adjust modal: current balance from trial balance ──
|
||||||
const adjustCurrentBalance = adjustingAccount
|
const adjustCurrentBalance = adjustingAccount
|
||||||
@@ -480,29 +510,25 @@ export function AccountsPage() {
|
|||||||
|
|
||||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Cash on Hand</Text>
|
<Text size="xs" c="dimmed">Operating Fund</Text>
|
||||||
<Text fw={700} size="sm" c="green">{fmt(totalsByType['asset'] || 0)}</Text>
|
<Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
|
||||||
|
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
|
||||||
</Card>
|
</Card>
|
||||||
{investmentTotal > 0 && (
|
|
||||||
<Card withBorder p="xs">
|
|
||||||
<Text size="xs" c="dimmed">Investments</Text>
|
|
||||||
<Text fw={700} size="sm" c="teal">{fmt(investmentTotal)}</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">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Net Position</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>
|
||||||
|
<Card withBorder p="xs">
|
||||||
|
<Text size="xs" c="dimmed">Total All Funds</Text>
|
||||||
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
{estMonthlyInterest > 0 && (
|
{estMonthlyInterest > 0 && (
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
||||||
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -1090,6 +1116,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Th>Name</Table.Th>
|
<Table.Th>Name</Table.Th>
|
||||||
<Table.Th>Institution</Table.Th>
|
<Table.Th>Institution</Table.Th>
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
<Table.Th ta="right">Principal</Table.Th>
|
<Table.Th ta="right">Principal</Table.Th>
|
||||||
<Table.Th ta="right">Current Value</Table.Th>
|
<Table.Th ta="right">Current Value</Table.Th>
|
||||||
<Table.Th ta="right">Rate</Table.Th>
|
<Table.Th ta="right">Rate</Table.Th>
|
||||||
@@ -1103,7 +1130,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{investments.length === 0 && (
|
{investments.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={11}>
|
<Table.Td colSpan={12}>
|
||||||
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
|
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -1117,6 +1144,11 @@ function InvestmentMiniTable({
|
|||||||
{inv.investment_type}
|
{inv.investment_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</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.principal)}</Table.Td>
|
||||||
<Table.Td ta="right" ff="monospace">{fmt(inv.current_value || 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>
|
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
|
||||||
|
|||||||
@@ -236,8 +236,12 @@ export function BudgetsPage() {
|
|||||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
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 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);
|
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -284,17 +288,23 @@ export function BudgetsPage() {
|
|||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Card withBorder p="sm">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Total Income</Text>
|
<Text size="xs" c="dimmed">Operating Income</Text>
|
||||||
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
|
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
|
||||||
</Card>
|
</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">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Total Expenses</Text>
|
<Text size="xs" c="dimmed">Total Expenses</Text>
|
||||||
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder p="sm">
|
<Card withBorder p="sm">
|
||||||
<Text size="xs" c="dimmed">Net</Text>
|
<Text size="xs" c="dimmed">Net (Operating)</Text>
|
||||||
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
||||||
{fmt(totalIncome - totalExpense)}
|
{fmt(totalOperatingIncome - totalExpense)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||||
Badge, Loader, Center,
|
Badge, Loader, Center, Divider,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCash,
|
IconCash,
|
||||||
IconFileInvoice,
|
IconFileInvoice,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
|
IconBuildingBank,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -20,6 +21,14 @@ interface DashboardData {
|
|||||||
recent_transactions: {
|
recent_transactions: {
|
||||||
id: string; entry_date: string; description: string; entry_type: string; amount: string;
|
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() {
|
export function DashboardPage() {
|
||||||
@@ -34,12 +43,8 @@ export function DashboardPage() {
|
|||||||
const fmt = (v: string | number) =>
|
const fmt = (v: string | number) =>
|
||||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
const stats = [
|
const opInv = parseFloat(data?.operating_investments || '0');
|
||||||
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' },
|
const resInv = parseFloat(data?.reserve_investments || '0');
|
||||||
{ 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 entryTypeColors: Record<string, string> = {
|
const entryTypeColors: Record<string, string> = {
|
||||||
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
||||||
@@ -67,23 +72,52 @@ export function DashboardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
{stats.map((stat) => (
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Card key={stat.title} withBorder padding="lg" radius="md">
|
<Group justify="space-between">
|
||||||
<Group justify="space-between">
|
<div>
|
||||||
<div>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
|
||||||
{stat.title}
|
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
|
||||||
</Text>
|
</div>
|
||||||
<Text fw={700} size="xl">
|
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||||
{stat.value}
|
<IconCash size={28} />
|
||||||
</Text>
|
</ThemeIcon>
|
||||||
</div>
|
</Group>
|
||||||
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
|
</Card>
|
||||||
<stat.icon size={28} />
|
<Card withBorder padding="lg" radius="md">
|
||||||
</ThemeIcon>
|
<Group justify="space-between">
|
||||||
</Group>
|
<div>
|
||||||
</Card>
|
<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>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
@@ -120,17 +154,31 @@ export function DashboardPage() {
|
|||||||
<Title order={4}>Quick Stats</Title>
|
<Title order={4}>Quick Stats</Title>
|
||||||
<Stack mt="sm" gap="xs">
|
<Stack mt="sm" gap="xs">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Cash Position</Text>
|
<Text size="sm" c="dimmed">Operating Cash</Text>
|
||||||
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
|
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
|
||||||
</Group>
|
</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">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
||||||
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
||||||
</Group>
|
</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">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
||||||
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
||||||
|
|||||||
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal 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} · {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/pages/vendors/VendorsPage.tsx
vendored
17
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -3,6 +3,7 @@ import {
|
|||||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||||
Switch, Badge, ActionIcon, Text, Loader, Center,
|
Switch, Badge, ActionIcon, Text, Loader, Center,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import { DateInput } from '@mantine/dates';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
@@ -15,6 +16,7 @@ interface Vendor {
|
|||||||
id: string; name: string; contact_name: string; email: string; phone: string;
|
id: string; name: string; contact_name: string; email: string; phone: string;
|
||||||
address_line1: string; city: string; state: string; zip_code: string;
|
address_line1: string; city: string; state: string; zip_code: string;
|
||||||
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
|
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
|
||||||
|
last_negotiated: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VendorsPage() {
|
export function VendorsPage() {
|
||||||
@@ -34,12 +36,19 @@ export function VendorsPage() {
|
|||||||
name: '', contact_name: '', email: '', phone: '',
|
name: '', contact_name: '', email: '', phone: '',
|
||||||
address_line1: '', city: '', state: '', zip_code: '',
|
address_line1: '', city: '', state: '', zip_code: '',
|
||||||
tax_id: '', is_1099_eligible: false,
|
tax_id: '', is_1099_eligible: false,
|
||||||
|
last_negotiated: null as Date | null,
|
||||||
},
|
},
|
||||||
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
|
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
||||||
notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' });
|
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 || '',
|
phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '',
|
||||||
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
|
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
|
||||||
is_1099_eligible: v.is_1099_eligible,
|
is_1099_eligible: v.is_1099_eligible,
|
||||||
|
last_negotiated: v.last_negotiated ? new Date(v.last_negotiated) : null,
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
@@ -122,6 +132,7 @@ export function VendorsPage() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
|
<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>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.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@@ -133,11 +144,12 @@ export function VendorsPage() {
|
|||||||
<Table.Td>{v.email}</Table.Td>
|
<Table.Td>{v.email}</Table.Td>
|
||||||
<Table.Td>{v.phone}</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.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 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.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
||||||
</Table.Tr>
|
</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.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
||||||
@@ -157,6 +169,7 @@ export function VendorsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
|
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
|
||||||
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
|
<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>
|
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user