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)}
+ )}