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

@@ -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>
</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">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 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>