Bug & tweak sprint: fix financial calculations, add quarterly report, enhance dashboard
- Fix Accounts page: include investment accounts in Est. Monthly Interest calc, add Fund column to investment table, split summary cards into Operating/Reserve - Fix Cash Flow: ending balance now respects includeInvestments toggle - Fix Budget Manager: separate operating/reserve income in summary cards - Fix Projects: default sort by planned_date instead of name - Add Vendors: last_negotiated date field with migration, CSV import/export - New Quarterly Financial Report: budget vs actuals, over-budget flagging, YTD - Enhance Dashboard: separate Operating/Reserve fund cards, expanded Quick Stats with monthly interest, YTD interest earned, planned capital spend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import { SankeyPage } from './pages/reports/SankeyPage';
|
||||
import { CashFlowPage } from './pages/reports/CashFlowPage';
|
||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||
@@ -135,6 +136,7 @@ export function App() {
|
||||
<Route path="reports/aging" element={<AgingReportPage />} />
|
||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||
<Route path="org-members" element={<OrgMembersPage />} />
|
||||
|
||||
@@ -74,6 +74,7 @@ const navSections = [
|
||||
{ label: 'Aging Report', path: '/reports/aging' },
|
||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||
{ label: 'Year-End', path: '/reports/year-end' },
|
||||
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -434,14 +434,44 @@ export function AccountsPage() {
|
||||
// Net position = assets + investments - liabilities
|
||||
const netPosition = (totalsByType['asset'] || 0) + investmentTotal - (totalsByType['liability'] || 0);
|
||||
|
||||
// ── Estimated monthly interest across all accounts with rates ──
|
||||
const estMonthlyInterest = accounts
|
||||
// ── Estimated monthly interest across all accounts + investments with rates ──
|
||||
const acctMonthlyInterest = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.interest_rate && parseFloat(a.interest_rate) > 0)
|
||||
.reduce((sum, a) => {
|
||||
const bal = parseFloat(a.balance || '0');
|
||||
const rate = parseFloat(a.interest_rate || '0');
|
||||
return sum + (bal * (rate / 100) / 12);
|
||||
}, 0);
|
||||
const invMonthlyInterest = investments
|
||||
.filter((i) => i.is_active && parseFloat(i.interest_rate || '0') > 0)
|
||||
.reduce((sum, i) => {
|
||||
const val = parseFloat(i.current_value || i.principal || '0');
|
||||
const rate = parseFloat(i.interest_rate || '0');
|
||||
return sum + (val * (rate / 100) / 12);
|
||||
}, 0);
|
||||
const estMonthlyInterest = acctMonthlyInterest + invMonthlyInterest;
|
||||
|
||||
// ── Per-fund cash and interest breakdowns ──
|
||||
const operatingCash = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'operating')
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||
const reserveCash = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset' && a.fund_type === 'reserve')
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||
const opInvTotal = operatingInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
const resInvTotal = reserveInvestments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
const opMonthlyInterest = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'operating' && parseFloat(a.interest_rate || '0') > 0)
|
||||
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
|
||||
+ operatingInvestments
|
||||
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
|
||||
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
|
||||
const resMonthlyInterest = accounts
|
||||
.filter((a) => a.is_active && !a.is_system && a.fund_type === 'reserve' && parseFloat(a.interest_rate || '0') > 0)
|
||||
.reduce((sum, a) => sum + (parseFloat(a.balance || '0') * (parseFloat(a.interest_rate || '0') / 100) / 12), 0)
|
||||
+ reserveInvestments
|
||||
.filter((i) => parseFloat(i.interest_rate || '0') > 0)
|
||||
.reduce((sum, i) => sum + (parseFloat(i.current_value || i.principal || '0') * (parseFloat(i.interest_rate || '0') / 100) / 12), 0);
|
||||
|
||||
// ── Adjust modal: current balance from trial balance ──
|
||||
const adjustCurrentBalance = adjustingAccount
|
||||
@@ -480,29 +510,25 @@ export function AccountsPage() {
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Cash on Hand</Text>
|
||||
<Text fw={700} size="sm" c="green">{fmt(totalsByType['asset'] || 0)}</Text>
|
||||
<Text size="xs" c="dimmed">Operating Fund</Text>
|
||||
<Text fw={700} size="sm" c="green">{fmt(operatingCash)}</Text>
|
||||
{opInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(opInvTotal)}</Text>}
|
||||
</Card>
|
||||
{investmentTotal > 0 && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Investments</Text>
|
||||
<Text fw={700} size="sm" c="teal">{fmt(investmentTotal)}</Text>
|
||||
</Card>
|
||||
)}
|
||||
{(totalsByType['liability'] || 0) > 0 && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Liabilities</Text>
|
||||
<Text fw={700} size="sm" c="red">{fmt(totalsByType['liability'] || 0)}</Text>
|
||||
</Card>
|
||||
)}
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Net Position</Text>
|
||||
<Text size="xs" c="dimmed">Reserve Fund</Text>
|
||||
<Text fw={700} size="sm" c="violet">{fmt(reserveCash)}</Text>
|
||||
{resInvTotal > 0 && <Text size="xs" c="teal">Investments: {fmt(resInvTotal)}</Text>}
|
||||
</Card>
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Total All Funds</Text>
|
||||
<Text fw={700} size="sm" c={netPosition >= 0 ? 'green' : 'red'}>{fmt(netPosition)}</Text>
|
||||
<Text size="xs" c="dimmed">Op: {fmt(operatingCash + opInvTotal)} | Res: {fmt(reserveCash + resInvTotal)}</Text>
|
||||
</Card>
|
||||
{estMonthlyInterest > 0 && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" c="dimmed">Est. Monthly Interest</Text>
|
||||
<Text fw={700} size="sm" c="blue">{fmt(estMonthlyInterest)}</Text>
|
||||
<Text size="xs" c="dimmed">Op: {fmt(opMonthlyInterest)} | Res: {fmt(resMonthlyInterest)}</Text>
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
@@ -1090,6 +1116,7 @@ function InvestmentMiniTable({
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Institution</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Principal</Table.Th>
|
||||
<Table.Th ta="right">Current Value</Table.Th>
|
||||
<Table.Th ta="right">Rate</Table.Th>
|
||||
@@ -1103,7 +1130,7 @@ function InvestmentMiniTable({
|
||||
<Table.Tbody>
|
||||
{investments.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={11}>
|
||||
<Table.Td colSpan={12}>
|
||||
<Text ta="center" c="dimmed" py="lg">No investment accounts</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -1117,6 +1144,11 @@ function InvestmentMiniTable({
|
||||
{inv.investment_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={inv.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||
{inv.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</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>
|
||||
|
||||
@@ -236,8 +236,12 @@ export function BudgetsPage() {
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
|
||||
const operatingIncomeLines = incomeLines.filter((b) => b.fund_type === 'operating');
|
||||
const reserveIncomeLines = incomeLines.filter((b) => b.fund_type === 'reserve');
|
||||
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
|
||||
const totalIncome = incomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
const totalIncome = totalOperatingIncome + totalReserveIncome;
|
||||
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
|
||||
return (
|
||||
@@ -284,17 +288,23 @@ export function BudgetsPage() {
|
||||
|
||||
<Group>
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Total Income</Text>
|
||||
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
|
||||
<Text size="xs" c="dimmed">Operating Income</Text>
|
||||
<Text fw={700} c="green">{fmt(totalOperatingIncome)}</Text>
|
||||
</Card>
|
||||
{totalReserveIncome > 0 && (
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Reserve Income</Text>
|
||||
<Text fw={700} c="violet">{fmt(totalReserveIncome)}</Text>
|
||||
</Card>
|
||||
)}
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Total Expenses</Text>
|
||||
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">Net</Text>
|
||||
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
||||
{fmt(totalIncome - totalExpense)}
|
||||
<Text size="xs" c="dimmed">Net (Operating)</Text>
|
||||
<Text fw={700} c={totalOperatingIncome - totalExpense >= 0 ? 'green' : 'red'}>
|
||||
{fmt(totalOperatingIncome - totalExpense)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Group>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
|
||||
Badge, Loader, Center,
|
||||
Badge, Loader, Center, Divider,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCash,
|
||||
IconFileInvoice,
|
||||
IconShieldCheck,
|
||||
IconAlertTriangle,
|
||||
IconBuildingBank,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
@@ -20,6 +21,14 @@ interface DashboardData {
|
||||
recent_transactions: {
|
||||
id: string; entry_date: string; description: string; entry_type: string; amount: string;
|
||||
}[];
|
||||
// Enhanced split data
|
||||
operating_cash: string;
|
||||
reserve_cash: string;
|
||||
operating_investments: string;
|
||||
reserve_investments: string;
|
||||
est_monthly_interest: string;
|
||||
interest_earned_ytd: string;
|
||||
planned_capital_spend: string;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
@@ -34,12 +43,8 @@ export function DashboardPage() {
|
||||
const fmt = (v: string | number) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const stats = [
|
||||
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' },
|
||||
{ title: 'Total Receivables', value: fmt(data?.total_receivables || '0'), icon: IconFileInvoice, color: 'blue' },
|
||||
{ title: 'Reserve Fund', value: fmt(data?.reserve_fund_balance || '0'), icon: IconShieldCheck, color: 'violet' },
|
||||
{ title: 'Delinquent Accounts', value: String(data?.delinquent_units || 0), icon: IconAlertTriangle, color: 'orange' },
|
||||
];
|
||||
const opInv = parseFloat(data?.operating_investments || '0');
|
||||
const resInv = parseFloat(data?.reserve_investments || '0');
|
||||
|
||||
const entryTypeColors: Record<string, string> = {
|
||||
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
|
||||
@@ -67,23 +72,52 @@ export function DashboardPage() {
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title} withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{stat.title}
|
||||
</Text>
|
||||
<Text fw={700} size="xl">
|
||||
{stat.value}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
|
||||
<stat.icon size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Fund</Text>
|
||||
<Text fw={700} size="xl">{fmt(data?.operating_cash || '0')}</Text>
|
||||
{opInv > 0 && <Text size="xs" c="teal">Investments: {fmt(opInv)}</Text>}
|
||||
</div>
|
||||
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||
<IconCash size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Fund</Text>
|
||||
<Text fw={700} size="xl">{fmt(data?.reserve_cash || '0')}</Text>
|
||||
{resInv > 0 && <Text size="xs" c="teal">Investments: {fmt(resInv)}</Text>}
|
||||
</div>
|
||||
<ThemeIcon color="violet" variant="light" size={48} radius="md">
|
||||
<IconShieldCheck size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Receivables</Text>
|
||||
<Text fw={700} size="xl">{fmt(data?.total_receivables || '0')}</Text>
|
||||
</div>
|
||||
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
||||
<IconFileInvoice size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Delinquent Accounts</Text>
|
||||
<Text fw={700} size="xl">{String(data?.delinquent_units || 0)}</Text>
|
||||
</div>
|
||||
<ThemeIcon color="orange" variant="light" size={48} radius="md">
|
||||
<IconAlertTriangle size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
@@ -120,17 +154,31 @@ export function DashboardPage() {
|
||||
<Title order={4}>Quick Stats</Title>
|
||||
<Stack mt="sm" gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Cash Position</Text>
|
||||
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
|
||||
<Text size="sm" c="dimmed">Operating Cash</Text>
|
||||
<Text size="sm" fw={500} c="green">{fmt(data?.operating_cash || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Reserve Cash</Text>
|
||||
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_cash || '0')}</Text>
|
||||
</Group>
|
||||
<Divider my={4} />
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Est. Monthly Interest</Text>
|
||||
<Text size="sm" fw={500} c="blue">{fmt(data?.est_monthly_interest || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Interest Earned YTD</Text>
|
||||
<Text size="sm" fw={500} c="teal">{fmt(data?.interest_earned_ytd || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Planned Capital Spend</Text>
|
||||
<Text size="sm" fw={500} c="orange">{fmt(data?.planned_capital_spend || '0')}</Text>
|
||||
</Group>
|
||||
<Divider my={4} />
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Outstanding AR</Text>
|
||||
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Reserve Funding</Text>
|
||||
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_fund_balance || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Delinquent Units</Text>
|
||||
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
|
||||
|
||||
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
292
frontend/src/pages/reports/QuarterlyReportPage.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Stack, Text, Card, Loader, Center,
|
||||
Badge, SimpleGrid, Select, ThemeIcon, Alert,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
||||
} from '@tabler/icons-react';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface BudgetVsActualItem {
|
||||
account_id: string;
|
||||
account_number: string;
|
||||
name: string;
|
||||
account_type: string;
|
||||
fund_type: string;
|
||||
quarter_budget: number;
|
||||
quarter_actual: number;
|
||||
quarter_variance: number;
|
||||
ytd_budget: number;
|
||||
ytd_actual: number;
|
||||
ytd_variance: number;
|
||||
variance_pct?: string;
|
||||
}
|
||||
|
||||
interface IncomeStatement {
|
||||
income: { name: string; amount: string; fund_type: string }[];
|
||||
expenses: { name: string; amount: string; fund_type: string }[];
|
||||
total_income: string;
|
||||
total_expenses: string;
|
||||
net_income: string;
|
||||
}
|
||||
|
||||
interface QuarterlyData {
|
||||
year: number;
|
||||
quarter: number;
|
||||
quarter_label: string;
|
||||
date_range: { from: string; to: string };
|
||||
quarter_income_statement: IncomeStatement;
|
||||
ytd_income_statement: IncomeStatement;
|
||||
budget_vs_actual: BudgetVsActualItem[];
|
||||
over_budget_items: BudgetVsActualItem[];
|
||||
}
|
||||
|
||||
export function QuarterlyReportPage() {
|
||||
const now = new Date();
|
||||
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
||||
const defaultQuarter = currentQuarter > 1 ? currentQuarter - 1 : 4;
|
||||
const defaultYear = currentQuarter > 1 ? now.getFullYear() : now.getFullYear() - 1;
|
||||
|
||||
const [year, setYear] = useState(String(defaultYear));
|
||||
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
||||
|
||||
const { data, isLoading } = useQuery<QuarterlyData>({
|
||||
queryKey: ['quarterly-report', year, quarter],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/reports/quarterly?year=${year}&quarter=${quarter}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const fmt = (v: string | number) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||
const y = now.getFullYear() - 2 + i;
|
||||
return { value: String(y), label: String(y) };
|
||||
});
|
||||
|
||||
const quarterOptions = [
|
||||
{ value: '1', label: 'Q1 (Jan-Mar)' },
|
||||
{ value: '2', label: 'Q2 (Apr-Jun)' },
|
||||
{ value: '3', label: 'Q3 (Jul-Sep)' },
|
||||
{ value: '4', label: 'Q4 (Oct-Dec)' },
|
||||
];
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
const qIS = data?.quarter_income_statement;
|
||||
const ytdIS = data?.ytd_income_statement;
|
||||
const bva = data?.budget_vs_actual || [];
|
||||
const overBudget = data?.over_budget_items || [];
|
||||
|
||||
const qRevenue = parseFloat(qIS?.total_income || '0');
|
||||
const qExpenses = parseFloat(qIS?.total_expenses || '0');
|
||||
const qNet = parseFloat(qIS?.net_income || '0');
|
||||
const ytdNet = parseFloat(ytdIS?.net_income || '0');
|
||||
|
||||
const incomeItems = bva.filter((b) => b.account_type === 'income');
|
||||
const expenseItems = bva.filter((b) => b.account_type === 'expense');
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Quarterly Financial Report</Title>
|
||||
<Group>
|
||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
|
||||
<Select data={quarterOptions} value={quarter} onChange={(v) => v && setQuarter(v)} w={160} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{data && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.quarter_label} · {new Date(data.date_range.from).toLocaleDateString()} – {new Date(data.date_range.to).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="green" size="sm"><IconTrendingUp size={14} /></ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Revenue</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c="green">{fmt(qRevenue)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="red" size="sm"><IconTrendingDown size={14} /></ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Expenses</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c="red">{fmt(qExpenses)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color={qNet >= 0 ? 'green' : 'red'} size="sm">
|
||||
<IconChartBar size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Quarter Net</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c={qNet >= 0 ? 'green' : 'red'}>{fmt(qNet)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color={ytdNet >= 0 ? 'green' : 'red'} size="sm">
|
||||
<IconChartBar size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>YTD Net</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c={ytdNet >= 0 ? 'green' : 'red'}>{fmt(ytdNet)}</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Over-Budget Alert */}
|
||||
{overBudget.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Group mb="md">
|
||||
<IconAlertTriangle size={20} color="var(--mantine-color-orange-6)" />
|
||||
<Title order={4}>Over-Budget Items ({overBudget.length})</Title>
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Account</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Budget</Table.Th>
|
||||
<Table.Th ta="right">Actual</Table.Th>
|
||||
<Table.Th ta="right">Over By</Table.Th>
|
||||
<Table.Th ta="right">% Over</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{overBudget.map((item) => (
|
||||
<Table.Tr key={item.account_id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>{item.name}</Text>
|
||||
<Text size="xs" c="dimmed">{item.account_number}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||
{item.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_actual)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c="red">{fmt(item.quarter_variance)}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Badge color="red" variant="light" size="sm">+{item.variance_pct}%</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Budget vs Actuals */}
|
||||
<Card withBorder>
|
||||
<Title order={4} mb="md">Budget vs Actuals</Title>
|
||||
{bva.length === 0 ? (
|
||||
<Alert variant="light" color="blue">No budget or actual data for this quarter.</Alert>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover style={{ minWidth: 900 }}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Account</Table.Th>
|
||||
<Table.Th>Fund</Table.Th>
|
||||
<Table.Th ta="right">Q Budget</Table.Th>
|
||||
<Table.Th ta="right">Q Actual</Table.Th>
|
||||
<Table.Th ta="right">Q Variance</Table.Th>
|
||||
<Table.Th ta="right">YTD Budget</Table.Th>
|
||||
<Table.Th ta="right">YTD Actual</Table.Th>
|
||||
<Table.Th ta="right">YTD Variance</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{incomeItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{incomeItems.map((item) => (
|
||||
<BVARow key={item.account_id} item={item} isExpense={false} />
|
||||
))}
|
||||
{incomeItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#e6f9e6' }}>
|
||||
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{expenseItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{expenseItems.map((item) => (
|
||||
<BVARow key={item.account_id} item={item} isExpense={true} />
|
||||
))}
|
||||
{expenseItems.length > 0 && (
|
||||
<Table.Tr style={{ background: '#fde8e8' }}>
|
||||
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_variance, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_budget, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_actual, 0))}</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.ytd_variance, 0))}</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BVARow({ item, isExpense }: { item: BudgetVsActualItem; isExpense: boolean }) {
|
||||
const fmt = (v: number) =>
|
||||
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
// For expenses, over budget (positive variance) is bad (red)
|
||||
// For income, under budget (negative variance) is bad (red)
|
||||
const qVarianceColor = isExpense
|
||||
? (item.quarter_variance > 0 ? 'red' : 'green')
|
||||
: (item.quarter_variance < 0 ? 'red' : 'green');
|
||||
const ytdVarianceColor = isExpense
|
||||
? (item.ytd_variance > 0 ? 'red' : 'green')
|
||||
: (item.ytd_variance < 0 ? 'red' : 'green');
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Text size="sm">{item.name}</Text>
|
||||
<Text size="xs" c="dimmed">{item.account_number}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={item.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
|
||||
{item.fund_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_budget)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.quarter_actual)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={item.quarter_variance !== 0 ? qVarianceColor : undefined}>
|
||||
{fmt(item.quarter_variance)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_budget)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(item.ytd_actual)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={item.ytd_variance !== 0 ? ytdVarianceColor : undefined}>
|
||||
{fmt(item.ytd_variance)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
17
frontend/src/pages/vendors/VendorsPage.tsx
vendored
17
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -3,6 +3,7 @@ import {
|
||||
Title, Table, Group, Button, Stack, TextInput, Modal,
|
||||
Switch, Badge, ActionIcon, Text, Loader, Center,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -15,6 +16,7 @@ interface Vendor {
|
||||
id: string; name: string; contact_name: string; email: string; phone: string;
|
||||
address_line1: string; city: string; state: string; zip_code: string;
|
||||
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
|
||||
last_negotiated: string | null;
|
||||
}
|
||||
|
||||
export function VendorsPage() {
|
||||
@@ -34,12 +36,19 @@ export function VendorsPage() {
|
||||
name: '', contact_name: '', email: '', phone: '',
|
||||
address_line1: '', city: '', state: '', zip_code: '',
|
||||
tax_id: '', is_1099_eligible: false,
|
||||
last_negotiated: null as Date | null,
|
||||
},
|
||||
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (values: any) => editing ? api.put(`/vendors/${editing.id}`, values) : api.post('/vendors', values),
|
||||
mutationFn: (values: any) => {
|
||||
const payload = {
|
||||
...values,
|
||||
last_negotiated: values.last_negotiated ? values.last_negotiated.toISOString().split('T')[0] : null,
|
||||
};
|
||||
return editing ? api.put(`/vendors/${editing.id}`, payload) : api.post('/vendors', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vendors'] });
|
||||
notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' });
|
||||
@@ -91,6 +100,7 @@ export function VendorsPage() {
|
||||
phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '',
|
||||
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
|
||||
is_1099_eligible: v.is_1099_eligible,
|
||||
last_negotiated: v.last_negotiated ? new Date(v.last_negotiated) : null,
|
||||
});
|
||||
open();
|
||||
};
|
||||
@@ -122,6 +132,7 @@ export function VendorsPage() {
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
|
||||
<Table.Th>Phone</Table.Th><Table.Th>1099</Table.Th>
|
||||
<Table.Th>Last Negotiated</Table.Th>
|
||||
<Table.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@@ -133,11 +144,12 @@ export function VendorsPage() {
|
||||
<Table.Td>{v.email}</Table.Td>
|
||||
<Table.Td>{v.phone}</Table.Td>
|
||||
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
|
||||
<Table.Td>{v.last_negotiated ? new Date(v.last_negotiated).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
|
||||
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
|
||||
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
|
||||
@@ -157,6 +169,7 @@ export function VendorsPage() {
|
||||
</Group>
|
||||
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
|
||||
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
|
||||
<DateInput label="Last Negotiated" clearable placeholder="Select date" {...form.getInputProps('last_negotiated')} />
|
||||
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user