init
This commit is contained in:
parent
5c828a1aa3
commit
43bd58910a
23
HISTORY.md
23
HISTORY.md
|
|
@ -1,14 +1,21 @@
|
|||
# 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
|
||||
|
||||
### 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 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.
|
||||
|
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import LoginPage from '@/pages/LoginPage';
|
|||
import AdminPage from '@/pages/AdminPage';
|
||||
import TrackerPage from '@/pages/TrackerPage';
|
||||
import CalendarPage from '@/pages/CalendarPage';
|
||||
import SummaryPage from '@/pages/SummaryPage';
|
||||
import BillsPage from '@/pages/BillsPage';
|
||||
import CategoriesPage from '@/pages/CategoriesPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
|
|
@ -76,6 +77,7 @@ export default function App() {
|
|||
>
|
||||
<Route index element={<TrackerPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="summary" element={<SummaryPage />} />
|
||||
<Route path="bills" element={<BillsPage />} />
|
||||
<Route path="categories" element={<CategoriesPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ export const api = {
|
|||
// Calendar
|
||||
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: () => get('/bills'),
|
||||
allBills: () => get('/bills?inactive=true'),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, LayoutGrid, LogOut, Menu, Receipt,
|
||||
Settings, ShieldCheck, Tag, User, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
const userNavItems = [
|
||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
|
|
|
|||
|
|
@ -141,7 +141,12 @@
|
|||
header,
|
||||
.analytics-screen-header,
|
||||
.analytics-controls,
|
||||
.analytics-actions {
|
||||
.analytics-actions,
|
||||
.summary-screen-header,
|
||||
.summary-controls,
|
||||
.summary-actions,
|
||||
.summary-edit-actions,
|
||||
.summary-income-form {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
|
@ -151,24 +156,30 @@
|
|||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.analytics-page {
|
||||
.analytics-page,
|
||||
.summary-page {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
.analytics-report-meta,
|
||||
.analytics-print-footer {
|
||||
.analytics-print-footer,
|
||||
.summary-print-meta,
|
||||
.summary-print-footer {
|
||||
display: block !important;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.analytics-report-meta h1 {
|
||||
.analytics-report-meta h1,
|
||||
.summary-print-meta h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.analytics-report-meta p,
|
||||
.analytics-print-footer {
|
||||
.analytics-print-footer,
|
||||
.summary-print-meta p,
|
||||
.summary-print-footer {
|
||||
color: #4b5563 !important;
|
||||
font-size: 12px;
|
||||
margin: 0.125rem 0;
|
||||
|
|
@ -178,11 +189,21 @@
|
|||
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 {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.analytics-chart {
|
||||
.analytics-chart,
|
||||
.summary-card,
|
||||
.summary-chart-card {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 1rem;
|
||||
|
|
|
|||
|
|
@ -1,32 +1,238 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputDialog } from '@/components/ui/input-dialog';
|
||||
import {
|
||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
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() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [expanded, setExpanded] = useState(() => new Set());
|
||||
const addInputRef = useRef(null);
|
||||
|
||||
// Rename dialog state
|
||||
const [renameTarget, setRenameTarget] = useState(null); // { id, name }
|
||||
const [renameTarget, setRenameTarget] = useState(null);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteTarget, setDeleteTarget] = useState(null); // { id, name }
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
|
@ -42,7 +248,21 @@ export default function CategoriesPage() {
|
|||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
|
|
@ -66,9 +286,8 @@ export default function CategoriesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Rename ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function openRename(cat) {
|
||||
function openRename(event, cat) {
|
||||
event.stopPropagation();
|
||||
setRenameTarget(cat);
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +305,8 @@ export default function CategoriesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function openDelete(cat) {
|
||||
function openDelete(event, cat) {
|
||||
event.stopPropagation();
|
||||
setDeleteTarget(cat);
|
||||
}
|
||||
|
||||
|
|
@ -97,129 +315,178 @@ export default function CategoriesPage() {
|
|||
try {
|
||||
await api.deleteCategory(deleteTarget.id);
|
||||
toast.success(`"${deleteTarget.name}" deleted`);
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(deleteTarget.id);
|
||||
return next;
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
toast.error(err.message || 'Could not delete category.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
const totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page header — floats on bg-background */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{categories.length} categories</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card layer — lifted above page background */}
|
||||
<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.
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{plural(categories.length, 'category')}</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{plural(totalBills, 'bill')}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="group flex items-center justify-between gap-3 px-4 py-4 hover:bg-muted/30 transition-colors sm:px-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">{cat.name}</span>
|
||||
{cat.bill_count > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cat.bill_count} {cat.bill_count === 1 ? 'bill' : 'bills'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-70 hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => openRename(cat)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
<ChipLegend />
|
||||
</div>
|
||||
|
||||
<div className="table-surface overflow-hidden">
|
||||
<div className="border-b border-border/50 bg-card/65 px-4 py-4 sm:px-6">
|
||||
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-xl sm:flex-row">
|
||||
<Input
|
||||
ref={addInputRef}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="New category name..."
|
||||
disabled={adding}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="h-9 sm:w-auto" disabled={adding || !newName.trim()}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{adding ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
No categories yet. Add one above.
|
||||
</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>{/* /card */}
|
||||
</div>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<InputDialog
|
||||
open={!!renameTarget}
|
||||
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
|
||||
title="Rename Category"
|
||||
label="Name"
|
||||
defaultValue={renameTarget?.name ?? ''}
|
||||
placeholder="Category name"
|
||||
confirmLabel="Rename"
|
||||
loading={renaming}
|
||||
onConfirm={handleRename}
|
||||
/>
|
||||
<InputDialog
|
||||
open={!!renameTarget}
|
||||
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
|
||||
title="Rename Category"
|
||||
label="Name"
|
||||
defaultValue={renameTarget?.name ?? ''}
|
||||
placeholder="Category name"
|
||||
confirmLabel="Rename"
|
||||
loading={renaming}
|
||||
onConfirm={handleRename}
|
||||
/>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bills in this category will become uncategorized. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: 'destructive' }))}
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Delete Category'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
</div>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bills in this category will become uncategorized. No bills or payments will be deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: 'destructive' }))}
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Category'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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 });
|
||||
|
||||
|
|
@ -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)');
|
||||
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) ─────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||
|
|
@ -349,14 +376,8 @@ function seedDefaults() {
|
|||
insert.run(key, value);
|
||||
}
|
||||
|
||||
const insertCat = db.prepare(
|
||||
'INSERT INTO categories (name) VALUES (?)'
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
// Category defaults are user-scoped. They are applied by
|
||||
// ensureUserDefaultCategories(userId) when user-owned category/bill data is read.
|
||||
}
|
||||
|
||||
function ensureUserDefaultCategories(userId) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,60 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
|||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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/settings', requireAuth, requireUser, require('./routes/settings'));
|
||||
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/notifications', requireAuth, require('./routes/notifications'));
|
||||
app.use('/api/status', requireAuth, require('./routes/status'));
|
||||
|
|
|
|||
Loading…
Reference in New Issue