diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index 3330374..9069fb9 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -716,14 +716,38 @@ export class ReportsService { `); 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(` - SELECT COALESCE(SUM(current_value - principal), 0) as total - FROM investment_accounts WHERE is_active = true AND current_value > principal - `); + 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]); + + // 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 - 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 @@ -749,7 +773,9 @@ export class ReportsService { operating_investments: operatingInvestments.toFixed(2), reserve_investments: reserveInvestments.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', }; } diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index c5546a2..9836106 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -306,6 +306,8 @@ interface DashboardData { reserve_investments: string; est_monthly_interest: string; interest_earned_ytd: string; + interest_last_year: string; + interest_projected: string; planned_capital_spend: string; } @@ -541,7 +543,30 @@ export function DashboardPage() { {fmt(data?.interest_earned_ytd || '0')} - Planned Capital Spend + Interest Earned YoY + + {fmt(data?.interest_projected || '0')} + proj + vs + {fmt(data?.interest_last_year || '0')} + prev + {(() => { + 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 ( + = 0 ? 'green' : 'red'} variant="light"> + {diff >= 0 ? '+' : ''}{prev > 0 ? ((diff / prev) * 100).toFixed(0) : '—'}% + + ); + })()} + + + + Capital Projects + + Planned Capital Spend {new Date().getFullYear()} {fmt(data?.planned_capital_spend || '0')} diff --git a/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx b/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx index f9e0e28..7a9c0fa 100644 --- a/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx +++ b/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx @@ -1,11 +1,12 @@ import { useState, useMemo } from 'react'; import { 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'; +import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { - IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, + IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconEdit, } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -65,6 +66,8 @@ export function MonthlyActualsPage() { const [month, setMonth] = useState(defaults.month); const [editedAmounts, setEditedAmounts] = useState>({}); const [savedJEId, setSavedJEId] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false); const queryClient = useQueryClient(); const isReadOnly = useIsReadOnly(); const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark'; @@ -84,10 +87,15 @@ export function MonthlyActualsPage() { const { data } = await api.get(`/monthly-actuals/${year}/${month}`); setEditedAmounts({}); 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; }, }); + // Whether actuals have been previously saved (reconciled) + const hasExistingActuals = !!savedJEId; + const saveMutation = useMutation({ mutationFn: async () => { const lines = (grid?.lines || []) @@ -107,6 +115,8 @@ export function MonthlyActualsPage() { queryClient.invalidateQueries({ queryKey: ['accounts'] }); queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] }); setSavedJEId(data.journal_entry_id); + setIsEditing(false); + setEditedAmounts({}); notifications.show({ message: data.message || 'Actuals saved and reconciled', color: 'green', @@ -131,6 +141,19 @@ export function MonthlyActualsPage() { setEditedAmounts((prev) => ({ ...prev, [accountId]: value })); }; + const handleEditClick = () => { + if (hasExistingActuals) { + openConfirm(); + } else { + setIsEditing(true); + } + }; + + const handleConfirmEdit = () => { + closeConfirm(); + setIsEditing(true); + }; + const lines = grid?.lines || []; const incomeLines = lines.filter((l) => l.account_type === 'income'); const expenseLines = lines.filter((l) => l.account_type === 'expense'); @@ -143,7 +166,6 @@ export function MonthlyActualsPage() { return { incomeBudget, incomeActual, expenseBudget, expenseActual }; }, [lines, editedAmounts]); - const hasChanges = Object.keys(editedAmounts).length > 0; const monthLabel = monthOptions.find((m) => m.value === month)?.label || ''; if (isLoading) return
; @@ -169,7 +191,7 @@ export function MonthlyActualsPage() { {title} {fmt(budgetTotal)} - + {fmt(actualTotal)} 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))} > @@ -204,17 +226,21 @@ export function MonthlyActualsPage() { {fmt(line.budget_amount)} - - updateAmount(line.account_id, Number(v) || 0)} - size="xs" - hideControls - decimalScale={2} - allowNegative - disabled={isReadOnly} - styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} - /> + + {isEditing ? ( + updateAmount(line.account_id, Number(v) || 0)} + size="xs" + hideControls + decimalScale={2} + allowNegative + disabled={isReadOnly} + styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }} + /> + ) : ( + {fmt(amount)} + )} v && setMonth(v)} w={150} /> - {!isReadOnly && ( + {!isReadOnly && !isEditing && ( + + )} + {!isReadOnly && isEditing && ( )} @@ -282,7 +318,7 @@ export function MonthlyActualsPage() { )} - {savedJEId && ( + {hasExistingActuals && !isEditing && ( } color="green" variant="light"> @@ -323,6 +359,26 @@ export function MonthlyActualsPage() { )} + + {/* Confirmation modal for editing reconciled actuals */} + + + + Actuals for {monthLabel} {year} have already been + reconciled. Editing will void the existing journal entry and create a new one + when you save. + + + Press Edit to proceed, or Cancel to keep the current values. + + + + + + + ); }