BillTracker/client/pages/SummaryPage.jsx

733 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
ArrowDown,
ArrowUp,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Edit3,
GripVertical,
Loader2,
Minus,
Printer,
RotateCcw,
Save,
} from 'lucide-react';
import { api } from '@/api.js';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, fmt } from '@/lib/utils';
import { moveInArray, reorderPayload } from '@/lib/reorder';
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
function selectedFromToday() {
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}
function shiftMonth(year, month, delta) {
const next = new Date(year, month - 1 + delta, 1);
return { year: next.getFullYear(), month: next.getMonth() + 1 };
}
function monthLabel(year, month) {
return `${MONTHS[month - 1]} ${year}`;
}
function moneyClass(value) {
return value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive';
}
function StatusMark({ expense }) {
if (expense.is_skipped) {
return (
<span className="inline-flex min-w-16 items-center justify-center rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
Skipped
</span>
);
}
if (expense.is_paid) {
return (
<span className="inline-flex min-w-16 items-center justify-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-3.5 w-3.5" />
Paid
</span>
);
}
return (
<span className="inline-flex min-w-16 items-center justify-center gap-1 rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
<Minus className="h-3.5 w-3.5" />
Open
</span>
);
}
function SummaryChart({ rows = [], onStartingClick }) {
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
const chartRows = rows.map((row, index) => ({
...row,
label: row.type === 'Remaining'
? Number(row.amount) >= 0 ? 'Remaining' : 'Shortfall'
: row.type,
color: index === 0
? 'hsl(var(--chart-1))'
: index === 1
? 'hsl(var(--chart-3))'
: Number(row.amount) >= 0
? 'hsl(var(--chart-2))'
: 'hsl(var(--destructive))',
width: Math.max(2, (Math.abs(Number(row.amount) || 0) / max) * 100),
}));
return (
<div className="space-y-3">
{chartRows.map(row => {
const isStarting = row.type === 'Starting';
const clickable = isStarting && !!onStartingClick;
return (
<div key={row.type} className="grid gap-2 sm:grid-cols-[5.75rem_minmax(0,1fr)_7rem] sm:items-center">
{clickable ? (
<button
type="button"
onClick={onStartingClick}
className="text-sm font-medium text-foreground text-left underline underline-offset-2 decoration-dashed decoration-muted-foreground/50 hover:text-primary transition-colors"
title="Click to see income breakdown"
>
{row.label}
</button>
) : (
<div className="text-sm font-medium text-foreground">{row.label}</div>
)}
<div className="h-7 rounded-full bg-muted/70 p-1">
<div
className={cn('h-full rounded-full transition-[width]', clickable && 'cursor-pointer')}
style={{ width: `${Math.min(row.width, 100)}%`, backgroundColor: row.color }}
title={`${row.label}: ${fmt(row.amount)}`}
onClick={clickable ? onStartingClick : undefined}
/>
</div>
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Remaining' ? moneyClass(row.amount) : 'text-foreground')}>
{fmt(row.amount)}
</div>
</div>
);
})}
</div>
);
}
function ExpenseRow({ expense, moveControls, dragProps }) {
return (
<div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'summary-expense-row group grid gap-2 border-b border-border/60 px-1 py-3 last:border-0 sm:grid-cols-[auto_minmax(0,1fr)_7.5rem_5.5rem] sm:items-center',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}
>
<div className="summary-reorder-controls flex shrink-0 items-center gap-0.5 self-start sm:self-center">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<div className="flex flex-col">
<button
type="button"
onClick={moveControls?.onMoveUp}
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move expense up"
aria-label={`Move ${expense.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls?.onMoveDown}
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move expense down"
aria-label={`Move ${expense.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-semibold text-foreground">{expense.name}</span>
<TooltipProvider delayDuration={300}>
{expense.autopay_enabled && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300 cursor-default">
AP
</span>
</TooltipTrigger>
<TooltipContent>Autopay enabled</TooltipContent>
</Tooltip>
)}
{expense.is_subscription && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300 cursor-default">
S
</span>
</TooltipTrigger>
<TooltipContent>Subscription</TooltipContent>
</Tooltip>
)}
{(expense.has_merchant_rule || expense.has_linked_transactions) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-emerald-600 dark:text-emerald-400 cursor-default">
L
</span>
</TooltipTrigger>
<TooltipContent>Linked to bank transactions</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{expense.category_name && <span>{expense.category_name}</span>}
<span>Due day {expense.due_day}</span>
{expense.actual_amount !== null && <span>Monthly amount</span>}
</div>
</div>
<div className="text-sm font-semibold text-foreground sm:text-right">{fmt(expense.display_amount)}</div>
<div className="sm:justify-self-end" aria-label={expense.is_paid ? 'Paid' : expense.is_skipped ? 'Skipped' : 'Open'}>
<StatusMark expense={expense} />
</div>
</div>
);
}
export default function SummaryPage() {
const [selected, setSelected] = useState(selectedFromToday);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [startingFirst, setStartingFirst] = useState('0');
const [startingFifteenth, setStartingFifteenth] = useState('0');
const [startingOther, setStartingOther] = useState('0');
const [editingStarting, setEditingStarting] = useState(false);
const [incomeAmount, setIncomeAmount] = useState('0');
const [incomeLabel, setIncomeLabel] = useState('Salary');
const [editingIncome, setEditingIncome] = useState(false);
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
const loadSummary = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.summary(selected.year, selected.month);
setData(result);
setStartingFirst(String(result.starting_amounts?.first_amount ?? 0));
setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
setStartingOther(String(result.starting_amounts?.other_amount ?? 0));
setEditingStarting(false);
setIncomeAmount(String(result.income?.amount ?? 0));
setIncomeLabel(result.income?.label || 'Salary');
setEditingIncome(false);
setDraggingId(null);
setDropTargetId(null);
setMovingBillId(null);
} catch (err) {
setError(err.message || 'Summary could not be loaded.');
toast.error(err.message || 'Summary could not be loaded.');
} finally {
setLoading(false);
}
}, [selected.month, selected.year]);
useEffect(() => {
loadSummary();
}, [loadSummary]);
const summary = data?.summary || {};
const expenses = data?.expenses || [];
const starting = data?.starting_amounts || {};
const reorderEnabled = !loading && !error && expenses.length > 1;
const generatedLabel = useMemo(() => {
if (!data?.generated_at) return '';
return new Date(data.generated_at).toLocaleString();
}, [data?.generated_at]);
async function saveStartingAmounts() {
const first = Number(startingFirst);
const fifteenth = Number(startingFifteenth);
const other = Number(startingOther);
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
toast.error('Enter non-negative starting amounts.');
return;
}
setSaving(true);
try {
await api.updateMonthlyStartingAmounts({
year: selected.year,
month: selected.month,
first_amount: first,
fifteenth_amount: fifteenth,
other_amount: other,
});
toast.success('Starting amounts saved.');
await loadSummary();
} catch (err) {
toast.error(err.message || 'Starting amounts could not be saved.');
} finally {
setSaving(false);
}
}
async function saveIncome() {
const amount = Number(incomeAmount);
if (!Number.isFinite(amount) || amount < 0) {
toast.error('Enter a valid income amount.');
return;
}
const label = incomeLabel.trim() || 'Salary';
setSaving(true);
try {
await api.saveSummaryIncome({ year: selected.year, month: selected.month, amount, label });
toast.success('Income saved.');
setEditingIncome(false);
await loadSummary();
} catch (err) {
toast.error(err.message || 'Income could not be saved.');
} finally {
setSaving(false);
}
}
function moveMonth(delta) {
setSelected(current => shiftMonth(current.year, current.month, delta));
}
function resetToday() {
setSelected(selectedFromToday());
}
async function persistExpenseOrder(nextExpenses, movedId) {
setData(prev => prev ? { ...prev, expenses: nextExpenses } : prev);
setMovingBillId(movedId);
try {
await api.reorderBills(reorderPayload(nextExpenses.map(expense => ({ id: expense.bill_id }))));
toast.success('Summary order saved');
await loadSummary();
} catch (err) {
toast.error(err.message || 'Failed to save summary order.');
await loadSummary();
} finally {
setMovingBillId(null);
}
}
function reorderExpenses(fromIndex, toIndex) {
if (!reorderEnabled || fromIndex === toIndex) return;
const nextExpenses = moveInArray(expenses, fromIndex, toIndex);
persistExpenseOrder(nextExpenses, expenses[fromIndex]?.bill_id || null);
}
function moveControlsFor(expense, index) {
return {
enabled: reorderEnabled,
moving: movingBillId === expense.bill_id,
canMoveUp: index > 0,
canMoveDown: index < expenses.length - 1,
onMoveUp: () => reorderExpenses(index, index - 1),
onMoveDown: () => reorderExpenses(index, index + 1),
};
}
function dragPropsFor(expense, index) {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === expense.bill_id,
isDropTarget: dropTargetId === expense.bill_id && draggingId !== expense.bill_id,
onDragStart: (event) => {
setDraggingId(expense.bill_id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(expense.bill_id));
},
onDragEnter: () => {
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
},
onDragOver: (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
},
onDrop: (event) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = expenses.findIndex(item => item.bill_id === sourceId);
if (fromIndex >= 0) reorderExpenses(fromIndex, index);
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
}
return (
<div className="summary-page mx-auto max-w-3xl space-y-5">
<div className="summary-print-meta hidden">
<h1>BillTracker Summary</h1>
<p>{monthLabel(selected.year, selected.month)}</p>
{generatedLabel && <p>Generated {generatedLabel}</p>}
</div>
<div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Summary</h1>
<p className="mt-1 text-sm text-muted-foreground">Plan starting balance, expenses, and monthly result.</p>
</div>
<div className="summary-actions flex gap-2">
<Button variant="outline" onClick={resetToday} className="sm:w-auto">
<RotateCcw className="h-4 w-4" />
Today
</Button>
<Button onClick={() => window.print()} className="sm:w-auto">
<Printer className="h-4 w-4" />
Print / PDF
</Button>
</div>
</div>
<div className="summary-controls mx-auto flex w-full max-w-md items-center justify-between gap-2 rounded-full border border-border/70 bg-card p-1.5 shadow-sm">
<Button variant="ghost" size="icon" onClick={() => moveMonth(-1)} aria-label="Previous month">
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 px-2 text-center">
<CalendarDays className="hidden h-4 w-4 text-muted-foreground sm:block" />
<div className="truncate text-base font-semibold text-foreground">{monthLabel(selected.year, selected.month)}</div>
</div>
<Button variant="ghost" size="icon" onClick={() => moveMonth(1)} aria-label="Next month">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{loading && (
<Card>
<CardContent className="flex min-h-72 items-center justify-center p-8 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading summary...
</CardContent>
</Card>
)}
{!loading && error && (
<Card className="border-destructive/40">
<CardContent className="space-y-3 p-6">
<p className="text-sm font-medium text-destructive">{error}</p>
<Button variant="outline" onClick={loadSummary}>Retry</Button>
</CardContent>
</Card>
)}
{!loading && !error && data && (
<>
<Card className="summary-card">
<CardHeader className="pb-3">
<CardTitle className="text-xl">Monthly Plan</CardTitle>
<CardDescription>{monthLabel(data.year, data.month)}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-normal text-muted-foreground">Starting Balance</h2>
<Button
type="button"
variant="ghost"
size="sm"
className="summary-edit-actions h-7 px-2"
onClick={() => setEditingStarting(value => !value)}
>
<Edit3 className="h-3.5 w-3.5" />
{editingStarting ? 'Close' : 'Edit'}
</Button>
</div>
<div className="grid gap-3 rounded-2xl bg-muted/45 p-4 sm:grid-cols-3">
<div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-xs font-medium text-muted-foreground cursor-default w-fit">1st</div>
</TooltipTrigger>
<TooltipContent>Cash on hand for bills due on the 1st14th</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="tracker-number mt-1 text-base font-bold text-foreground">{fmt(starting.first_amount)}</div>
</div>
<div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-xs font-medium text-muted-foreground cursor-default w-fit">15th</div>
</TooltipTrigger>
<TooltipContent>Cash on hand for bills due on the 15th31st</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="tracker-number mt-1 text-base font-bold text-foreground">{fmt(starting.fifteenth_amount)}</div>
</div>
<div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-xs font-medium text-muted-foreground cursor-default w-fit">Other</div>
</TooltipTrigger>
<TooltipContent>Additional funds not tied to a specific pay period</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="tracker-number mt-1 text-base font-bold text-foreground">{fmt(starting.other_amount)}</div>
</div>
<div className="border-t border-border/60 pt-3 sm:col-span-3">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-xs font-medium text-muted-foreground">Total starting</div>
<div className="tracker-number mt-1 text-lg font-bold text-foreground">{fmt(starting.combined_amount)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Paid</div>
<div className="tracker-number mt-1 text-lg font-bold text-emerald-600 dark:text-emerald-300">{fmt(starting.paid_total)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Total remaining</div>
<div className={cn('tracker-number mt-1 text-lg font-bold', moneyClass(starting.combined_remaining || 0))}>
{fmt(starting.combined_remaining)}
</div>
</div>
</div>
</div>
</div>
{data.previous_month && (
<div className="rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
Previous month remaining: {fmt(data.previous_month.combined_remaining)}
</div>
)}
{editingStarting && (
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">1st</span>
<Input
type="number"
min="0"
step="0.01"
value={startingFirst}
onChange={event => setStartingFirst(event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">15th</span>
<Input
type="number"
min="0"
step="0.01"
value={startingFifteenth}
onChange={event => setStartingFifteenth(event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Other</span>
<Input
type="number"
min="0"
step="0.01"
value={startingOther}
onChange={event => setStartingOther(event.target.value)}
/>
</label>
<Button onClick={saveStartingAmounts} disabled={saving} className="summary-edit-actions w-full md:w-auto">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</Button>
</div>
)}
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-normal text-muted-foreground">Monthly Income</h2>
<Button
type="button"
variant="ghost"
size="sm"
className="summary-edit-actions h-7 px-2"
onClick={() => setEditingIncome(v => !v)}
>
<Edit3 className="h-3.5 w-3.5" />
{editingIncome ? 'Close' : 'Edit'}
</Button>
</div>
<div className="flex items-center justify-between rounded-2xl bg-muted/45 px-4 py-3">
<div>
<div className="text-xs font-medium text-muted-foreground">{data?.income?.label || 'Salary'}</div>
<div className="tracker-number mt-1 text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{fmt(data?.income?.amount ?? 0)}
</div>
</div>
{Number(data?.income?.amount) > 0 && Number(summary?.expense_total) > 0 && (
<div className="text-right">
<div className="text-xs text-muted-foreground">After expenses</div>
<div className={cn('tracker-number mt-1 text-lg font-bold', moneyClass(Number(data.income.amount) - Number(summary.expense_total)))}>
{fmt(Number(data.income.amount) - Number(summary.expense_total))}
</div>
</div>
)}
</div>
{editingIncome && (
<div className="grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_2fr_auto] md:items-end">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Label</span>
<Input
value={incomeLabel}
onChange={e => setIncomeLabel(e.target.value)}
placeholder="Salary"
maxLength={80}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Amount</span>
<Input
type="number"
min="0"
step="0.01"
value={incomeAmount}
onChange={e => setIncomeAmount(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveIncome(); }}
/>
</label>
<Button onClick={saveIncome} disabled={saving} className="summary-edit-actions w-full md:w-auto">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</Button>
</div>
)}
</section>
<section className="space-y-3">
<div className="flex items-end justify-between gap-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Expenses</h2>
<p className="mt-1 text-xs text-muted-foreground">Skipped bills are shown but not counted.</p>
</div>
<div className="hidden text-xs font-semibold uppercase tracking-wide text-muted-foreground sm:block">
Paid
</div>
</div>
{expenses.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
No bills found for this month.
</div>
) : (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3">
{expenses.map((expense, index) => (
<ExpenseRow
key={expense.bill_id}
expense={expense}
moveControls={moveControlsFor(expense, index)}
dragProps={dragPropsFor(expense, index)}
/>
))}
</div>
)}
</section>
<section className="space-y-3 rounded-2xl border border-border/60 bg-muted/40 p-4">
<div className="flex items-center justify-between gap-4">
<div className="text-sm font-medium text-muted-foreground">Fully Paid Expenses</div>
<div className="text-base font-bold text-foreground">{summary.paid_expense_count || 0} / {summary.expense_count || 0}</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="text-sm font-medium text-muted-foreground">Expenses</div>
<div className="text-base font-semibold text-foreground">{fmt(summary.expense_total)}</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-border/60 pt-3">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-base font-semibold text-foreground cursor-default">Result</div>
</TooltipTrigger>
<TooltipContent>Starting balance minus total planned expenses</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className={cn('text-2xl font-bold', moneyClass(summary.result || 0))}>{fmt(summary.result)}</div>
</div>
</section>
<Button onClick={() => window.print()} className="summary-actions w-full">
<Printer className="h-4 w-4" />
Print / PDF
</Button>
</CardContent>
</Card>
<Card className="summary-chart-card">
<CardHeader className="pb-3">
<CardTitle className="text-xl">Total amount per type</CardTitle>
<CardDescription>
Starting balance, planned expenses, and {Number(summary.result || 0) >= 0 ? 'remaining' : 'shortfall'} for {monthLabel(data.year, data.month)}.
</CardDescription>
</CardHeader>
<CardContent>
<SummaryChart rows={data.chart || []} />
</CardContent>
</Card>
<div className="summary-print-footer hidden text-xs text-muted-foreground">
Generated {generatedLabel || 'now'}
</div>
</>
)}
</div>
);
}