Compare commits
4 Commits
8e2456dcae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b13fbfe8c7 | |||
| 280a5996f6 | |||
| 9a082d2950 | |||
| 82433955bd |
@@ -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 using
|
||||||
|
// the rate-based est_monthly_interest (same source as the dashboard KPI)
|
||||||
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
|
const ytdInterest = parseFloat(interestEarned[0]?.total || '0');
|
||||||
|
const projectedInterest = ytdInterest + (estMonthlyInterest * (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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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,7 +226,8 @@ 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 }}>
|
||||||
|
{isEditing ? (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
|
||||||
@@ -215,6 +238,9 @@ export function MonthlyActualsPage() {
|
|||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user