calendar
This commit is contained in:
parent
d46b85da8a
commit
969139251d
25
HISTORY.md
25
HISTORY.md
|
|
@ -1,5 +1,23 @@
|
|||
# Bill Tracker — Changelog
|
||||
|
||||
## v0.18.1
|
||||
|
||||
### 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.
|
||||
- Added Calendar to the top navigation after Tracker while preserving the existing desktop and mobile nav behavior.
|
||||
- Improved mobile and tablet responsive rendering across the top navigation, page headers, dialogs, dense tables, Tracker, Bills, Categories, Settings, Status, Admin, Analytics, and Login views.
|
||||
- Preserved the current desktop layout by keeping existing desktop-oriented layouts at `lg` and above while adding mobile/tablet stacking, scrolling, and tap-friendly controls below that breakpoint.
|
||||
- Tablet navigation now uses the compact menu to avoid horizontal overflow; user menu, theme toggle, and admin-only navigation remain reachable.
|
||||
- Dialogs and destructive confirmations now respect mobile viewport width/height and scroll internally when content is long.
|
||||
- Dense Tracker, Bills, Admin, Analytics, and import/history style tables use horizontal scrolling or mobile stacking so actions remain reachable on smaller screens.
|
||||
- Tracker and Bills now use stacked mobile/tablet bill rows below `lg`, reducing sideways scrolling for normal bill review, quick payment, and bill actions while preserving the desktop table layouts.
|
||||
- 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.
|
||||
|
||||
## v0.18
|
||||
|
||||
### Branding
|
||||
|
|
@ -14,6 +32,13 @@
|
|||
- Vite now copies only modern React public assets from `client/public`, preventing legacy `public/*.html`, CSS, and JS files from being emitted into `dist`.
|
||||
- No backend, auth, tracker, bills, categories, settings, status, admin, or navigation-link behavior was changed.
|
||||
|
||||
### Analytics
|
||||
- Added a user-scoped Analytics API at `GET /api/analytics/summary` using existing bills, payments, categories, and monthly bill state data without schema changes.
|
||||
- Added an Analytics page with date range controls, category and bill filters, inactive/skipped toggles, chart visibility toggles, and a line/area trend option.
|
||||
- Added monthly spending trend, expected vs actual spend, category spending donut, and pay-on-time heatmap views.
|
||||
- Added print and browser save-as-PDF report output with print CSS that hides navigation, controls, and interactive actions.
|
||||
- Analytics queries are scoped to the signed-in user and do not accept or expose cross-user aggregation.
|
||||
|
||||
### Security
|
||||
- **OIDC ID token signature verification** now uses `openid-client@5` for full cryptographic validation via JWKS: signature, issuer, audience, expiry, nonce, and `sub` presence — tokens without a valid signature are rejected
|
||||
- **OIDC client cache** invalidation path added; cache is keyed by issuer/client/redirect so Admin panel credential changes pick up a fresh client
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
|
|
@ -6,10 +6,12 @@ import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
|||
import LoginPage from '@/pages/LoginPage';
|
||||
import AdminPage from '@/pages/AdminPage';
|
||||
import TrackerPage from '@/pages/TrackerPage';
|
||||
import CalendarPage from '@/pages/CalendarPage';
|
||||
import BillsPage from '@/pages/BillsPage';
|
||||
import CategoriesPage from '@/pages/CategoriesPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
import StatusPage from '@/pages/StatusPage';
|
||||
import AnalyticsPage from '@/pages/AnalyticsPage';
|
||||
import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
|
||||
import DataPage from '@/pages/DataPage';
|
||||
import ProfilePage from '@/pages/ProfilePage';
|
||||
|
|
@ -73,8 +75,10 @@ export default function App() {
|
|||
}
|
||||
>
|
||||
<Route index element={<TrackerPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="bills" element={<BillsPage />} />
|
||||
<Route path="categories" element={<CategoriesPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="data" element={<DataPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ export const api = {
|
|||
tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`),
|
||||
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
||||
|
||||
// Calendar
|
||||
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
|
||||
|
||||
// Bills
|
||||
bills: () => get('/bills'),
|
||||
allBills: () => get('/bills?inactive=true'),
|
||||
|
|
@ -141,6 +144,16 @@ export const api = {
|
|||
settings: () => get('/settings'),
|
||||
saveSettings: (data) => put('/settings', data),
|
||||
|
||||
// Analytics
|
||||
analyticsSummary: (params = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
|
||||
});
|
||||
const query = qs.toString();
|
||||
return get(`/analytics/summary${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
// Status
|
||||
status: () => get('/status'),
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
</DialogHeader>
|
||||
|
||||
<form id="bill-modal-form" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-4 py-2">
|
||||
<div className="grid gap-x-5 gap-y-4 py-2 sm:grid-cols-2">
|
||||
|
||||
{/* Name */}
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -10,10 +10,127 @@ function hasHistoricalVisibility(bill) {
|
|||
return !!bill.has_history_ranges || (visibility && visibility !== 'default');
|
||||
}
|
||||
|
||||
function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory }) {
|
||||
const hasHistory = hasHistoricalVisibility(bill);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
|
||||
onClick={() => onEdit?.(bill.id)}
|
||||
title={`Edit ${bill.name}`}
|
||||
>
|
||||
{bill.name}
|
||||
</button>
|
||||
{hasHistory && (
|
||||
<span
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
|
||||
title="Historical visibility configured"
|
||||
aria-label="Historical visibility configured"
|
||||
>
|
||||
<History className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
bill.active
|
||||
? 'bg-emerald-500/15 text-emerald-500'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{bill.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{!!bill.autopay_enabled && (
|
||||
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-500">AP</span>
|
||||
)}
|
||||
{!!bill.has_2fa && (
|
||||
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-400">2FA</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||
${Number(bill.expected_amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
|
||||
<p className="mt-0.5 text-sm text-foreground">Day {bill.due_day}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
|
||||
<p className="mt-0.5 truncate text-sm text-foreground">{bill.category_name || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Cycle</p>
|
||||
<p className="mt-0.5 text-sm capitalize text-foreground">{bill.billing_cycle || 'monthly'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 px-2.5 text-xs',
|
||||
bill.active
|
||||
? 'text-muted-foreground hover:text-destructive'
|
||||
: 'text-emerald-500 hover:text-emerald-400',
|
||||
)}
|
||||
onClick={() => onToggle?.(bill)}
|
||||
>
|
||||
{bill.active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
{!bill.active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
|
||||
onClick={() => onHistory?.(bill)}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onDelete?.(bill)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Accepts row action handlers from BillsPage
|
||||
export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) {
|
||||
return (
|
||||
<Table>
|
||||
<>
|
||||
<div className="grid gap-3 p-3 lg:hidden">
|
||||
{bills.map((bill) => (
|
||||
<MobileBillRow
|
||||
key={bill.id}
|
||||
bill={bill}
|
||||
onEdit={onEdit}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onHistory={onHistory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Table className="min-w-[900px]">
|
||||
|
||||
<TableHeader className="bg-muted border-b border-border/70">
|
||||
<TableRow className="hover:bg-transparent border-0">
|
||||
|
|
@ -103,7 +220,7 @@ export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onH
|
|||
|
||||
{/* Actions — visible on row hover */}
|
||||
<TableCell className="px-6 py-4 w-72 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-end gap-1.5 opacity-100 transition-opacity lg:opacity-0 lg:group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -143,5 +260,7 @@ export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onH
|
|||
</TableBody>
|
||||
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
|
||||
Settings, ShieldCheck, Tag, User, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -19,8 +19,10 @@ import {
|
|||
|
||||
const userNavItems = [
|
||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
{ to: '/status', icon: Activity, label: 'Status' },
|
||||
];
|
||||
|
|
@ -130,7 +132,7 @@ export default function Sidebar({ adminMode = false }) {
|
|||
<div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
|
||||
<BrandBlock adminMode={adminMode} />
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{items.map(item => (
|
||||
<NavPill key={item.to} item={item} />
|
||||
))}
|
||||
|
|
@ -143,7 +145,7 @@ export default function Sidebar({ adminMode = false }) {
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="md:hidden rounded-full bg-card/90"
|
||||
className="lg:hidden rounded-full bg-card/90"
|
||||
aria-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen(v => !v)}
|
||||
|
|
@ -154,7 +156,7 @@ export default function Sidebar({ adminMode = false }) {
|
|||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 md:hidden">
|
||||
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden">
|
||||
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
||||
{items.map(item => (
|
||||
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function AlertDialogContent({ className, ...props }) {
|
|||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-lg max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -131,3 +131,63 @@
|
|||
@apply surface overflow-hidden shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
header,
|
||||
.analytics-screen-header,
|
||||
.analytics-controls,
|
||||
.analytics-actions {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
main,
|
||||
main > div {
|
||||
max-width: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.analytics-page {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
.analytics-report-meta,
|
||||
.analytics-print-footer {
|
||||
display: block !important;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.analytics-report-meta h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.analytics-report-meta p,
|
||||
.analytics-print-footer {
|
||||
color: #4b5563 !important;
|
||||
font-size: 12px;
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
|
||||
.analytics-range {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.analytics-chart-grid {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.analytics-chart {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #d1d5db !important;
|
||||
box-shadow: none !important;
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
export const APP_VERSION = '0.16.2';
|
||||
export const APP_VERSION = '0.18.1';
|
||||
export const APP_NAME = 'BillTracker';
|
||||
|
||||
export const RELEASE_NOTES = {
|
||||
version: '0.16.2',
|
||||
date: '2026-05-03',
|
||||
version: '0.18.1',
|
||||
date: '2026-05-04',
|
||||
highlights: [
|
||||
{ icon: '🗄️', title: 'SQLite data import', desc: 'Preview and import user-owned SQLite exports created by this app.' },
|
||||
{ icon: '🧾', title: 'Import tools layout', desc: 'Spreadsheet and SQLite import tools now sit side by side in Profile.' },
|
||||
{ icon: '📦', title: 'Exports below imports', desc: 'User data export downloads now live below the import tools.' },
|
||||
{ icon: '🎨', title: 'Material Design theme', desc: 'Light mode defaults to the shadcn Material Design theme tokens.' },
|
||||
{ icon: '📅', title: 'Due day editing', desc: 'Bill due dates are edited as recurring day-of-month values.' },
|
||||
{ icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' },
|
||||
{ icon: '🧭', title: 'Tablet-safe navigation', desc: 'The top navigation uses the compact menu on tablet sizes to avoid horizontal overflow.' },
|
||||
{ icon: '📊', title: 'Responsive analytics', desc: 'Analytics controls, charts, and the pay heatmap resize or scroll cleanly on smaller screens.' },
|
||||
{ icon: '🪟', title: 'Viewport-safe dialogs', desc: 'Dialogs and confirmations fit mobile screens and scroll internally when content is long.' },
|
||||
{ icon: '🖥️', title: 'Desktop preserved', desc: 'Existing desktop layouts remain on the same large-screen breakpoints.' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ function SectionHeading({ children }) {
|
|||
|
||||
function FieldRow({ label, children }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[200px_1fr] items-center gap-4">
|
||||
<Label className="text-right text-muted-foreground">{label}</Label>
|
||||
<div className="grid gap-2 lg:grid-cols-[200px_1fr] lg:items-center lg:gap-4">
|
||||
<Label className="text-muted-foreground lg:text-right">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1174,7 +1174,7 @@ function AddUserCard({ onCreated }) {
|
|||
<CardTitle>Add User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate} className="flex items-end gap-3">
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label htmlFor="new-uname">Username</Label>
|
||||
<Input
|
||||
|
|
@ -1421,8 +1421,8 @@ function BackupManagementCard() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="min-w-[860px] w-full text-sm">
|
||||
<thead className="bg-muted/40">
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Backup</th>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,565 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const RANGE_OPTIONS = [6, 12, 24, 36];
|
||||
const MONTH_OPTIONS = [
|
||||
['1', 'January'], ['2', 'February'], ['3', 'March'], ['4', 'April'],
|
||||
['5', 'May'], ['6', 'June'], ['7', 'July'], ['8', 'August'],
|
||||
['9', 'September'], ['10', 'October'], ['11', 'November'], ['12', 'December'],
|
||||
];
|
||||
const CHART_OPTIONS = [
|
||||
['monthlyTrend', 'Monthly trend'],
|
||||
['expectedActual', 'Expected vs actual'],
|
||||
['categorySpend', 'Category spend'],
|
||||
['heatmap', 'Pay heatmap'],
|
||||
];
|
||||
const PALETTE = ['#7c3aed', '#10b981', '#ec4899', '#3b82f6', '#f59e0b', '#14b8a6', '#ef4444', '#8b5cf6'];
|
||||
|
||||
function currentMonth() {
|
||||
const now = new Date();
|
||||
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
return (Number(value) || 0).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function fullMoney(value) {
|
||||
return (Number(value) || 0).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function formatRange(range) {
|
||||
if (!range?.start || !range?.end) return 'Selected range';
|
||||
return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
function hasData(rows, keys) {
|
||||
return rows?.some(row => keys.some(key => Number(row[key]) > 0));
|
||||
}
|
||||
|
||||
function EmptyState({ label = 'No analytics data for this selection.' }) {
|
||||
return (
|
||||
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 px-4 text-center text-sm text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, subtitle, children, summary }) {
|
||||
return (
|
||||
<section className="analytics-chart surface-elevated p-5">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
{summary && <div className="shrink-0 text-right text-sm font-semibold tabular-nums">{summary}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SvgFrame({ children, height = 260 }) {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60">
|
||||
<svg viewBox={`0 0 720 ${height}`} role="img" className="h-auto w-full">
|
||||
{children}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineChart({ rows, area = false }) {
|
||||
if (!hasData(rows, ['total'])) return <EmptyState />;
|
||||
|
||||
const width = 720;
|
||||
const height = 260;
|
||||
const pad = { left: 58, right: 24, top: 24, bottom: 46 };
|
||||
const chartW = width - pad.left - pad.right;
|
||||
const chartH = height - pad.top - pad.bottom;
|
||||
const max = Math.max(...rows.map(r => r.total), 1);
|
||||
const points = rows.map((row, index) => {
|
||||
const x = pad.left + (rows.length === 1 ? chartW / 2 : (index / (rows.length - 1)) * chartW);
|
||||
const y = pad.top + chartH - (row.total / max) * chartH;
|
||||
return { ...row, x, y };
|
||||
});
|
||||
const line = points.map(p => `${p.x},${p.y}`).join(' ');
|
||||
const areaPoints = `${pad.left},${pad.top + chartH} ${line} ${pad.left + chartW},${pad.top + chartH}`;
|
||||
|
||||
return (
|
||||
<SvgFrame height={height}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
|
||||
const y = pad.top + chartH - tick * chartH;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={pad.left} x2={pad.left + chartW} y1={y} y2={y} stroke="currentColor" opacity="0.09" />
|
||||
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(max * tick)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{area && <polygon points={areaPoints} fill="#7c3aed" opacity="0.16" />}
|
||||
<polyline points={line} fill="none" stroke="#7c3aed" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{points.map((point, index) => (
|
||||
<g key={point.month}>
|
||||
<circle cx={point.x} cy={point.y} r="4.5" fill="#7c3aed" />
|
||||
{(rows.length <= 12 || index % 3 === 0) && (
|
||||
<text x={point.x} y={height - 18} fontSize="12" fill="currentColor" opacity="0.65" textAnchor="middle">
|
||||
{point.label}
|
||||
</text>
|
||||
)}
|
||||
<title>{`${point.label}: ${fullMoney(point.total)}`}</title>
|
||||
</g>
|
||||
))}
|
||||
</SvgFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedBarChart({ rows }) {
|
||||
if (!hasData(rows, ['expected', 'actual'])) return <EmptyState />;
|
||||
|
||||
const width = 720;
|
||||
const height = 280;
|
||||
const pad = { left: 58, right: 24, top: 24, bottom: 50 };
|
||||
const chartW = width - pad.left - pad.right;
|
||||
const chartH = height - pad.top - pad.bottom;
|
||||
const max = Math.max(...rows.flatMap(r => [r.expected, r.actual]), 1);
|
||||
const groupW = chartW / rows.length;
|
||||
const barW = Math.max(5, Math.min(17, groupW * 0.28));
|
||||
|
||||
return (
|
||||
<SvgFrame height={height}>
|
||||
{[0, 0.5, 1].map(tick => {
|
||||
const y = pad.top + chartH - tick * chartH;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={pad.left} x2={pad.left + chartW} y1={y} y2={y} stroke="currentColor" opacity="0.09" />
|
||||
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(max * tick)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{rows.map((row, index) => {
|
||||
const center = pad.left + index * groupW + groupW / 2;
|
||||
const expectedH = (row.expected / max) * chartH;
|
||||
const actualH = (row.actual / max) * chartH;
|
||||
return (
|
||||
<g key={row.month}>
|
||||
<rect x={center - barW - 1} y={pad.top + chartH - expectedH} width={barW} height={expectedH} rx="4" fill="#8b5cf6">
|
||||
<title>{`${row.label} expected: ${fullMoney(row.expected)}`}</title>
|
||||
</rect>
|
||||
<rect x={center + 1} y={pad.top + chartH - actualH} width={barW} height={actualH} rx="4" fill="#10b981">
|
||||
<title>{`${row.label} actual: ${fullMoney(row.actual)}`}</title>
|
||||
</rect>
|
||||
{(rows.length <= 12 || index % 3 === 0) && (
|
||||
<text x={center} y={height - 18} fontSize="12" fill="currentColor" opacity="0.65" textAnchor="middle">
|
||||
{row.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<g transform={`translate(${width - 190}, 18)`} fontSize="12" fill="currentColor">
|
||||
<rect width="10" height="10" rx="2" fill="#8b5cf6" /><text x="16" y="10">Expected</text>
|
||||
<rect x="92" width="10" height="10" rx="2" fill="#10b981" /><text x="108" y="10">Actual</text>
|
||||
</g>
|
||||
</SvgFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function DonutChart({ rows }) {
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0);
|
||||
if (!total) return <EmptyState />;
|
||||
|
||||
let cumulative = 0;
|
||||
const radius = 78;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 md:grid-cols-[260px_1fr] md:items-center">
|
||||
<div className="flex justify-center">
|
||||
<svg viewBox="0 0 220 220" role="img" className="h-56 w-56">
|
||||
<circle cx="110" cy="110" r={radius} fill="none" stroke="currentColor" strokeWidth="30" opacity="0.08" />
|
||||
{rows.map((row, index) => {
|
||||
const value = Number(row.total || 0);
|
||||
const dash = (value / total) * circumference;
|
||||
const segment = (
|
||||
<circle
|
||||
key={row.category_name}
|
||||
cx="110"
|
||||
cy="110"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={PALETTE[index % PALETTE.length]}
|
||||
strokeWidth="30"
|
||||
strokeDasharray={`${dash} ${circumference - dash}`}
|
||||
strokeDashoffset={-cumulative}
|
||||
transform="rotate(-90 110 110)"
|
||||
>
|
||||
<title>{`${row.category_name}: ${fullMoney(value)}`}</title>
|
||||
</circle>
|
||||
);
|
||||
cumulative += dash;
|
||||
return segment;
|
||||
})}
|
||||
<text x="110" y="104" textAnchor="middle" fontSize="13" fill="currentColor" opacity="0.65">Total</text>
|
||||
<text x="110" y="126" textAnchor="middle" fontSize="22" fontWeight="700" fill="currentColor">{money(total)}</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, index) => (
|
||||
<div key={row.category_name} className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-3 w-3 shrink-0 rounded-sm" style={{ backgroundColor: PALETTE[index % PALETTE.length] }} />
|
||||
<span className="truncate">{row.category_name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-medium tabular-nums">{fullMoney(row.total)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HEATMAP_CLASS = {
|
||||
paid: 'bg-emerald-500/85 border-emerald-400/40',
|
||||
skipped: 'bg-sky-500/70 border-sky-400/40',
|
||||
missed: 'bg-red-500/75 border-red-400/40',
|
||||
no_data: 'bg-muted border-border',
|
||||
};
|
||||
|
||||
function Heatmap({ heatmap }) {
|
||||
const rows = heatmap?.rows || [];
|
||||
const months = heatmap?.months || [];
|
||||
if (!rows.length || !months.length) return <EmptyState />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-lg border border-border/60">
|
||||
<div className="min-w-[760px]">
|
||||
<div
|
||||
className="grid border-b border-border/60 bg-muted/30 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
|
||||
>
|
||||
<div className="px-3 py-2">Bill</div>
|
||||
{months.map(month => <div key={month.key} className="px-1 py-2 text-center">{month.label}</div>)}
|
||||
</div>
|
||||
{rows.map(row => (
|
||||
<div
|
||||
key={row.bill_id}
|
||||
className="grid border-b border-border/40 last:border-b-0"
|
||||
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
|
||||
>
|
||||
<div className="min-w-0 px-3 py-2">
|
||||
<p className="truncate text-sm font-medium">{row.bill_name}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{row.category_name}</p>
|
||||
</div>
|
||||
{months.map(month => {
|
||||
const cell = row.cells.find(item => item.month === month.key) || { status: 'no_data', amount_paid: 0 };
|
||||
return (
|
||||
<div key={`${row.bill_id}-${month.key}`} className="flex items-center justify-center px-1 py-2">
|
||||
<span
|
||||
className={cn('h-5 w-5 rounded border', HEATMAP_CLASS[cell.status] || HEATMAP_CLASS.no_data)}
|
||||
title={`${row.bill_name}, ${month.label}: ${cell.status.replace('_', ' ')}${cell.amount_paid ? ` (${fullMoney(cell.amount_paid)})` : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{[
|
||||
['paid', 'Paid'],
|
||||
['skipped', 'Skipped'],
|
||||
['missed', 'Missed'],
|
||||
['no_data', 'No data'],
|
||||
].map(([status, label]) => (
|
||||
<span key={status} className="inline-flex items-center gap-1.5">
|
||||
<span className={cn('h-3 w-3 rounded border', HEATMAP_CLASS[status])} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<label className="space-y-1.5">
|
||||
<span className="block text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlSelect({ value, onChange, children, className }) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={cn('h-9 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50', className)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const initial = currentMonth();
|
||||
const [year, setYear] = useState(initial.year);
|
||||
const [month, setMonth] = useState(initial.month);
|
||||
const [months, setMonths] = useState(12);
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [billId, setBillId] = useState('');
|
||||
const [includeInactive, setIncludeInactive] = useState(false);
|
||||
const [includeSkipped, setIncludeSkipped] = useState(true);
|
||||
const [trendMode, setTrendMode] = useState('line');
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [visible, setVisible] = useState({
|
||||
monthlyTrend: true,
|
||||
expectedActual: true,
|
||||
categorySpend: true,
|
||||
heatmap: true,
|
||||
});
|
||||
|
||||
const params = useMemo(() => ({
|
||||
year,
|
||||
month,
|
||||
months,
|
||||
category_id: categoryId,
|
||||
bill_id: billId,
|
||||
include_inactive: includeInactive,
|
||||
include_skipped: includeSkipped,
|
||||
}), [billId, categoryId, includeInactive, includeSkipped, month, months, year]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.analyticsSummary(params);
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load analytics.');
|
||||
toast.error(err.message || 'Failed to load analytics.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const reset = () => {
|
||||
const next = currentMonth();
|
||||
setYear(next.year);
|
||||
setMonth(next.month);
|
||||
setMonths(12);
|
||||
setCategoryId('');
|
||||
setBillId('');
|
||||
setIncludeInactive(false);
|
||||
setIncludeSkipped(true);
|
||||
setTrendMode('line');
|
||||
setVisible({ monthlyTrend: true, expectedActual: true, categorySpend: true, heatmap: true });
|
||||
};
|
||||
|
||||
const totalCategorySpend = data?.category_spend?.reduce((sum, row) => sum + Number(row.total || 0), 0) || 0;
|
||||
const activeCharts = CHART_OPTIONS.filter(([key]) => visible[key]).map(([, label]) => label).join(', ') || 'None';
|
||||
const filterSummary = [
|
||||
categoryId ? `Category: ${data?.categories?.find(c => String(c.id) === String(categoryId))?.name || categoryId}` : 'All categories',
|
||||
billId ? `Bill: ${data?.bills?.find(b => String(b.id) === String(billId))?.name || billId}` : 'All bills',
|
||||
includeInactive ? 'Includes inactive bills' : 'Active bills only',
|
||||
includeSkipped ? 'Shows skipped months' : 'Hides skipped months',
|
||||
].join(' | ');
|
||||
|
||||
return (
|
||||
<div className="analytics-page space-y-6">
|
||||
<div className="analytics-report-meta hidden">
|
||||
<h1>BillTracker Analytics</h1>
|
||||
<p>{formatRange(data?.range)}</p>
|
||||
<p>{filterSummary}</p>
|
||||
<p>Visible charts: {activeCharts}</p>
|
||||
<p>Generated {new Date(data?.generated_at || Date.now()).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="analytics-screen-header flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Analytics</h1>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Spending trends, category breakdowns, and payment history.
|
||||
</p>
|
||||
</div>
|
||||
<div className="analytics-actions flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="flex-1 sm:flex-none">
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
Print
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
Print / Save PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="analytics-controls surface-elevated p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<Field label="Ending month">
|
||||
<ControlSelect value={String(month)} onChange={value => setMonth(Number(value))}>
|
||||
{MONTH_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
<Field label="Ending year">
|
||||
<input
|
||||
type="number"
|
||||
min="2000"
|
||||
max="2100"
|
||||
value={year}
|
||||
onChange={e => setYear(Number(e.target.value))}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Range">
|
||||
<ControlSelect value={String(months)} onChange={value => setMonths(Number(value))}>
|
||||
{RANGE_OPTIONS.map(value => <option key={value} value={value}>{value} months</option>)}
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
<Field label="Category">
|
||||
<ControlSelect value={categoryId} onChange={value => { setCategoryId(value); setBillId(''); }}>
|
||||
<option value="">All categories</option>
|
||||
{(data?.categories || []).map(category => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
<Field label="Bill">
|
||||
<ControlSelect value={billId} onChange={setBillId}>
|
||||
<option value="">All bills</option>
|
||||
{(data?.bills || []).map(bill => (
|
||||
<option key={bill.id} value={bill.id}>{bill.name}{bill.active ? '' : ' (inactive)'}</option>
|
||||
))}
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
<Field label="Trend style">
|
||||
<ControlSelect value={trendMode} onChange={setTrendMode}>
|
||||
<option value="line">Line</option>
|
||||
<option value="area">Area</option>
|
||||
</ControlSelect>
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={reset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 border-t border-border/60 pt-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{CHART_OPTIONS.map(([key, label]) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible[key]}
|
||||
onChange={e => setVisible(prev => ({ ...prev, [key]: e.target.checked }))}
|
||||
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeInactive}
|
||||
onChange={e => setIncludeInactive(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
||||
/>
|
||||
Include inactive bills
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSkipped}
|
||||
onChange={e => setIncludeSkipped(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
||||
/>
|
||||
Show skipped months
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="analytics-range text-sm text-muted-foreground">
|
||||
{data ? (
|
||||
<>
|
||||
Reporting on <span className="font-medium text-foreground">{formatRange(data.range)}</span>.
|
||||
<span className="ml-2">{filterSummary}</span>
|
||||
</>
|
||||
) : 'Preparing analytics...'}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{[1, 2, 3, 4].map(item => <div key={item} className="h-80 animate-pulse rounded-2xl bg-muted/50" />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<div className="analytics-chart-grid grid gap-5 xl:grid-cols-2">
|
||||
{visible.monthlyTrend && (
|
||||
<ChartCard title="Monthly spending trend" subtitle="Actual payments grouped by paid month.">
|
||||
<LineChart rows={data.monthly_spending || []} area={trendMode === 'area'} />
|
||||
</ChartCard>
|
||||
)}
|
||||
{visible.expectedActual && (
|
||||
<ChartCard title="Expected vs actual spend" subtitle="Expected uses monthly override amount when present, otherwise the bill estimate.">
|
||||
<GroupedBarChart rows={data.expected_vs_actual || []} />
|
||||
</ChartCard>
|
||||
)}
|
||||
{visible.categorySpend && (
|
||||
<ChartCard title="Spending by category" subtitle="Payments grouped by bill category." summary={fullMoney(totalCategorySpend)}>
|
||||
<DonutChart rows={data.category_spend || []} />
|
||||
</ChartCard>
|
||||
)}
|
||||
{visible.heatmap && (
|
||||
<div className="xl:col-span-2">
|
||||
<ChartCard title="Pay-on-time heatmap" subtitle="Bill status by month. Future/current unpaid months show as no data.">
|
||||
<Heatmap heatmap={data.heatmap} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
{!Object.values(visible).some(Boolean) && (
|
||||
<div className="xl:col-span-2">
|
||||
<EmptyState label="Select at least one chart to show analytics." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="analytics-print-footer hidden text-xs text-muted-foreground">
|
||||
Generated from BillTracker Analytics on {new Date(data?.generated_at || Date.now()).toLocaleString()}.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -418,7 +418,7 @@ export default function BillsPage() {
|
|||
<div className="space-y-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
||||
Manage
|
||||
|
|
@ -443,7 +443,7 @@ export default function BillsPage() {
|
|||
|
||||
{/* ── Active Bills ── */}
|
||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
||||
<div className="flex items-center justify-between px-6 py-3 bg-muted/30 border-b border-border">
|
||||
<div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Active Bills
|
||||
</span>
|
||||
|
|
@ -493,7 +493,7 @@ export default function BillsPage() {
|
|||
|
||||
{showInactive && (
|
||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
||||
<div className="flex items-center justify-between px-6 py-3 bg-muted/30 border-b border-border">
|
||||
<div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Inactive Bills
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,455 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, CircleDollarSign, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function currentMonth() {
|
||||
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 displayStatus(status) {
|
||||
if (status === 'due_soon') return 'Due';
|
||||
if (status === 'late') return 'Late';
|
||||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
|
||||
}
|
||||
|
||||
function statusTone(status) {
|
||||
if (status === 'paid' || status === 'autodraft') return 'bg-emerald-500/15 text-emerald-500 border-emerald-500/25';
|
||||
if (status === 'skipped') return 'bg-muted text-muted-foreground border-border';
|
||||
if (status === 'late' || status === 'missed') return 'bg-destructive/15 text-destructive border-destructive/25';
|
||||
return 'bg-primary/10 text-primary border-primary/25';
|
||||
}
|
||||
|
||||
function LegendItem({ className, label }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryProgress({ summary }) {
|
||||
const percent = Number(summary?.paid_percent || 0);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDollarSign className="h-4 w-4 text-emerald-500" />
|
||||
<CardTitle className="text-base">Total Expenses Paid</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Monthly progress across active, unskipped bills.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-2xl font-semibold tracking-tight">
|
||||
{fmt(summary?.paid_total)}
|
||||
<span className="mx-2 text-sm font-normal text-muted-foreground">/</span>
|
||||
<span className="text-base text-muted-foreground">{fmt(summary?.expected_total)}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{fmt(summary?.remaining_total)} remaining</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-semibold">{percent}%</p>
|
||||
<p className="text-xs text-muted-foreground">paid</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-3 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{summary?.bill_count || 0} active bills</span>
|
||||
<span>{summary?.paid_count || 0} paid</span>
|
||||
{!!summary?.skipped_count && <span>{summary.skipped_count} skipped</span>}
|
||||
{!!summary?.missed_count && <span className="text-destructive">{summary.missed_count} late or missed</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DayIndicators({ day }) {
|
||||
const summary = day.status_summary;
|
||||
const hasPaid = summary.paid_count > 0;
|
||||
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
|
||||
const hasSkipped = summary.skipped_count > 0;
|
||||
const hasMissed = summary.missed_count > 0;
|
||||
const paymentOnly = day.payments.length > 0 && day.bills_due.length === 0;
|
||||
|
||||
return (
|
||||
<div className="mt-auto flex flex-wrap gap-1">
|
||||
{hasPaid && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" title="Paid" />}
|
||||
{(hasDue || paymentOnly) && <span className="h-1.5 w-1.5 rounded-full bg-primary" title="Due or payment" />}
|
||||
{hasSkipped && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" title="Skipped" />}
|
||||
{hasMissed && <span className="h-1.5 w-1.5 rounded-full bg-destructive" title="Missed or late" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarGrid({ data, selectedDate, onSelectDay }) {
|
||||
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
|
||||
const cells = [
|
||||
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })),
|
||||
...data.days.map(day => ({ type: 'day', key: day.date, day })),
|
||||
];
|
||||
const today = todayStr();
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="grid grid-cols-7 border-b border-border/70 bg-muted/30">
|
||||
{WEEKDAYS.map(day => (
|
||||
<div key={day} className="px-1 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map(cell => {
|
||||
if (cell.type === 'blank') {
|
||||
return <div key={cell.key} className="min-h-16 border-b border-r border-border/50 bg-muted/10 sm:min-h-24" />;
|
||||
}
|
||||
|
||||
const day = cell.day;
|
||||
const isToday = day.date === today;
|
||||
const isSelected = day.date === selectedDate;
|
||||
const summary = day.status_summary;
|
||||
const hasActivity = day.bills_due.length > 0 || day.payments.length > 0;
|
||||
const isPaidDay = summary.due_count > 0 && summary.paid_count >= summary.due_count - summary.skipped_count;
|
||||
const hasMissed = summary.missed_count > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={() => onSelectDay(day)}
|
||||
className={cn(
|
||||
'flex min-h-16 flex-col border-b border-r border-border/50 p-1.5 text-left transition-colors sm:min-h-24 sm:p-2',
|
||||
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
hasActivity && 'bg-primary/[0.03] hover:bg-accent/60',
|
||||
isPaidDay && 'bg-emerald-500/[0.07]',
|
||||
hasMissed && 'bg-destructive/[0.08]',
|
||||
isSelected && 'ring-2 ring-primary ring-inset',
|
||||
)}
|
||||
aria-label={`View ${fmtDate(day.date)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<span className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium sm:text-sm',
|
||||
isToday && 'border border-primary bg-primary/10 text-primary',
|
||||
)}>
|
||||
{day.day}
|
||||
</span>
|
||||
{summary.due_count > 0 && (
|
||||
<span className="rounded bg-background/75 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
{summary.due_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 hidden min-w-0 space-y-0.5 sm:block">
|
||||
{day.bills_due.slice(0, 2).map(bill => (
|
||||
<p key={bill.bill_id} className="truncate text-[11px] text-muted-foreground">
|
||||
{bill.name}
|
||||
</p>
|
||||
))}
|
||||
{day.bills_due.length > 2 && (
|
||||
<p className="text-[11px] text-muted-foreground">+{day.bills_due.length - 2} more</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DayIndicators day={day} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DayDetailDialog({ day, open, onOpenChange }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">{day ? fmtDate(day.date) : 'Day details'}</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">Bills due and payments recorded for this date.</p>
|
||||
</DialogHeader>
|
||||
|
||||
{day && (
|
||||
<div className="space-y-5">
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
|
||||
{day.bills_due.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
|
||||
No bills are due on this day.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{day.bills_due.map(bill => (
|
||||
<div key={bill.bill_id} className="rounded-lg border border-border/60 bg-background/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{bill.name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{bill.category_name || 'Uncategorized'}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn('shrink-0 capitalize', statusTone(bill.status))}>
|
||||
{displayStatus(bill.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<p>Expected</p>
|
||||
<p className="font-mono text-sm text-foreground">{fmt(bill.effective_amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Paid</p>
|
||||
<p className="font-mono text-sm text-emerald-500">{fmt(bill.paid_amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Due</p>
|
||||
<p className="font-mono text-sm text-foreground">{fmtDate(bill.due_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Payments</h3>
|
||||
{day.payments.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
|
||||
No payments were recorded on this day.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{day.payments.map(payment => (
|
||||
<div key={payment.payment_id} className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{payment.bill_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{payment.method || 'Payment'}</p>
|
||||
</div>
|
||||
<span className="font-mono text-sm text-emerald-500">{fmt(payment.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-4">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/">Open Tracker</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link to="/bills">Manage Bills</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const initial = currentMonth();
|
||||
const [year, setYear] = useState(initial.year);
|
||||
const [month, setMonth] = useState(initial.month);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedDay, setSelectedDay] = useState(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.calendar(year, month);
|
||||
setData(result);
|
||||
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Calendar data could not be loaded.');
|
||||
toast.error(err.message || 'Calendar data could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, month]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
|
||||
const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
|
||||
|
||||
function navigate(delta) {
|
||||
const next = shiftMonth(year, month, delta);
|
||||
setYear(next.year);
|
||||
setMonth(next.month);
|
||||
setSelectedDay(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
const next = currentMonth();
|
||||
setYear(next.year);
|
||||
setMonth(next.month);
|
||||
setSelectedDay(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
|
||||
Monthly Calendar
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Calendar</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
View bills, payments, and monthly progress by date.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center rounded-full border border-border/70 bg-card/90 p-1 shadow-sm">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(-1)} aria-label="Previous month">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-40 px-3 text-center text-sm font-semibold">{monthLabel}</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(1)} aria-label="Next month">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={goToday}>Today</Button>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full" onClick={load} aria-label="Refresh calendar">
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-card/70 px-4 py-3">
|
||||
<LegendItem className="border-emerald-500 bg-emerald-500" label="Paid" />
|
||||
<LegendItem className="border-primary bg-primary" label="Due" />
|
||||
<LegendItem className="border-muted-foreground/50 bg-muted-foreground/50" label="Skipped" />
|
||||
<LegendItem className="border-destructive bg-destructive" label="Missed/Late" />
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="h-5 w-5 rounded-full border border-primary bg-primary/10" />
|
||||
Today
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-[360px] items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Loading calendar...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-[260px] flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load}>Try again</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<CalendarGrid
|
||||
data={data}
|
||||
selectedDate={selectedDay?.date}
|
||||
onSelectDay={day => {
|
||||
setSelectedDay(day);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
/>
|
||||
{!hasAnyBills && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<CalendarDays className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">No bills on this calendar yet.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Add a bill to start seeing due dates and payment progress.</p>
|
||||
</div>
|
||||
<Button asChild size="sm">
|
||||
<Link to="/bills">Add bill</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SummaryProgress summary={data?.summary} />
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Selected Day</CardTitle>
|
||||
<CardDescription>Tap a date to inspect bills and payments.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedDay ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">{fmtDate(selectedDay.date)}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-lg bg-muted/40 p-3">
|
||||
<p className="text-xs text-muted-foreground">Due</p>
|
||||
<p className="font-mono font-semibold">{fmt(selectedDay.status_summary.total_due)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/40 p-3">
|
||||
<p className="text-xs text-muted-foreground">Paid</p>
|
||||
<p className="font-mono font-semibold text-emerald-500">{fmt(selectedDay.status_summary.total_paid)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" size="sm" onClick={() => setDetailOpen(true)}>
|
||||
View day details
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No day selected.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DayDetailDialog
|
||||
day={selectedDay}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -122,8 +122,8 @@ export default function CategoriesPage() {
|
|||
<div className="table-surface">
|
||||
|
||||
{/* Card header with inline add form */}
|
||||
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
||||
<form onSubmit={handleAdd} className="flex gap-2 flex-1 max-w-sm">
|
||||
<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}
|
||||
|
|
@ -151,7 +151,7 @@ export default function CategoriesPage() {
|
|||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="group flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-colors"
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export default function LoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-6">
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4 sm:p-6">
|
||||
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ function SectionCard({ title, children }) {
|
|||
|
||||
function SettingRow({ label, description, children }) {
|
||||
return (
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0 mr-8">
|
||||
<div className="px-4 py-4 flex flex-col gap-3 sm:px-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1 min-w-0 sm:mr-8">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export default function StatusPage() {
|
|||
<div>
|
||||
|
||||
{/* Page header — flat on background */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
|
|
|
|||
|
|
@ -650,7 +650,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
{row.payments && row.payments.length > 0 && (
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title="Edit payment"
|
||||
onClick={() => setEditPayment(row.payments[0])}
|
||||
>
|
||||
|
|
@ -661,7 +661,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
{/* Monthly state editor (gear icon) — always available */}
|
||||
<Button
|
||||
size="icon" variant="ghost"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||
onClick={() => setShowMbs(true)}
|
||||
>
|
||||
|
|
@ -698,6 +698,186 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
|||
);
|
||||
}
|
||||
|
||||
function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||
const amountRef = useRef(null);
|
||||
const [editPayment, setEditPayment] = useState(null);
|
||||
const [showMbs, setShowMbs] = useState(false);
|
||||
|
||||
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
|
||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||||
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
|
||||
const isSkipped = !!row.is_skipped;
|
||||
const effectiveStatus = isSkipped
|
||||
? 'skipped'
|
||||
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
|
||||
? 'paid'
|
||||
: row.status;
|
||||
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
|
||||
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
|
||||
|
||||
async function handleQuickPay() {
|
||||
const val = parseFloat(amountRef.current?.value);
|
||||
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
|
||||
try {
|
||||
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
|
||||
toast.success('Marked as paid');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
|
||||
'space-y-3 transition-colors',
|
||||
isSkipped ? 'opacity-55' : rowBg,
|
||||
)}
|
||||
style={{ animationDelay: `${index * 40}ms` }}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{row.autopay_enabled && (
|
||||
<span
|
||||
className="inline-flex shrink-0 rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-sky-500"
|
||||
title="Autopay"
|
||||
>
|
||||
AP
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
className={cn(
|
||||
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
|
||||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||||
isSkipped && 'line-through',
|
||||
)}
|
||||
title="Edit bill"
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
</div>
|
||||
{row.monthly_notes && (
|
||||
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
||||
{row.monthly_notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
|
||||
<p className="mt-0.5 font-mono text-sm text-foreground">{fmtDate(row.due_date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
|
||||
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
|
||||
<p className={cn('mt-0.5 font-mono text-sm', row.actual_amount != null ? 'text-amber-500' : 'text-foreground')}>
|
||||
{fmt(threshold)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
|
||||
<p className={cn('mt-0.5 font-mono text-sm', remaining > 0 ? 'text-foreground' : 'text-emerald-500')}>
|
||||
{fmt(remaining)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Paid </span>
|
||||
<span className="font-mono text-emerald-500">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
||||
<span className="text-muted-foreground">Date </span>
|
||||
<span className="font-mono text-foreground">{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{!isPaid && !isSkipped && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={amountRef}
|
||||
type="number" min="0" step="0.01"
|
||||
defaultValue={threshold}
|
||||
className="h-8 w-24 text-right font-mono text-sm bg-background/70 border-border/60"
|
||||
title="Payment amount"
|
||||
aria-label={`${row.name} payment amount`}
|
||||
/>
|
||||
<Button
|
||||
size="sm" variant="default"
|
||||
onClick={handleQuickPay}
|
||||
className="h-8 px-3 text-xs font-semibold"
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{row.payments && row.payments.length > 0 && (
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
title="Edit payment"
|
||||
onClick={() => setEditPayment(row.payments[0])}
|
||||
>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Payment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||
onClick={() => setShowMbs(true)}
|
||||
>
|
||||
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Month
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
|
||||
<NotesCell row={row} refresh={refresh} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editPayment && (
|
||||
<PaymentModal
|
||||
payment={editPayment}
|
||||
onClose={() => setEditPayment(null)}
|
||||
onSave={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMbs && (
|
||||
<MonthlyStateDialog
|
||||
row={row}
|
||||
year={year}
|
||||
month={month}
|
||||
open={showMbs}
|
||||
onOpenChange={setShowMbs}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bucket ─────────────────────────────────────────────────────────────────
|
||||
function Bucket({ label, rows, year, month, refresh, onEditBill }) {
|
||||
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
||||
|
|
@ -746,7 +926,22 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<div className="grid gap-3 p-3 lg:hidden">
|
||||
{rows.map((r, i) => (
|
||||
<MobileTrackerRow
|
||||
key={r.id}
|
||||
row={r}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refresh}
|
||||
index={i}
|
||||
onEditBill={onEditBill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Table className="min-w-[1120px]">
|
||||
<TableHeader>
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead>
|
||||
|
|
@ -776,6 +971,7 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -835,7 +1031,7 @@ export default function TrackerPage() {
|
|||
<div className="space-y-5">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
||||
Monthly Overview
|
||||
|
|
@ -875,7 +1071,7 @@ export default function TrackerPage() {
|
|||
</div>
|
||||
|
||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||
<div className="flex gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
||||
<SummaryCard type="expected" value={summary.total_expected} />
|
||||
<SummaryCard type="paid" value={summary.total_paid} />
|
||||
<SummaryCard type="remaining" value={summary.remaining} />
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 297 KiB |
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.17",
|
||||
"version": "0.18.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bill-tracker",
|
||||
"version": "0.17",
|
||||
"version": "0.18.1",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.18",
|
||||
"version": "0.18.1",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
|
||||
function parseInteger(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) ? parsed : NaN;
|
||||
}
|
||||
|
||||
function monthKey(year, month) {
|
||||
return `${year}-${String(month).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function monthLabel(year, month) {
|
||||
return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function addMonths(year, month, delta) {
|
||||
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
|
||||
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
|
||||
}
|
||||
|
||||
function monthEndDate(year, month) {
|
||||
const day = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function buildMonths(endYear, endMonth, count) {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = addMonths(endYear, endMonth, index - count + 1);
|
||||
return {
|
||||
...value,
|
||||
key: monthKey(value.year, value.month),
|
||||
label: monthLabel(value.year, value.month),
|
||||
start: `${monthKey(value.year, value.month)}-01`,
|
||||
end: monthEndDate(value.year, value.month),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateSummaryQuery(query) {
|
||||
const now = new Date();
|
||||
const year = parseInteger(query.year, now.getFullYear());
|
||||
const month = parseInteger(query.month, now.getMonth() + 1);
|
||||
const months = parseInteger(query.months, 12);
|
||||
const categoryId = parseInteger(query.category_id, null);
|
||||
const billId = parseInteger(query.bill_id, null);
|
||||
const includeInactive = query.include_inactive === 'true';
|
||||
const includeSkipped = query.include_skipped !== 'false';
|
||||
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
|
||||
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
|
||||
}
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
||||
return { error: 'month must be an integer between 1 and 12' };
|
||||
}
|
||||
if (!Number.isInteger(months) || months < 1 || months > 36) {
|
||||
return { error: 'months must be an integer between 1 and 36' };
|
||||
}
|
||||
if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) {
|
||||
return { error: 'category_id must be a positive integer' };
|
||||
}
|
||||
if (billId !== null && (!Number.isInteger(billId) || billId < 1)) {
|
||||
return { error: 'bill_id must be a positive integer' };
|
||||
}
|
||||
|
||||
return { year, month, months, categoryId, billId, includeInactive, includeSkipped };
|
||||
}
|
||||
|
||||
function isMonthInPast(year, month) {
|
||||
const now = new Date();
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const targetMonthStart = new Date(year, month - 1, 1);
|
||||
return targetMonthStart < currentMonthStart;
|
||||
}
|
||||
|
||||
function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
|
||||
const clauses = ['b.user_id = ?'];
|
||||
const params = [userId];
|
||||
if (!includeInactive) clauses.push('b.active = 1');
|
||||
if (categoryId) {
|
||||
clauses.push('b.category_id = ?');
|
||||
params.push(categoryId);
|
||||
}
|
||||
if (billId) {
|
||||
clauses.push('b.id = ?');
|
||||
params.push(billId);
|
||||
}
|
||||
return { where: clauses.join(' AND '), params };
|
||||
}
|
||||
|
||||
router.get('/summary', (req, res) => {
|
||||
const parsed = validateSummaryQuery(req.query);
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months);
|
||||
const startDate = rangeMonths[0].start;
|
||||
const endDate = rangeMonths[rangeMonths.length - 1].end;
|
||||
const billWhere = buildBillWhere({ ...parsed, userId });
|
||||
|
||||
const categories = db.prepare(`
|
||||
SELECT id, name
|
||||
FROM categories
|
||||
WHERE user_id = ?
|
||||
ORDER BY name COLLATE NOCASE
|
||||
`).all(userId);
|
||||
|
||||
const bills = db.prepare(`
|
||||
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
|
||||
c.name AS category_name
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
|
||||
WHERE ${billWhere.where}
|
||||
ORDER BY b.name COLLATE NOCASE
|
||||
`).all(...billWhere.params);
|
||||
|
||||
if (!bills.length) {
|
||||
return res.json({
|
||||
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
|
||||
filters: {
|
||||
category_id: parsed.categoryId,
|
||||
bill_id: parsed.billId,
|
||||
include_inactive: parsed.includeInactive,
|
||||
include_skipped: parsed.includeSkipped,
|
||||
},
|
||||
categories,
|
||||
bills: [],
|
||||
monthly_spending: [],
|
||||
expected_vs_actual: [],
|
||||
category_spend: [],
|
||||
heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: [] },
|
||||
generated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const billIds = bills.map(b => b.id);
|
||||
const placeholders = billIds.map(() => '?').join(',');
|
||||
|
||||
const paymentRows = db.prepare(`
|
||||
SELECT p.bill_id,
|
||||
substr(p.paid_date, 1, 7) AS month_key,
|
||||
SUM(p.amount) AS total
|
||||
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, substr(p.paid_date, 1, 7)
|
||||
`).all(userId, ...billIds, startDate, endDate);
|
||||
|
||||
const stateRows = db.prepare(`
|
||||
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
|
||||
FROM monthly_bill_state m
|
||||
JOIN bills b ON b.id = m.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND m.bill_id IN (${placeholders})
|
||||
AND (m.year * 100 + m.month) BETWEEN ? AND ?
|
||||
`).all(
|
||||
userId,
|
||||
...billIds,
|
||||
rangeMonths[0].year * 100 + rangeMonths[0].month,
|
||||
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month,
|
||||
);
|
||||
|
||||
const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0]));
|
||||
const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
|
||||
|
||||
const monthly_spending = rangeMonths.map(m => {
|
||||
const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0);
|
||||
return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
|
||||
}).filter(row => row.total > 0);
|
||||
|
||||
const expected_vs_actual = rangeMonths.map(m => {
|
||||
let expected = 0;
|
||||
let actual = 0;
|
||||
let skipped_count = 0;
|
||||
for (const bill of bills) {
|
||||
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
|
||||
const skipped = !!state?.is_skipped;
|
||||
if (skipped) skipped_count += 1;
|
||||
if (!skipped || parsed.includeSkipped) {
|
||||
actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
|
||||
}
|
||||
if (!skipped) {
|
||||
expected += state?.actual_amount ?? bill.expected_amount ?? 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
month: m.key,
|
||||
label: m.label,
|
||||
expected: Number(expected.toFixed(2)),
|
||||
actual: Number(actual.toFixed(2)),
|
||||
skipped_count,
|
||||
};
|
||||
}).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
|
||||
|
||||
const categoryMap = new Map();
|
||||
for (const bill of bills) {
|
||||
const categoryId = bill.category_id || null;
|
||||
const key = categoryId == null ? 'uncategorized' : String(categoryId);
|
||||
const existing = categoryMap.get(key) || {
|
||||
category_id: categoryId,
|
||||
category_name: bill.category_name || 'Uncategorized',
|
||||
total: 0,
|
||||
};
|
||||
for (const m of rangeMonths) {
|
||||
existing.total += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
|
||||
}
|
||||
categoryMap.set(key, existing);
|
||||
}
|
||||
const category_spend = Array.from(categoryMap.values())
|
||||
.map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
|
||||
.filter(row => row.total > 0)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const heatmapRows = bills.map(bill => {
|
||||
const cells = rangeMonths.map(m => {
|
||||
const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0;
|
||||
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
|
||||
const skipped = !!state?.is_skipped;
|
||||
let status = 'no_data';
|
||||
if (skipped) status = 'skipped';
|
||||
else if (paid) status = 'paid';
|
||||
else if (isMonthInPast(m.year, m.month)) status = 'missed';
|
||||
return {
|
||||
month: m.key,
|
||||
label: m.label,
|
||||
status,
|
||||
amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
|
||||
};
|
||||
});
|
||||
return {
|
||||
bill_id: bill.id,
|
||||
bill_name: bill.name,
|
||||
category_name: bill.category_name || 'Uncategorized',
|
||||
active: !!bill.active,
|
||||
cells: parsed.includeSkipped ? cells : cells.filter(cell => cell.status !== 'skipped'),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
|
||||
filters: {
|
||||
category_id: parsed.categoryId,
|
||||
bill_id: parsed.billId,
|
||||
include_inactive: parsed.includeInactive,
|
||||
include_skipped: parsed.includeSkipped,
|
||||
},
|
||||
categories,
|
||||
bills: bills.map(b => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
category_id: b.category_id,
|
||||
category_name: b.category_name || 'Uncategorized',
|
||||
active: !!b.active,
|
||||
})),
|
||||
monthly_spending,
|
||||
expected_vs_actual,
|
||||
category_spend,
|
||||
heatmap: {
|
||||
months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })),
|
||||
rows: heatmapRows,
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
|
||||
|
||||
function clampDay(year, month, day) {
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
return Math.min(Math.max(parseInt(day || 1, 10), 1), daysInMonth);
|
||||
}
|
||||
|
||||
function toDateString(year, month, day) {
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function emptyDay(year, month, day) {
|
||||
return {
|
||||
date: toDateString(year, month, day),
|
||||
day,
|
||||
bills_due: [],
|
||||
payments: [],
|
||||
status_summary: {
|
||||
due_count: 0,
|
||||
paid_count: 0,
|
||||
skipped_count: 0,
|
||||
missed_count: 0,
|
||||
total_due: 0,
|
||||
total_paid: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/calendar?year=2026&month=5
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const year = parseInt(req.query.year || now.getFullYear(), 10);
|
||||
const month = parseInt(req.query.month || now.getMonth() + 1, 10);
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return res.status(400).json({ error: 'year must be a 4-digit integer between 2000 and 2100' });
|
||||
}
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return res.status(400).json({ error: 'month must be an integer between 1 and 12' });
|
||||
}
|
||||
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1));
|
||||
const dayByDate = new Map(days.map(day => [day.date, day]));
|
||||
|
||||
const bills = db.prepare(`
|
||||
SELECT b.*, c.name AS category_name
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON b.category_id = c.id
|
||||
WHERE b.active = 1 AND b.user_id = ?
|
||||
ORDER BY b.due_day ASC, b.name ASC
|
||||
`).all(req.user.id);
|
||||
|
||||
const paymentsByBillStmt = db.prepare(`
|
||||
SELECT *
|
||||
FROM payments
|
||||
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY paid_date DESC
|
||||
`);
|
||||
|
||||
const monthlyStateStmt = db.prepare(`
|
||||
SELECT actual_amount, notes, is_skipped
|
||||
FROM monthly_bill_state
|
||||
WHERE bill_id = ? AND year = ? AND month = ?
|
||||
`);
|
||||
|
||||
const payments = db.prepare(`
|
||||
SELECT
|
||||
p.id AS payment_id,
|
||||
p.bill_id,
|
||||
b.name AS bill_name,
|
||||
p.amount,
|
||||
p.paid_date,
|
||||
p.method,
|
||||
p.notes
|
||||
FROM payments p
|
||||
JOIN bills b ON p.bill_id = b.id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
ORDER BY p.paid_date ASC, b.name ASC
|
||||
`).all(req.user.id, start, end);
|
||||
|
||||
for (const payment of payments) {
|
||||
const day = dayByDate.get(payment.paid_date);
|
||||
if (day) {
|
||||
day.payments.push({
|
||||
payment_id: payment.payment_id,
|
||||
bill_id: payment.bill_id,
|
||||
bill_name: payment.bill_name,
|
||||
amount: payment.amount,
|
||||
paid_date: payment.paid_date,
|
||||
method: payment.method || null,
|
||||
notes: payment.notes || null,
|
||||
});
|
||||
day.status_summary.total_paid += payment.amount || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const calendarBills = bills.map(bill => {
|
||||
const billPayments = paymentsByBillStmt.all(bill.id, start, end);
|
||||
const row = buildTrackerRow(bill, billPayments, year, month, today);
|
||||
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
|
||||
const actualAmount = monthlyState?.actual_amount ?? null;
|
||||
const isSkipped = !!monthlyState?.is_skipped;
|
||||
const effectiveAmount = actualAmount ?? row.expected_amount;
|
||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount;
|
||||
const isAutodraft = row.status === 'autodraft';
|
||||
const status = isSkipped
|
||||
? 'skipped'
|
||||
: isPaidByThreshold
|
||||
? 'paid'
|
||||
: row.status;
|
||||
const isPaid = status === 'paid' || isAutodraft;
|
||||
const dueDay = clampDay(year, month, bill.due_day);
|
||||
const dueDate = toDateString(year, month, dueDay);
|
||||
|
||||
return {
|
||||
bill_id: bill.id,
|
||||
name: bill.name,
|
||||
due_date: dueDate,
|
||||
due_day: dueDay,
|
||||
expected_amount: row.expected_amount,
|
||||
actual_amount: actualAmount,
|
||||
effective_amount: effectiveAmount,
|
||||
category_name: bill.category_name || null,
|
||||
is_paid: isPaid,
|
||||
is_skipped: isSkipped,
|
||||
paid_amount: row.total_paid || 0,
|
||||
status,
|
||||
};
|
||||
});
|
||||
|
||||
for (const bill of calendarBills) {
|
||||
const day = dayByDate.get(bill.due_date);
|
||||
if (!day) continue;
|
||||
|
||||
day.bills_due.push(bill);
|
||||
day.status_summary.due_count += 1;
|
||||
if (bill.is_paid) day.status_summary.paid_count += 1;
|
||||
if (bill.is_skipped) day.status_summary.skipped_count += 1;
|
||||
if (!bill.is_paid && !bill.is_skipped && (bill.status === 'late' || bill.status === 'missed')) {
|
||||
day.status_summary.missed_count += 1;
|
||||
}
|
||||
if (!bill.is_skipped) day.status_summary.total_due += bill.effective_amount || 0;
|
||||
}
|
||||
|
||||
const activeBills = calendarBills.filter(bill => !bill.is_skipped);
|
||||
const expectedTotal = activeBills.reduce((sum, bill) => sum + (bill.effective_amount || 0), 0);
|
||||
const paidTotal = activeBills.reduce((sum, bill) => sum + (bill.paid_amount || 0), 0);
|
||||
const remainingTotal = Math.max(0, expectedTotal - paidTotal);
|
||||
const paidPercent = expectedTotal > 0 ? Math.min(100, Math.round((paidTotal / expectedTotal) * 100)) : 0;
|
||||
|
||||
res.json({
|
||||
year,
|
||||
month,
|
||||
today,
|
||||
days,
|
||||
summary: {
|
||||
expected_total: expectedTotal,
|
||||
paid_total: paidTotal,
|
||||
remaining_total: remainingTotal,
|
||||
paid_percent: paidPercent,
|
||||
bill_count: activeBills.length,
|
||||
paid_count: activeBills.filter(bill => bill.is_paid).length,
|
||||
skipped_count: calendarBills.filter(bill => bill.is_skipped).length,
|
||||
missed_count: activeBills.filter(bill => bill.status === 'late' || bill.status === 'missed').length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -46,6 +46,8 @@ app.use('/api/bills', requireAuth, requireUser, require('./routes/bills'
|
|||
app.use('/api/payments', requireAuth, requireUser, require('./routes/payments'));
|
||||
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/analytics', requireAuth, requireUser, require('./routes/analytics'));
|
||||
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
||||
app.use('/api/status', requireAuth, require('./routes/status'));
|
||||
app.use('/api/version', require('./routes/version')); // public
|
||||
|
|
|
|||
Loading…
Reference in New Issue