Show investments under Operating/Reserve tabs and include in fund totals

- Investment accounts now appear under their respective Operating/Reserve
  tabs in the Accounts page, with a compact sub-table showing name, type,
  institution, principal/value, rate, interest earned, and maturity info
- Investment values (current_value) are included in dashboard Total Cash KPI
- Reserve investment values are added to Reserve Fund Balance KPI and
  project funded percentage calculations
- Year-end report reserve status now includes reserve investment values
- Tab counts updated to include investment accounts per fund type
- Summary cards show separate "asset (investments)" total for visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 08:33:00 -05:00
parent 112578672e
commit c68a7e21c3
3 changed files with 163 additions and 24 deletions

View File

@@ -10,7 +10,7 @@ export class ProjectsService {
// The total reserve fund balance (from reserve EQUITY accounts = fund balance) is distributed
// proportionally across reserve projects based on their estimated_cost
return this.tenant.query(`
WITH reserve_balance AS (
WITH reserve_equity AS (
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
FROM accounts a
@@ -20,6 +20,14 @@ export class ProjectsService {
GROUP BY a.id
) sub
),
reserve_investments AS (
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
),
reserve_balance AS (
SELECT re.total + ri.total as total
FROM reserve_equity re, reserve_investments ri
),
reserve_total_cost AS (
SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects
@@ -54,7 +62,7 @@ export class ProjectsService {
// Only return projects that have target_year set (for the Capital Planning kanban)
// Uses the same dynamic reserve fund balance computation as findAll()
return this.tenant.query(`
WITH reserve_balance AS (
WITH reserve_equity AS (
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
FROM accounts a
@@ -64,6 +72,14 @@ export class ProjectsService {
GROUP BY a.id
) sub
),
reserve_investments AS (
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
),
reserve_balance AS (
SELECT re.total + ri.total as total
FROM reserve_equity re, reserve_investments ri
),
reserve_total_cost AS (
SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects

View File

@@ -374,9 +374,9 @@ export class ReportsService {
`, [year]);
// Reserve fund status from unified projects table
// Uses dynamic reserve balance computation (reserve equity accounts = fund balance)
// Uses dynamic reserve balance computation (reserve equity accounts + reserve investments)
const reserveStatus = await this.tenant.query(`
WITH reserve_balance AS (
WITH reserve_equity AS (
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
FROM accounts a
@@ -386,6 +386,14 @@ export class ReportsService {
GROUP BY a.id
) sub
),
reserve_investments AS (
SELECT COALESCE(SUM(current_value), 0) as total
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
),
reserve_balance AS (
SELECT re.total + ri.total as total
FROM reserve_equity re, reserve_investments ri
),
reserve_total_cost AS (
SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects
@@ -432,7 +440,12 @@ export class ReportsService {
GROUP BY a.id
) sub
`);
const totalCash = parseFloat(cash[0]?.total || '0');
// 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
`);
const totalCash = parseFloat(cash[0]?.total || '0') + parseFloat(investmentCash[0]?.total || '0');
// Receivables: sum of unpaid invoices
const ar = await this.tenant.query(`
@@ -453,6 +466,11 @@ 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(`
@@ -470,7 +488,7 @@ export class ReportsService {
return {
total_cash: totalCash.toFixed(2),
total_receivables: ar[0]?.total || '0.00',
reserve_fund_balance: parseFloat(reserves[0]?.total || '0').toFixed(2),
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2),
delinquent_units: parseInt(delinquent[0]?.count || '0'),
recent_transactions: recentTx,
};

View File

@@ -104,6 +104,7 @@ interface TrialBalanceEntry {
const accountTypeColors: Record<string, string> = {
asset: 'green',
'asset (investments)': 'teal',
liability: 'red',
equity: 'violet',
income: 'blue',
@@ -329,6 +330,10 @@ export function AccountsPage() {
const activeAccounts = filtered.filter((a) => a.is_active);
const archivedAccounts = filtered.filter((a) => !a.is_active);
// ── Investments split by fund type ──
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
// ── Summary cards ──
const totalsByType = accounts.reduce(
(acc, a) => {
@@ -340,6 +345,14 @@ export function AccountsPage() {
{} as Record<string, number>,
);
// Include investment principal in the asset totals
const investmentTotal = investments
.filter((i) => i.is_active)
.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
if (investmentTotal > 0) {
totalsByType['asset (investments)'] = investmentTotal;
}
// ── Adjust modal: current balance from trial balance ──
const adjustCurrentBalance = adjustingAccount
? parseFloat(
@@ -416,10 +429,14 @@ export function AccountsPage() {
<Tabs defaultValue="all">
<Tabs.List>
<Tabs.Tab value="all">All ({activeAccounts.length})</Tabs.Tab>
<Tabs.Tab value="operating">Operating</Tabs.Tab>
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
<Tabs.Tab value="investments">Investments</Tabs.Tab>
<Tabs.Tab value="all">All ({activeAccounts.length + investments.filter(i => i.is_active).length})</Tabs.Tab>
<Tabs.Tab value="operating">
Operating ({activeAccounts.filter(a => a.fund_type === 'operating').length + operatingInvestments.length})
</Tabs.Tab>
<Tabs.Tab value="reserve">
Reserve ({activeAccounts.filter(a => a.fund_type === 'reserve').length + reserveInvestments.length})
</Tabs.Tab>
<Tabs.Tab value="investments">Investments ({investments.filter(i => i.is_active).length})</Tabs.Tab>
{showArchived && archivedAccounts.length > 0 && (
<Tabs.Tab value="archived" color="gray">
Archived ({archivedAccounts.length})
@@ -437,6 +454,7 @@ export function AccountsPage() {
/>
</Tabs.Panel>
<Tabs.Panel value="operating" pt="sm">
<Stack>
<AccountTable
accounts={activeAccounts.filter((a) => a.fund_type === 'operating')}
onEdit={handleEdit}
@@ -444,8 +462,16 @@ export function AccountsPage() {
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
/>
{operatingInvestments.length > 0 && (
<>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} />
</>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="reserve" pt="sm">
<Stack>
<AccountTable
accounts={activeAccounts.filter((a) => a.fund_type === 'reserve')}
onEdit={handleEdit}
@@ -453,6 +479,13 @@ export function AccountsPage() {
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
/>
{reserveInvestments.length > 0 && (
<>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} />
</>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="investments" pt="sm">
<InvestmentsTab investments={investments} isLoading={investmentsLoading} />
@@ -916,3 +949,75 @@ function InvestmentsTab({
</Stack>
);
}
// ── Compact Investment Table for Operating/Reserve tabs ──
function InvestmentMiniTable({ investments }: { investments: Investment[] }) {
const totalValue = investments.reduce(
(s, i) => s + parseFloat(i.current_value || i.principal || '0'),
0,
);
return (
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" fw={600} c="dimmed">
{investments.length} investment{investments.length !== 1 ? 's' : ''} Total: {fmt(totalValue)}
</Text>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Institution</Table.Th>
<Table.Th ta="right">Principal / Value</Table.Th>
<Table.Th ta="right">Rate</Table.Th>
<Table.Th ta="right">Interest Earned</Table.Th>
<Table.Th>Maturity</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{investments.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td fw={500}>{inv.name}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="teal">
{inv.investment_type}
</Badge>
</Table.Td>
<Table.Td>{inv.institution || '-'}</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" ff="monospace">
{inv.interest_earned !== null ? fmt(inv.interest_earned) : '-'}
</Table.Td>
<Table.Td>
{inv.maturity_date ? (
<Group gap={4}>
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
{inv.days_remaining !== null && (
<Badge
size="xs"
color={inv.days_remaining <= 30 ? 'red' : inv.days_remaining <= 90 ? 'yellow' : 'gray'}
variant="light"
>
{inv.days_remaining}d
</Badge>
)}
</Group>
) : (
'-'
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
}