init
This commit is contained in:
parent
0ef9362817
commit
b019487423
23
HISTORY.md
23
HISTORY.md
|
|
@ -1,14 +1,21 @@
|
||||||
# Bill Tracker — Changelog
|
# Bill Tracker — Changelog
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Rewrote README.md as a simpler self-hosting guide based on the implemented app, setup, auth, data, and security behavior.
|
|
||||||
- Updated Admin authentik/OIDC issuer help text to show the authentik discovery URL example and clarify that issuer base or full discovery URL can be used.
|
|
||||||
|
|
||||||
## v0.18.1
|
## v0.18.1
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Updated Admin authentik/OIDC issuer help text to show the authentik discovery URL example and clarify that issuer base or full discovery URL can be used.
|
||||||
|
- Updated the default category seed list to the top 10 common bill categories, safely filling missing user-scoped defaults without renaming or deleting existing categories.
|
||||||
|
- Categories now return user-scoped active/inactive bill counts, payment counts, bill name previews, and compact bill detail data.
|
||||||
|
- Categories page now shows compact stat chips for active bills, inactive bills, and payments with a subtle legend.
|
||||||
|
- Removed the category-level total paid chip from Categories while keeping bill-level paid totals in expanded details.
|
||||||
|
- Category rows now expand to show bills in that category, with hover/tap summaries for chips and bill names.
|
||||||
|
- Improved Categories page mobile and tablet layout so chips wrap cleanly and expanded bill details stay readable without page-level horizontal scrolling.
|
||||||
|
- Added a Summary page for monthly planning with income, expenses, paid expense count, result/savings, and browser Print / PDF output.
|
||||||
|
- Added minimal user-scoped monthly income support for the Summary page.
|
||||||
|
- Added a user-scoped `GET /api/summary` endpoint and income save endpoint using existing bills, payments, and monthly bill state data.
|
||||||
|
- Summary includes a simple income, expenses, and savings chart without adding a new chart library.
|
||||||
|
- Cleaned up the Summary page layout with a centered planner view, display-first Monthly Plan card, compact income editing, cleaner expense rows, and a calmer chart card.
|
||||||
|
- Summary Print / PDF behavior remains browser-based and no backend/payment behavior was changed.
|
||||||
- Added a Calendar page with a month grid for user-owned bills and payments, compact day indicators, a legend, monthly progress summary, and day detail dialog.
|
- Added a Calendar page with a month grid for user-owned bills and payments, compact day indicators, a legend, monthly progress summary, and day detail dialog.
|
||||||
- Added a user-scoped `GET /api/calendar` endpoint for one-month calendar data using existing bills, payments, categories, and monthly bill state records without schema changes.
|
- Added a user-scoped `GET /api/calendar` endpoint for one-month calendar data using existing bills, payments, categories, and monthly bill state records without schema changes.
|
||||||
- Calendar status and totals respect monthly actual amount overrides, skipped bills, existing due-day clamping, and existing tracker-style late/missed status behavior.
|
- Calendar status and totals respect monthly actual amount overrides, skipped bills, existing due-day clamping, and existing tracker-style late/missed status behavior.
|
||||||
|
|
@ -22,7 +29,9 @@
|
||||||
- Tracker mobile notes stay contained in each bill row, so long notes can truncate or scroll locally without forcing the whole bill list sideways.
|
- Tracker mobile notes stay contained in each bill row, so long notes can truncate or scroll locally without forcing the whole bill list sideways.
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- No schema, auth behavior, tracker/payment/bill business logic, admin permissions, or desktop redesign changes were made.
|
- No auth behavior, tracker/payment/bill business logic, admin permissions, or desktop redesign changes were made.
|
||||||
|
- No Tracker, Bills, payment, analytics, calendar, auth, or admin behavior was changed for the Categories page updates.
|
||||||
|
- No Tracker, Bills, payment, Calendar, Analytics, auth, or admin behavior was changed for the Summary page updates.
|
||||||
|
|
||||||
## v0.18
|
## v0.18
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import LoginPage from '@/pages/LoginPage';
|
||||||
import AdminPage from '@/pages/AdminPage';
|
import AdminPage from '@/pages/AdminPage';
|
||||||
import TrackerPage from '@/pages/TrackerPage';
|
import TrackerPage from '@/pages/TrackerPage';
|
||||||
import CalendarPage from '@/pages/CalendarPage';
|
import CalendarPage from '@/pages/CalendarPage';
|
||||||
|
import SummaryPage from '@/pages/SummaryPage';
|
||||||
import BillsPage from '@/pages/BillsPage';
|
import BillsPage from '@/pages/BillsPage';
|
||||||
import CategoriesPage from '@/pages/CategoriesPage';
|
import CategoriesPage from '@/pages/CategoriesPage';
|
||||||
import SettingsPage from '@/pages/SettingsPage';
|
import SettingsPage from '@/pages/SettingsPage';
|
||||||
|
|
@ -76,6 +77,7 @@ export default function App() {
|
||||||
>
|
>
|
||||||
<Route index element={<TrackerPage />} />
|
<Route index element={<TrackerPage />} />
|
||||||
<Route path="calendar" element={<CalendarPage />} />
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
|
<Route path="summary" element={<SummaryPage />} />
|
||||||
<Route path="bills" element={<BillsPage />} />
|
<Route path="bills" element={<BillsPage />} />
|
||||||
<Route path="categories" element={<CategoriesPage />} />
|
<Route path="categories" element={<CategoriesPage />} />
|
||||||
<Route path="analytics" element={<AnalyticsPage />} />
|
<Route path="analytics" element={<AnalyticsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,10 @@ export const api = {
|
||||||
// Calendar
|
// Calendar
|
||||||
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
|
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
|
||||||
|
saveSummaryIncome: (data) => put('/summary/income', data),
|
||||||
|
|
||||||
// Bills
|
// Bills
|
||||||
bills: () => get('/bills'),
|
bills: () => get('/bills'),
|
||||||
allBills: () => get('/bills?inactive=true'),
|
allBills: () => get('/bills?inactive=true'),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, CalendarDays, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
|
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, LayoutGrid, LogOut, Menu, Receipt,
|
||||||
Settings, ShieldCheck, Tag, User, X,
|
Settings, ShieldCheck, Tag, User, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
const userNavItems = [
|
const userNavItems = [
|
||||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||||
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||||
|
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,12 @@
|
||||||
header,
|
header,
|
||||||
.analytics-screen-header,
|
.analytics-screen-header,
|
||||||
.analytics-controls,
|
.analytics-controls,
|
||||||
.analytics-actions {
|
.analytics-actions,
|
||||||
|
.summary-screen-header,
|
||||||
|
.summary-controls,
|
||||||
|
.summary-actions,
|
||||||
|
.summary-edit-actions,
|
||||||
|
.summary-income-form {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,24 +156,30 @@
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-page {
|
.analytics-page,
|
||||||
|
.summary-page {
|
||||||
color: #111827 !important;
|
color: #111827 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-report-meta,
|
.analytics-report-meta,
|
||||||
.analytics-print-footer {
|
.analytics-print-footer,
|
||||||
|
.summary-print-meta,
|
||||||
|
.summary-print-footer {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-report-meta h1 {
|
.analytics-report-meta h1,
|
||||||
|
.summary-print-meta h1 {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-report-meta p,
|
.analytics-report-meta p,
|
||||||
.analytics-print-footer {
|
.analytics-print-footer,
|
||||||
|
.summary-print-meta p,
|
||||||
|
.summary-print-footer {
|
||||||
color: #4b5563 !important;
|
color: #4b5563 !important;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin: 0.125rem 0;
|
margin: 0.125rem 0;
|
||||||
|
|
@ -178,11 +189,21 @@
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-page input {
|
||||||
|
border: 0 !important;
|
||||||
|
background: white !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: #111827 !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.analytics-chart-grid {
|
.analytics-chart-grid {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-chart {
|
.analytics-chart,
|
||||||
|
.summary-card,
|
||||||
|
.summary-chart-card {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,238 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
ChevronDown, Plus, Pencil, Trash2, ReceiptText,
|
||||||
|
} from 'lucide-react';
|
||||||
import { api } from '@/api.js';
|
import { api } from '@/api.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { InputDialog } from '@/components/ui/input-dialog';
|
import { InputDialog } from '@/components/ui/input-dialog';
|
||||||
import {
|
import {
|
||||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { buttonVariants } from '@/components/ui/button';
|
import {
|
||||||
import { cn } from '@/lib/utils';
|
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
|
|
||||||
// ─── CategoriesPage ───────────────────────────────────────────────────────────
|
function plural(count, label) {
|
||||||
|
return `${count} ${label}${count === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function billPreview(names = []) {
|
||||||
|
if (!names.length) return 'No bills in this category yet.';
|
||||||
|
const visible = names.slice(0, 4).join(', ');
|
||||||
|
const more = names.length > 4 ? `, +${names.length - 4} more` : '';
|
||||||
|
return `${visible}${more}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ value, label, tone = 'muted', details }) {
|
||||||
|
const toneClass = {
|
||||||
|
active: 'border-primary/25 bg-primary/10 text-primary',
|
||||||
|
muted: 'border-border bg-muted/55 text-muted-foreground',
|
||||||
|
info: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
tabIndex={0}
|
||||||
|
title={details || label}
|
||||||
|
aria-label={details || label}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-6 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
toneClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-64 leading-relaxed">
|
||||||
|
<p>{label}</p>
|
||||||
|
{details && details !== label && <p className="opacity-85">{details}</p>}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatChips({ category }) {
|
||||||
|
const names = billPreview(category.bill_names);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Chip
|
||||||
|
value={category.active_bill_count || 0}
|
||||||
|
label={plural(category.active_bill_count || 0, 'active bill')}
|
||||||
|
details={names}
|
||||||
|
tone="active"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
value={category.inactive_bill_count || 0}
|
||||||
|
label={plural(category.inactive_bill_count || 0, 'inactive bill')}
|
||||||
|
details={names}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
value={category.payment_count || 0}
|
||||||
|
label={plural(category.payment_count || 0, 'payment')}
|
||||||
|
details={names}
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipLegend() {
|
||||||
|
const items = [
|
||||||
|
['Active', 'active'],
|
||||||
|
['Inactive', 'muted'],
|
||||||
|
['Payments', 'info'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{items.map(([label, tone]) => (
|
||||||
|
<span key={label} className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={cn(
|
||||||
|
'h-2.5 w-2.5 rounded-full border',
|
||||||
|
tone === 'active' && 'border-primary/30 bg-primary/45',
|
||||||
|
tone === 'muted' && 'border-border bg-muted',
|
||||||
|
tone === 'info' && 'border-sky-500/30 bg-sky-500/45',
|
||||||
|
)} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ active }) {
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||||
|
active
|
||||||
|
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-border bg-muted text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BillName({ bill }) {
|
||||||
|
const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`;
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span title={label} className="font-medium text-foreground underline-offset-4 hover:underline">
|
||||||
|
{bill.name}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-72 leading-relaxed">
|
||||||
|
<p>{label}</p>
|
||||||
|
<p className="opacity-85">
|
||||||
|
{plural(bill.payment_count || 0, 'payment')} / {fmt(bill.total_paid)} paid
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpandedBills({ category }) {
|
||||||
|
const bills = category.bills || [];
|
||||||
|
|
||||||
|
if (!bills.length) {
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border/60 bg-muted/15 px-4 py-5 sm:px-6">
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-border/70 bg-background/65 p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<span>No bills in this category yet.</span>
|
||||||
|
<Button asChild variant="outline" size="sm" className="w-fit">
|
||||||
|
<Link to="/bills">Open Bills</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-6">
|
||||||
|
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold">Bill</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold">Status</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold">Expected</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold">Due</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold">Paid</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold">Payments</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold">Last Paid</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/50">
|
||||||
|
{bills.map(bill => (
|
||||||
|
<tr key={bill.id} className="hover:bg-muted/25">
|
||||||
|
<td className="px-4 py-3"><BillName bill={bill} /></td>
|
||||||
|
<td className="px-4 py-3"><StatusPill active={bill.active} /></td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono">{fmt(bill.expected_amount)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{bill.due_day}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono">{fmt(bill.total_paid)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{bill.payment_count || 0}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{fmtDate(bill.last_paid_date)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:hidden">
|
||||||
|
{bills.map(bill => (
|
||||||
|
<div key={bill.id} className="rounded-lg border border-border/60 bg-background/75 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm"><BillName bill={bill} /></p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill active={bill.active} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Expected</p>
|
||||||
|
<p className="mt-0.5 font-mono font-semibold">{fmt(bill.expected_amount)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Paid</p>
|
||||||
|
<p className="mt-0.5 font-mono font-semibold">{fmt(bill.total_paid)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Payments</p>
|
||||||
|
<p className="mt-0.5 font-semibold tabular-nums">{bill.payment_count || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Last Paid</p>
|
||||||
|
<p className="mt-0.5 font-semibold tabular-nums">{fmtDate(bill.last_paid_date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(() => new Set());
|
||||||
const addInputRef = useRef(null);
|
const addInputRef = useRef(null);
|
||||||
|
|
||||||
// Rename dialog state
|
const [renameTarget, setRenameTarget] = useState(null);
|
||||||
const [renameTarget, setRenameTarget] = useState(null); // { id, name }
|
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
// Delete dialog state
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null); // { id, name }
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|
@ -42,7 +248,21 @@ export default function CategoriesPage() {
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
// ── Add ──────────────────────────────────────────────────────────────────────
|
function toggleCategory(id) {
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowKeyDown(event, id) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleCategory(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAdd(e) {
|
async function handleAdd(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -66,9 +286,8 @@ export default function CategoriesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rename ───────────────────────────────────────────────────────────────────
|
function openRename(event, cat) {
|
||||||
|
event.stopPropagation();
|
||||||
function openRename(cat) {
|
|
||||||
setRenameTarget(cat);
|
setRenameTarget(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,9 +305,8 @@ export default function CategoriesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
function openDelete(event, cat) {
|
||||||
|
event.stopPropagation();
|
||||||
function openDelete(cat) {
|
|
||||||
setDeleteTarget(cat);
|
setDeleteTarget(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,129 +315,178 @@ export default function CategoriesPage() {
|
||||||
try {
|
try {
|
||||||
await api.deleteCategory(deleteTarget.id);
|
await api.deleteCategory(deleteTarget.id);
|
||||||
toast.success(`"${deleteTarget.name}" deleted`);
|
toast.success(`"${deleteTarget.name}" deleted`);
|
||||||
|
setExpanded(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(deleteTarget.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(err.message || 'Could not delete category.');
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────────
|
const totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<TooltipProvider delayDuration={180}>
|
||||||
{/* Page header — floats on bg-background */}
|
<div>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{categories.length} categories</p>
|
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
</div>
|
<span>{plural(categories.length, 'category')}</span>
|
||||||
</div>
|
<span aria-hidden="true">/</span>
|
||||||
|
<span>{plural(totalBills, 'bill')}</span>
|
||||||
{/* Card layer — lifted above page background */}
|
</p>
|
||||||
<div className="table-surface">
|
|
||||||
|
|
||||||
{/* Card header with inline add form */}
|
|
||||||
<div className="px-4 py-4 border-b border-border/50 flex items-center gap-3 sm:px-6">
|
|
||||||
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-sm sm:flex-row">
|
|
||||||
<Input
|
|
||||||
ref={addInputRef}
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
placeholder="New category name…"
|
|
||||||
disabled={adding}
|
|
||||||
className="h-8 text-sm"
|
|
||||||
/>
|
|
||||||
<Button type="submit" size="sm" className="h-8" disabled={adding || !newName.trim()}>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
||||||
{adding ? 'Adding…' : 'Add'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category list */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="py-16 text-center text-muted-foreground text-sm">Loading…</div>
|
|
||||||
) : categories.length === 0 ? (
|
|
||||||
<div className="py-16 text-center text-muted-foreground text-sm">
|
|
||||||
No categories yet. Add one above.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<ChipLegend />
|
||||||
<div className="divide-y divide-border/50">
|
</div>
|
||||||
{categories.map((cat) => (
|
|
||||||
<div
|
<div className="table-surface overflow-hidden">
|
||||||
key={cat.id}
|
<div className="border-b border-border/50 bg-card/65 px-4 py-4 sm:px-6">
|
||||||
className="group flex items-center justify-between gap-3 px-4 py-4 hover:bg-muted/30 transition-colors sm:px-6"
|
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-xl sm:flex-row">
|
||||||
>
|
<Input
|
||||||
<div className="flex items-center gap-3">
|
ref={addInputRef}
|
||||||
<span className="text-sm font-medium">{cat.name}</span>
|
value={newName}
|
||||||
{cat.bill_count > 0 && (
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
<span className="text-xs text-muted-foreground">
|
placeholder="New category name..."
|
||||||
{cat.bill_count} {cat.bill_count === 1 ? 'bill' : 'bills'}
|
disabled={adding}
|
||||||
</span>
|
className="h-9 text-sm"
|
||||||
)}
|
/>
|
||||||
</div>
|
<Button type="submit" size="sm" className="h-9 sm:w-auto" disabled={adding || !newName.trim()}>
|
||||||
<div className="flex items-center gap-1 opacity-70 hover:opacity-100 transition-opacity">
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
<Button
|
{adding ? 'Adding...' : 'Add'}
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</form>
|
||||||
className="h-7 w-7"
|
</div>
|
||||||
onClick={() => openRename(cat)}
|
|
||||||
>
|
{loading ? (
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
||||||
</Button>
|
) : categories.length === 0 ? (
|
||||||
<Button
|
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||||
variant="ghost"
|
No categories yet. Add one above.
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => openDelete(cat)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isExpanded = expanded.has(cat.id);
|
||||||
|
const preview = billPreview(cat.bill_names);
|
||||||
|
return (
|
||||||
|
<section key={cat.id} className="bg-card/35">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
title={preview}
|
||||||
|
onClick={() => toggleCategory(cat.id)}
|
||||||
|
onKeyDown={event => onRowKeyDown(event, cat.id)}
|
||||||
|
className={cn(
|
||||||
|
'group flex cursor-pointer flex-col gap-4 px-4 py-4 transition-colors sm:px-6 lg:flex-row lg:items-center lg:justify-between',
|
||||||
|
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||||
|
isExpanded && 'bg-muted/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-180 text-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="truncate text-sm font-semibold tracking-tight text-foreground" title={preview}>
|
||||||
|
{cat.name}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-72 leading-relaxed">
|
||||||
|
<p className="font-medium">{cat.name}</p>
|
||||||
|
<p className="opacity-85">{preview}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<StatChips category={cat} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-1 self-end opacity-80 transition-opacity group-hover:opacity-100 lg:self-auto">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(event) => openRename(event, cat)}
|
||||||
|
aria-label={`Rename ${cat.name}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={(event) => openDelete(event, cat)}
|
||||||
|
aria-label={`Delete ${cat.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && <ExpandedBills category={cat} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||||
|
<ReceiptText className="h-3.5 w-3.5" />
|
||||||
|
<span>Category totals include active and inactive bills in your account only.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>{/* /card */}
|
|
||||||
|
|
||||||
{/* Rename dialog */}
|
<InputDialog
|
||||||
<InputDialog
|
open={!!renameTarget}
|
||||||
open={!!renameTarget}
|
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
|
||||||
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
|
title="Rename Category"
|
||||||
title="Rename Category"
|
label="Name"
|
||||||
label="Name"
|
defaultValue={renameTarget?.name ?? ''}
|
||||||
defaultValue={renameTarget?.name ?? ''}
|
placeholder="Category name"
|
||||||
placeholder="Category name"
|
confirmLabel="Rename"
|
||||||
confirmLabel="Rename"
|
loading={renaming}
|
||||||
loading={renaming}
|
onConfirm={handleRename}
|
||||||
onConfirm={handleRename}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Delete dialog */}
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
<AlertDialogContent>
|
||||||
<AlertDialogContent>
|
<AlertDialogHeader>
|
||||||
<AlertDialogHeader>
|
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
|
||||||
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
|
<AlertDialogDescription>
|
||||||
<AlertDialogDescription>
|
Bills in this category will become uncategorized. No bills or payments will be deleted.
|
||||||
Bills in this category will become uncategorized. This cannot be undone.
|
</AlertDialogDescription>
|
||||||
</AlertDialogDescription>
|
</AlertDialogHeader>
|
||||||
</AlertDialogHeader>
|
<AlertDialogFooter>
|
||||||
<AlertDialogFooter>
|
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
<AlertDialogAction
|
||||||
<AlertDialogAction
|
className={cn(buttonVariants({ variant: 'destructive' }))}
|
||||||
className={cn(buttonVariants({ variant: 'destructive' }))}
|
onClick={handleDelete}
|
||||||
onClick={handleDelete}
|
disabled={deleting}
|
||||||
disabled={deleting}
|
>
|
||||||
>
|
{deleting ? 'Deleting...' : 'Delete Category'}
|
||||||
{deleting ? 'Deleting…' : 'Delete Category'}
|
</AlertDialogAction>
|
||||||
</AlertDialogAction>
|
</AlertDialogFooter>
|
||||||
</AlertDialogFooter>
|
</AlertDialogContent>
|
||||||
</AlertDialogContent>
|
</AlertDialog>
|
||||||
</AlertDialog>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,387 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit3,
|
||||||
|
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 { cn, fmt } from '@/lib/utils';
|
||||||
|
|
||||||
|
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 = [] }) {
|
||||||
|
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
|
||||||
|
const chartRows = rows.map((row, index) => ({
|
||||||
|
...row,
|
||||||
|
label: row.type === 'Savings'
|
||||||
|
? Number(row.amount) >= 0 ? 'Savings' : '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 => (
|
||||||
|
<div key={row.type} className="grid gap-2 sm:grid-cols-[5.75rem_minmax(0,1fr)_7rem] sm:items-center">
|
||||||
|
<div className="text-sm font-medium text-foreground">{row.label}</div>
|
||||||
|
<div className="h-7 rounded-full bg-muted/70 p-1">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-[width]"
|
||||||
|
style={{ width: `${Math.min(row.width, 100)}%`, backgroundColor: row.color }}
|
||||||
|
title={`${row.label}: ${fmt(row.amount)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Savings' ? moneyClass(row.amount) : 'text-foreground')}>
|
||||||
|
{fmt(row.amount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpenseRow({ expense }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 border-b border-border/60 px-1 py-3 last:border-0 sm:grid-cols-[minmax(0,1fr)_7.5rem_5.5rem] sm:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-foreground">{expense.name}</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 [incomeLabel, setIncomeLabel] = useState('Salary');
|
||||||
|
const [incomeAmount, setIncomeAmount] = useState('0');
|
||||||
|
const [editingIncome, setEditingIncome] = useState(false);
|
||||||
|
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.summary(selected.year, selected.month);
|
||||||
|
setData(result);
|
||||||
|
setIncomeLabel(result.income?.label || 'Salary');
|
||||||
|
setIncomeAmount(String(result.income?.amount ?? 0));
|
||||||
|
setEditingIncome(false);
|
||||||
|
} 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 generatedLabel = useMemo(() => {
|
||||||
|
if (!data?.generated_at) return '';
|
||||||
|
return new Date(data.generated_at).toLocaleString();
|
||||||
|
}, [data?.generated_at]);
|
||||||
|
|
||||||
|
async function saveIncome() {
|
||||||
|
const amount = Number(incomeAmount);
|
||||||
|
if (!Number.isFinite(amount) || amount < 0) {
|
||||||
|
toast.error('Enter a valid income amount.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.saveSummaryIncome({
|
||||||
|
year: selected.year,
|
||||||
|
month: selected.month,
|
||||||
|
label: incomeLabel.trim() || 'Salary',
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
toast.success('Income saved.');
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 income, 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/95 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-wide text-muted-foreground">Income</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="summary-edit-actions h-7 px-2"
|
||||||
|
onClick={() => setEditingIncome(value => !value)}
|
||||||
|
>
|
||||||
|
<Edit3 className="h-3.5 w-3.5" />
|
||||||
|
{editingIncome ? 'Close' : 'Edit'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="summary-income-display flex items-center justify-between gap-4 rounded-2xl bg-muted/45 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-foreground">{data.income?.label || 'Salary'}</div>
|
||||||
|
{Number(summary.income_total || 0) === 0 && (
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">Add income to calculate savings.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-lg font-bold text-foreground">{fmt(summary.income_total)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingIncome && (
|
||||||
|
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[minmax(0,1fr)_10rem_auto] md:items-end">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Label</span>
|
||||||
|
<Input value={incomeLabel} onChange={event => setIncomeLabel(event.target.value)} placeholder="Salary" />
|
||||||
|
</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={event => setIncomeAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</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 => (
|
||||||
|
<ExpenseRow key={expense.bill_id} expense={expense} />
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
<div className="text-base font-semibold text-foreground">Result</div>
|
||||||
|
<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>
|
||||||
|
Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,18 @@ const fs = require('fs');
|
||||||
|
|
||||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bills.db');
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bills.db');
|
||||||
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
||||||
const DEFAULT_CATEGORIES = ['Housing', 'Utilities', 'Subscriptions', 'Insurance', 'Loans', 'Other'];
|
const DEFAULT_CATEGORIES = [
|
||||||
|
'Housing',
|
||||||
|
'Utilities',
|
||||||
|
'Credit Cards',
|
||||||
|
'Loans',
|
||||||
|
'Insurance',
|
||||||
|
'Subscriptions',
|
||||||
|
'Phone & Internet',
|
||||||
|
'Transportation',
|
||||||
|
'Medical',
|
||||||
|
'Other',
|
||||||
|
];
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
|
|
||||||
|
|
@ -141,6 +152,22 @@ function runMigrations() {
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
|
||||||
console.log('[migration] monthly_bill_state table ensured');
|
console.log('[migration] monthly_bill_state table ensured');
|
||||||
|
|
||||||
|
// -- monthly_income: per-user monthly income for Summary planning (v0.18.1)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS monthly_income (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||||
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||||
|
label TEXT NOT NULL DEFAULT 'Salary',
|
||||||
|
amount REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id, year, month)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
|
||||||
|
|
||||||
// ── import_sessions: temporary preview state (v0.38) ─────────────────────
|
// ── import_sessions: temporary preview state (v0.38) ─────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS import_sessions (
|
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||||
|
|
@ -349,14 +376,8 @@ function seedDefaults() {
|
||||||
insert.run(key, value);
|
insert.run(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertCat = db.prepare(
|
// Category defaults are user-scoped. They are applied by
|
||||||
'INSERT INTO categories (name) VALUES (?)'
|
// ensureUserDefaultCategories(userId) when user-owned category/bill data is read.
|
||||||
);
|
|
||||||
|
|
||||||
for (const name of DEFAULT_CATEGORIES) {
|
|
||||||
const existing = db.prepare('SELECT id FROM categories WHERE user_id IS NULL AND name = ? COLLATE NOCASE').get(name);
|
|
||||||
if (!existing) insertCat.run(name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureUserDefaultCategories(userId) {
|
function ensureUserDefaultCategories(userId) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,60 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
ensureUserDefaultCategories(req.user.id);
|
ensureUserDefaultCategories(req.user.id);
|
||||||
res.json(db.prepare('SELECT * FROM categories WHERE user_id = ? ORDER BY name ASC').all(req.user.id));
|
|
||||||
|
const categories = db.prepare(`
|
||||||
|
SELECT id, user_id, name, created_at, updated_at
|
||||||
|
FROM categories
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY name COLLATE NOCASE ASC
|
||||||
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
const billsByCategory = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
b.id,
|
||||||
|
b.category_id,
|
||||||
|
b.name,
|
||||||
|
b.active,
|
||||||
|
b.expected_amount,
|
||||||
|
b.due_day,
|
||||||
|
COUNT(p.id) AS payment_count,
|
||||||
|
COALESCE(SUM(p.amount), 0) AS total_paid,
|
||||||
|
MAX(p.paid_date) AS last_paid_date
|
||||||
|
FROM bills b
|
||||||
|
LEFT JOIN payments p
|
||||||
|
ON p.bill_id = b.id
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
WHERE b.user_id = ?
|
||||||
|
AND b.category_id = ?
|
||||||
|
GROUP BY b.id
|
||||||
|
ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const shaped = categories.map(category => {
|
||||||
|
const bills = billsByCategory.all(req.user.id, category.id).map(bill => ({
|
||||||
|
...bill,
|
||||||
|
active: !!bill.active,
|
||||||
|
payment_count: Number(bill.payment_count || 0),
|
||||||
|
total_paid: Number(bill.total_paid || 0),
|
||||||
|
last_paid_date: bill.last_paid_date || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const activeBillCount = bills.filter(bill => bill.active).length;
|
||||||
|
const inactiveBillCount = bills.length - activeBillCount;
|
||||||
|
const paymentCount = bills.reduce((sum, bill) => sum + bill.payment_count, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
bill_count: activeBillCount,
|
||||||
|
active_bill_count: activeBillCount,
|
||||||
|
inactive_bill_count: inactiveBillCount,
|
||||||
|
payment_count: paymentCount,
|
||||||
|
bill_names: bills.map(bill => bill.name),
|
||||||
|
bills,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(shaped);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/categories
|
// POST /api/categories
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const { getCycleRange } = require('../services/statusService');
|
||||||
|
|
||||||
|
const DEFAULT_INCOME_LABEL = 'Salary';
|
||||||
|
|
||||||
|
function parseYearMonth(source) {
|
||||||
|
const now = new Date();
|
||||||
|
const year = parseInt(source.year || now.getFullYear(), 10);
|
||||||
|
const month = parseInt(source.month || now.getMonth() + 1, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(year) || year < 2000 || year > 2100) {
|
||||||
|
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
|
||||||
|
}
|
||||||
|
if (Number.isNaN(month) || month < 1 || month > 12) {
|
||||||
|
return { error: 'month must be an integer between 1 and 12' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { year, month };
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIncome(db, userId, year, month) {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT id, label, amount
|
||||||
|
FROM monthly_income
|
||||||
|
WHERE user_id = ? AND year = ? AND month = ?
|
||||||
|
`).get(userId, year, month);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row?.id || null,
|
||||||
|
label: row?.label || DEFAULT_INCOME_LABEL,
|
||||||
|
amount: money(row?.amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(db, userId, year, month) {
|
||||||
|
const income = getIncome(db, userId, year, month);
|
||||||
|
const { start, end } = getCycleRange(year, month);
|
||||||
|
|
||||||
|
const billRows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
b.id AS bill_id,
|
||||||
|
b.name,
|
||||||
|
b.expected_amount,
|
||||||
|
b.due_day,
|
||||||
|
c.name AS category_name,
|
||||||
|
m.actual_amount,
|
||||||
|
m.is_skipped
|
||||||
|
FROM bills b
|
||||||
|
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
|
||||||
|
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||||
|
WHERE b.user_id = ? AND b.active = 1
|
||||||
|
ORDER BY b.due_day ASC, b.name ASC
|
||||||
|
`).all(year, month, userId);
|
||||||
|
|
||||||
|
const billIds = billRows.map(row => row.bill_id);
|
||||||
|
const paymentMap = new Map();
|
||||||
|
|
||||||
|
if (billIds.length > 0) {
|
||||||
|
const placeholders = billIds.map(() => '?').join(', ');
|
||||||
|
const payments = db.prepare(`
|
||||||
|
SELECT p.bill_id, COUNT(p.id) AS payment_count, SUM(p.amount) AS paid_amount
|
||||||
|
FROM payments p
|
||||||
|
JOIN bills b ON b.id = p.bill_id
|
||||||
|
WHERE b.user_id = ?
|
||||||
|
AND p.bill_id IN (${placeholders})
|
||||||
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
GROUP BY p.bill_id
|
||||||
|
`).all(userId, ...billIds, start, end);
|
||||||
|
|
||||||
|
for (const row of payments) {
|
||||||
|
paymentMap.set(row.bill_id, {
|
||||||
|
payment_count: row.payment_count || 0,
|
||||||
|
paid_amount: money(row.paid_amount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenses = billRows.map(row => {
|
||||||
|
const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 };
|
||||||
|
const hasActual = row.actual_amount !== null && row.actual_amount !== undefined;
|
||||||
|
const displayAmount = money(hasActual ? row.actual_amount : row.expected_amount);
|
||||||
|
const paidAmount = money(payment.paid_amount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bill_id: row.bill_id,
|
||||||
|
name: row.name,
|
||||||
|
expected_amount: money(row.expected_amount),
|
||||||
|
actual_amount: hasActual ? money(row.actual_amount) : null,
|
||||||
|
display_amount: displayAmount,
|
||||||
|
is_paid: payment.payment_count > 0,
|
||||||
|
paid_amount: paidAmount,
|
||||||
|
payment_count: payment.payment_count,
|
||||||
|
is_skipped: !!row.is_skipped,
|
||||||
|
due_day: row.due_day,
|
||||||
|
category_name: row.category_name || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const countedExpenses = expenses.filter(expense => !expense.is_skipped);
|
||||||
|
const incomeTotal = money(income.amount);
|
||||||
|
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
|
||||||
|
const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
|
||||||
|
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
||||||
|
const result = incomeTotal - expenseTotal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
income,
|
||||||
|
expenses,
|
||||||
|
summary: {
|
||||||
|
income_total: incomeTotal,
|
||||||
|
expense_total: expenseTotal,
|
||||||
|
paid_expense_count: paidExpenseCount,
|
||||||
|
expense_count: countedExpenses.length,
|
||||||
|
paid_total: paidTotal,
|
||||||
|
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
chart: [
|
||||||
|
{ type: 'Income', amount: incomeTotal },
|
||||||
|
{ type: 'Expenses', amount: expenseTotal },
|
||||||
|
{ type: 'Savings', amount: result },
|
||||||
|
],
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const parsed = parseYearMonth(req.query);
|
||||||
|
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/income', (req, res) => {
|
||||||
|
const parsed = parseYearMonth(req.body || {});
|
||||||
|
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||||
|
|
||||||
|
const amount = Number(req.body?.amount);
|
||||||
|
if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) {
|
||||||
|
return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = String(req.body?.label || DEFAULT_INCOME_LABEL).trim().slice(0, 80) || DEFAULT_INCOME_LABEL;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO monthly_income (user_id, year, month, label, amount, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id, year, month) DO UPDATE SET
|
||||||
|
label = excluded.label,
|
||||||
|
amount = excluded.amount,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
`).run(req.user.id, parsed.year, parsed.month, label, amount);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
year: parsed.year,
|
||||||
|
month: parsed.month,
|
||||||
|
income: getIncome(db, req.user.id, parsed.year, parsed.month),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -47,6 +47,7 @@ app.use('/api/payments', requireAuth, requireUser, require('./routes/paymen
|
||||||
app.use('/api/categories', requireAuth, requireUser, require('./routes/categories'));
|
app.use('/api/categories', requireAuth, requireUser, require('./routes/categories'));
|
||||||
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
|
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
|
||||||
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
|
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
|
||||||
|
app.use('/api/summary', requireAuth, requireUser, require('./routes/summary'));
|
||||||
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
|
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
|
||||||
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
||||||
app.use('/api/status', requireAuth, require('./routes/status'));
|
app.use('/api/status', requireAuth, require('./routes/status'));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue