734 lines
31 KiB
JavaScript
734 lines
31 KiB
JavaScript
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 1st–14th</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 15th–31st</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="flex flex-col items-center gap-2 rounded-xl bg-muted/20 px-6 py-10 text-center">
|
||
<p className="text-sm font-medium text-muted-foreground">No bills for this month</p>
|
||
<a href="/bills" className="text-xs text-primary underline underline-offset-4 hover:opacity-80 transition-opacity">Add a bill →</a>
|
||
</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>
|
||
);
|
||
}
|