2 Commits

Author SHA1 Message Date
9a082d2950 Merge branch 'claude/ecstatic-elgamal' 2026-03-13 14:41:20 -04:00
82433955bd feat: dashboard quick stats enhancements and monthly actuals read/edit mode
Dashboard Quick Stats:
- Create Capital Projects section with "Planned Capital Spend 2026"
- Fix Interest Earned YTD to pull from actual journal entries on
  interest income accounts instead of unrealized investment gains
- Add Interest Earned YoY showing projected current year vs last year
  actuals with percentage change badge

Monthly Actuals:
- Default to read-only view when actuals are already reconciled
- Show "Edit Actuals" button instead of "Save Actuals" for reconciled months
- Add confirmation modal warning that editing will void existing journal
  entry before allowing edits
- New months without actuals open directly in edit mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:41:14 -04:00
3 changed files with 132 additions and 25 deletions

View File

@@ -716,14 +716,38 @@ export class ReportsService {
`); `);
const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0'); const estMonthlyInterest = acctInterestTotal + parseFloat(invInterest[0]?.total || '0');
// Interest earned YTD: approximate from current_value - principal (unrealized gains) // Interest earned YTD: actual interest income from journal entries for current year
const currentYear = new Date().getFullYear();
const interestEarned = await this.tenant.query(` const interestEarned = await this.tenant.query(`
SELECT COALESCE(SUM(current_value - principal), 0) as total SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
FROM investment_accounts WHERE is_active = true AND current_value > principal 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 EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
AND LOWER(a.name) LIKE '%interest%'
`, [currentYear]);
// Interest earned last year (for YoY comparison)
const interestLastYear = await this.tenant.query(`
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as total
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 EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
AND LOWER(a.name) LIKE '%interest%'
`, [currentYear - 1]);
// Projected interest for current year (YTD actual + remaining months estimated)
const currentMonth = new Date().getMonth() + 1;
const ytdInterest = parseFloat(interestEarned[0]?.total || '0');
const monthlyAvg = currentMonth > 0 ? ytdInterest / currentMonth : 0;
const projectedInterest = ytdInterest + (monthlyAvg * (12 - currentMonth));
// Planned capital spend for current year // Planned capital spend for current year
const currentYear = new Date().getFullYear();
const capitalSpend = await this.tenant.query(` const capitalSpend = await this.tenant.query(`
SELECT COALESCE(SUM(estimated_cost), 0) as total SELECT COALESCE(SUM(estimated_cost), 0) as total
FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true FROM projects WHERE target_year = $1 AND status IN ('planned', 'in_progress') AND is_active = true
@@ -749,7 +773,9 @@ export class ReportsService {
operating_investments: operatingInvestments.toFixed(2), operating_investments: operatingInvestments.toFixed(2),
reserve_investments: reserveInvestments.toFixed(2), reserve_investments: reserveInvestments.toFixed(2),
est_monthly_interest: estMonthlyInterest.toFixed(2), est_monthly_interest: estMonthlyInterest.toFixed(2),
interest_earned_ytd: interestEarned[0]?.total || '0.00', interest_earned_ytd: ytdInterest.toFixed(2),
interest_last_year: parseFloat(interestLastYear[0]?.total || '0').toFixed(2),
interest_projected: projectedInterest.toFixed(2),
planned_capital_spend: capitalSpend[0]?.total || '0.00', planned_capital_spend: capitalSpend[0]?.total || '0.00',
}; };
} }

View File

@@ -306,6 +306,8 @@ interface DashboardData {
reserve_investments: string; reserve_investments: string;
est_monthly_interest: string; est_monthly_interest: string;
interest_earned_ytd: string; interest_earned_ytd: string;
interest_last_year: string;
interest_projected: string;
planned_capital_spend: string; planned_capital_spend: string;
} }
@@ -541,7 +543,30 @@ export function DashboardPage() {
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text> <Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Planned Capital Spend</Text> <Text size="sm" c="dimmed">Interest Earned YoY</Text>
<Group gap={6}>
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_projected || '0')}</Text>
<Text size="xs" c="dimmed">proj</Text>
<Text size="xs" c="dimmed">vs</Text>
<Text size="sm" fw={500} c="gray">{fmt(data?.interest_last_year || '0')}</Text>
<Text size="xs" c="dimmed">prev</Text>
{(() => {
const proj = parseFloat(data?.interest_projected || '0');
const prev = parseFloat(data?.interest_last_year || '0');
const diff = proj - prev;
if (prev === 0 && proj === 0) return null;
return (
<Badge size="xs" color={diff >= 0 ? 'green' : 'red'} variant="light">
{diff >= 0 ? '+' : ''}{prev > 0 ? ((diff / prev) * 100).toFixed(0) : '—'}%
</Badge>
);
})()}
</Group>
</Group>
<Divider my={4} />
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Capital Projects</Text>
<Group justify="space-between">
<Text size="sm" c="dimmed">Planned Capital Spend {new Date().getFullYear()}</Text>
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text> <Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
</Group> </Group>
<Divider my={4} /> <Divider my={4} />

View File

@@ -1,11 +1,12 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { import {
Title, Table, Group, Button, Stack, Text, NumberInput, Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Card, SimpleGrid, Badge, Alert, Select, Loader, Center, Card, SimpleGrid, Badge, Alert, Modal,
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { import {
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconEdit,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
@@ -65,6 +66,8 @@ export function MonthlyActualsPage() {
const [month, setMonth] = useState(defaults.month); const [month, setMonth] = useState(defaults.month);
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({}); const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
const [savedJEId, setSavedJEId] = useState<string | null>(null); const [savedJEId, setSavedJEId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly(); const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark'; const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
@@ -84,10 +87,15 @@ export function MonthlyActualsPage() {
const { data } = await api.get(`/monthly-actuals/${year}/${month}`); const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
setEditedAmounts({}); setEditedAmounts({});
setSavedJEId(data.existing_journal_entry_id || null); setSavedJEId(data.existing_journal_entry_id || null);
// Default to read mode if actuals already exist, edit mode if new
setIsEditing(!data.existing_journal_entry_id);
return data; return data;
}, },
}); });
// Whether actuals have been previously saved (reconciled)
const hasExistingActuals = !!savedJEId;
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const lines = (grid?.lines || []) const lines = (grid?.lines || [])
@@ -107,6 +115,8 @@ export function MonthlyActualsPage() {
queryClient.invalidateQueries({ queryKey: ['accounts'] }); queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] }); queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
setSavedJEId(data.journal_entry_id); setSavedJEId(data.journal_entry_id);
setIsEditing(false);
setEditedAmounts({});
notifications.show({ notifications.show({
message: data.message || 'Actuals saved and reconciled', message: data.message || 'Actuals saved and reconciled',
color: 'green', color: 'green',
@@ -131,6 +141,19 @@ export function MonthlyActualsPage() {
setEditedAmounts((prev) => ({ ...prev, [accountId]: value })); setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
}; };
const handleEditClick = () => {
if (hasExistingActuals) {
openConfirm();
} else {
setIsEditing(true);
}
};
const handleConfirmEdit = () => {
closeConfirm();
setIsEditing(true);
};
const lines = grid?.lines || []; const lines = grid?.lines || [];
const incomeLines = lines.filter((l) => l.account_type === 'income'); const incomeLines = lines.filter((l) => l.account_type === 'income');
const expenseLines = lines.filter((l) => l.account_type === 'expense'); const expenseLines = lines.filter((l) => l.account_type === 'expense');
@@ -143,7 +166,6 @@ export function MonthlyActualsPage() {
return { incomeBudget, incomeActual, expenseBudget, expenseActual }; return { incomeBudget, incomeActual, expenseBudget, expenseActual };
}, [lines, editedAmounts]); }, [lines, editedAmounts]);
const hasChanges = Object.keys(editedAmounts).length > 0;
const monthLabel = monthOptions.find((m) => m.value === month)?.label || ''; const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
if (isLoading) return <Center h={300}><Loader /></Center>; if (isLoading) return <Center h={300}><Loader /></Center>;
@@ -169,7 +191,7 @@ export function MonthlyActualsPage() {
{title} {title}
</Table.Td> </Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td> <Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
<Table.Td /> <Table.Td ta="right" fw={700} ff="monospace">{fmt(actualTotal)}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace" <Table.Td ta="right" fw={700} ff="monospace"
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))} c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
> >
@@ -204,17 +226,21 @@ export function MonthlyActualsPage() {
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}> <Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
{fmt(line.budget_amount)} {fmt(line.budget_amount)}
</Table.Td> </Table.Td>
<Table.Td p={2} style={{ minWidth: 130 }}> <Table.Td p={isEditing ? 2 : undefined} style={{ minWidth: 130 }}>
<NumberInput {isEditing ? (
value={amount} <NumberInput
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)} value={amount}
size="xs" onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
hideControls size="xs"
decimalScale={2} hideControls
allowNegative decimalScale={2}
disabled={isReadOnly} allowNegative
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} disabled={isReadOnly}
/> styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ff="monospace" ta="right">{fmt(amount)}</Text>
)}
</Table.Td> </Table.Td>
<Table.Td <Table.Td
ta="right" ff="monospace" style={{ minWidth: 110 }} ta="right" ff="monospace" style={{ minWidth: 110 }}
@@ -238,14 +264,24 @@ export function MonthlyActualsPage() {
<Group> <Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} /> <Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} /> <Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
{!isReadOnly && ( {!isReadOnly && !isEditing && (
<Button
leftSection={<IconEdit size={16} />}
variant="light"
onClick={handleEditClick}
disabled={lines.length === 0}
>
Edit Actuals
</Button>
)}
{!isReadOnly && isEditing && (
<Button <Button
leftSection={<IconDeviceFloppy size={16} />} leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending} loading={saveMutation.isPending}
disabled={lines.length === 0} disabled={lines.length === 0}
> >
{hasChanges ? 'Save & Reconcile' : 'Save Actuals'} Save Actuals
</Button> </Button>
)} )}
</Group> </Group>
@@ -282,7 +318,7 @@ export function MonthlyActualsPage() {
</Alert> </Alert>
)} )}
{savedJEId && ( {hasExistingActuals && !isEditing && (
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light"> <Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
<Group justify="space-between" align="flex-start"> <Group justify="space-between" align="flex-start">
<Text size="sm"> <Text size="sm">
@@ -323,6 +359,26 @@ export function MonthlyActualsPage() {
<AttachmentPanel journalEntryId={savedJEId} /> <AttachmentPanel journalEntryId={savedJEId} />
</Card> </Card>
)} )}
{/* Confirmation modal for editing reconciled actuals */}
<Modal opened={confirmOpened} onClose={closeConfirm} title="Edit Reconciled Actuals" centered>
<Stack>
<Text size="sm">
Actuals for <Text span fw={700}>{monthLabel} {year}</Text> have already been
reconciled. Editing will void the existing journal entry and create a new one
when you save.
</Text>
<Text size="sm" c="dimmed">
Press Edit to proceed, or Cancel to keep the current values.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeConfirm}>Cancel</Button>
<Button color="orange" leftSection={<IconEdit size={16} />} onClick={handleConfirmEdit}>
Edit
</Button>
</Group>
</Stack>
</Modal>
</Stack> </Stack>
); );
} }