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:
@@ -10,7 +10,7 @@ export class ProjectsService {
|
|||||||
// The total reserve fund balance (from reserve EQUITY accounts = fund balance) is distributed
|
// The total reserve fund balance (from reserve EQUITY accounts = fund balance) is distributed
|
||||||
// proportionally across reserve projects based on their estimated_cost
|
// proportionally across reserve projects based on their estimated_cost
|
||||||
return this.tenant.query(`
|
return this.tenant.query(`
|
||||||
WITH reserve_balance AS (
|
WITH reserve_equity AS (
|
||||||
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
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
@@ -20,6 +20,14 @@ export class ProjectsService {
|
|||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) 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 (
|
reserve_total_cost AS (
|
||||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||||
FROM projects
|
FROM projects
|
||||||
@@ -54,7 +62,7 @@ export class ProjectsService {
|
|||||||
// Only return projects that have target_year set (for the Capital Planning kanban)
|
// Only return projects that have target_year set (for the Capital Planning kanban)
|
||||||
// Uses the same dynamic reserve fund balance computation as findAll()
|
// Uses the same dynamic reserve fund balance computation as findAll()
|
||||||
return this.tenant.query(`
|
return this.tenant.query(`
|
||||||
WITH reserve_balance AS (
|
WITH reserve_equity AS (
|
||||||
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
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
@@ -64,6 +72,14 @@ export class ProjectsService {
|
|||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) 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 (
|
reserve_total_cost AS (
|
||||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||||
FROM projects
|
FROM projects
|
||||||
|
|||||||
@@ -374,9 +374,9 @@ export class ReportsService {
|
|||||||
`, [year]);
|
`, [year]);
|
||||||
|
|
||||||
// Reserve fund status from unified projects table
|
// 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(`
|
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(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
|
||||||
FROM accounts a
|
FROM accounts a
|
||||||
@@ -386,6 +386,14 @@ export class ReportsService {
|
|||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) 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 (
|
reserve_total_cost AS (
|
||||||
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
SELECT COALESCE(SUM(estimated_cost), 0) as total
|
||||||
FROM projects
|
FROM projects
|
||||||
@@ -432,7 +440,12 @@ export class ReportsService {
|
|||||||
GROUP BY a.id
|
GROUP BY a.id
|
||||||
) sub
|
) 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
|
// Receivables: sum of unpaid invoices
|
||||||
const ar = await this.tenant.query(`
|
const ar = await this.tenant.query(`
|
||||||
@@ -453,6 +466,11 @@ 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(`
|
||||||
@@ -470,7 +488,7 @@ 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').toFixed(2),
|
reserve_fund_balance: (parseFloat(reserves[0]?.total || '0') + parseFloat(reserveInvestments[0]?.total || '0')).toFixed(2),
|
||||||
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
||||||
recent_transactions: recentTx,
|
recent_transactions: recentTx,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ interface TrialBalanceEntry {
|
|||||||
|
|
||||||
const accountTypeColors: Record<string, string> = {
|
const accountTypeColors: Record<string, string> = {
|
||||||
asset: 'green',
|
asset: 'green',
|
||||||
|
'asset (investments)': 'teal',
|
||||||
liability: 'red',
|
liability: 'red',
|
||||||
equity: 'violet',
|
equity: 'violet',
|
||||||
income: 'blue',
|
income: 'blue',
|
||||||
@@ -329,6 +330,10 @@ export function AccountsPage() {
|
|||||||
const activeAccounts = filtered.filter((a) => a.is_active);
|
const activeAccounts = filtered.filter((a) => a.is_active);
|
||||||
const archivedAccounts = 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 ──
|
// ── Summary cards ──
|
||||||
const totalsByType = accounts.reduce(
|
const totalsByType = accounts.reduce(
|
||||||
(acc, a) => {
|
(acc, a) => {
|
||||||
@@ -340,6 +345,14 @@ export function AccountsPage() {
|
|||||||
{} as Record<string, number>,
|
{} 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 ──
|
// ── Adjust modal: current balance from trial balance ──
|
||||||
const adjustCurrentBalance = adjustingAccount
|
const adjustCurrentBalance = adjustingAccount
|
||||||
? parseFloat(
|
? parseFloat(
|
||||||
@@ -416,10 +429,14 @@ export function AccountsPage() {
|
|||||||
|
|
||||||
<Tabs defaultValue="all">
|
<Tabs defaultValue="all">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="all">All ({activeAccounts.length})</Tabs.Tab>
|
<Tabs.Tab value="all">All ({activeAccounts.length + investments.filter(i => i.is_active).length})</Tabs.Tab>
|
||||||
<Tabs.Tab value="operating">Operating</Tabs.Tab>
|
<Tabs.Tab value="operating">
|
||||||
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
|
Operating ({activeAccounts.filter(a => a.fund_type === 'operating').length + operatingInvestments.length})
|
||||||
<Tabs.Tab value="investments">Investments</Tabs.Tab>
|
</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 && (
|
{showArchived && archivedAccounts.length > 0 && (
|
||||||
<Tabs.Tab value="archived" color="gray">
|
<Tabs.Tab value="archived" color="gray">
|
||||||
Archived ({archivedAccounts.length})
|
Archived ({archivedAccounts.length})
|
||||||
@@ -437,6 +454,7 @@ export function AccountsPage() {
|
|||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="operating" pt="sm">
|
<Tabs.Panel value="operating" pt="sm">
|
||||||
|
<Stack>
|
||||||
<AccountTable
|
<AccountTable
|
||||||
accounts={activeAccounts.filter((a) => a.fund_type === 'operating')}
|
accounts={activeAccounts.filter((a) => a.fund_type === 'operating')}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
@@ -444,8 +462,16 @@ export function AccountsPage() {
|
|||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
/>
|
/>
|
||||||
|
{operatingInvestments.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
||||||
|
<InvestmentMiniTable investments={operatingInvestments} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="reserve" pt="sm">
|
<Tabs.Panel value="reserve" pt="sm">
|
||||||
|
<Stack>
|
||||||
<AccountTable
|
<AccountTable
|
||||||
accounts={activeAccounts.filter((a) => a.fund_type === 'reserve')}
|
accounts={activeAccounts.filter((a) => a.fund_type === 'reserve')}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
@@ -453,6 +479,13 @@ export function AccountsPage() {
|
|||||||
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
|
||||||
onAdjustBalance={handleAdjustBalance}
|
onAdjustBalance={handleAdjustBalance}
|
||||||
/>
|
/>
|
||||||
|
{reserveInvestments.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
||||||
|
<InvestmentMiniTable investments={reserveInvestments} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="investments" pt="sm">
|
<Tabs.Panel value="investments" pt="sm">
|
||||||
<InvestmentsTab investments={investments} isLoading={investmentsLoading} />
|
<InvestmentsTab investments={investments} isLoading={investmentsLoading} />
|
||||||
@@ -916,3 +949,75 @@ function InvestmentsTab({
|
|||||||
</Stack>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user