calendar
This commit is contained in:
parent
eb908ce934
commit
bb5afcafb2
25
HISTORY.md
25
HISTORY.md
|
|
@ -1,5 +1,23 @@
|
||||||
# Bill Tracker — Changelog
|
# 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
|
## v0.18
|
||||||
|
|
||||||
### Branding
|
### 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`.
|
- 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.
|
- 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
|
### 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 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
|
- **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 LoginPage from '@/pages/LoginPage';
|
||||||
import AdminPage from '@/pages/AdminPage';
|
import AdminPage from '@/pages/AdminPage';
|
||||||
import TrackerPage from '@/pages/TrackerPage';
|
import TrackerPage from '@/pages/TrackerPage';
|
||||||
|
import CalendarPage from '@/pages/CalendarPage';
|
||||||
import BillsPage from '@/pages/BillsPage';
|
import BillsPage from '@/pages/BillsPage';
|
||||||
import CategoriesPage from '@/pages/CategoriesPage';
|
import CategoriesPage from '@/pages/CategoriesPage';
|
||||||
import SettingsPage from '@/pages/SettingsPage';
|
import SettingsPage from '@/pages/SettingsPage';
|
||||||
import StatusPage from '@/pages/StatusPage';
|
import StatusPage from '@/pages/StatusPage';
|
||||||
|
import AnalyticsPage from '@/pages/AnalyticsPage';
|
||||||
import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
|
import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
|
||||||
import DataPage from '@/pages/DataPage';
|
import DataPage from '@/pages/DataPage';
|
||||||
import ProfilePage from '@/pages/ProfilePage';
|
import ProfilePage from '@/pages/ProfilePage';
|
||||||
|
|
@ -73,8 +75,10 @@ export default function App() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<TrackerPage />} />
|
<Route index element={<TrackerPage />} />
|
||||||
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
<Route path="bills" element={<BillsPage />} />
|
<Route path="bills" element={<BillsPage />} />
|
||||||
<Route path="categories" element={<CategoriesPage />} />
|
<Route path="categories" element={<CategoriesPage />} />
|
||||||
|
<Route path="analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="data" element={<DataPage />} />
|
<Route path="data" element={<DataPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,9 @@ export const api = {
|
||||||
tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`),
|
tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`),
|
||||||
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
|
||||||
|
|
||||||
// Bills
|
// Bills
|
||||||
bills: () => get('/bills'),
|
bills: () => get('/bills'),
|
||||||
allBills: () => get('/bills?inactive=true'),
|
allBills: () => get('/bills?inactive=true'),
|
||||||
|
|
@ -141,6 +144,16 @@ export const api = {
|
||||||
settings: () => get('/settings'),
|
settings: () => get('/settings'),
|
||||||
saveSettings: (data) => put('/settings', data),
|
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
|
||||||
status: () => get('/status'),
|
status: () => get('/status'),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form id="bill-modal-form" onSubmit={handleSubmit}>
|
<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 */}
|
{/* Name */}
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
|
|
||||||
|
|
@ -10,138 +10,257 @@ function hasHistoricalVisibility(bill) {
|
||||||
return !!bill.has_history_ranges || (visibility && visibility !== 'default');
|
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
|
// Accepts row action handlers from BillsPage
|
||||||
export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) {
|
export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) {
|
||||||
return (
|
return (
|
||||||
<Table>
|
<>
|
||||||
|
<div className="grid gap-3 p-3 lg:hidden">
|
||||||
<TableHeader className="bg-muted border-b border-border/70">
|
|
||||||
<TableRow className="hover:bg-transparent border-0">
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Bill</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Category</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Due</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28 text-right">Expected</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28">Cycle</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Flags</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 w-72" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{bills.map((bill) => (
|
{bills.map((bill) => (
|
||||||
<TableRow
|
<MobileBillRow
|
||||||
key={bill.id}
|
key={bill.id}
|
||||||
className="group border-b border-border/50 last:border-0 hover:bg-accent/60 transition-colors"
|
bill={bill}
|
||||||
>
|
onEdit={onEdit}
|
||||||
|
onToggle={onToggle}
|
||||||
{/* Bill name */}
|
onDelete={onDelete}
|
||||||
<TableCell className="px-6 py-4">
|
onHistory={onHistory}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-left text-sm font-medium 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>
|
|
||||||
{hasHistoricalVisibility(bill) && (
|
|
||||||
<span
|
|
||||||
className="inline-flex h-5 w-5 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>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Category */}
|
|
||||||
<TableCell className="px-6 py-4">
|
|
||||||
{bill.category_name ? (
|
|
||||||
<span className="text-xs text-muted-foreground">{bill.category_name}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground/40 text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Due day */}
|
|
||||||
<TableCell className="px-6 py-4 w-24">
|
|
||||||
<span className="text-sm text-muted-foreground">Day {bill.due_day}</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Expected amount */}
|
|
||||||
<TableCell className="px-6 py-4 w-28 text-right">
|
|
||||||
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
|
||||||
${Number(bill.expected_amount).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Billing cycle — field is billing_cycle, not cycle */}
|
|
||||||
<TableCell className="px-6 py-4 w-28">
|
|
||||||
<span className="text-xs text-muted-foreground capitalize">
|
|
||||||
{bill.billing_cycle || 'monthly'}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Flags */}
|
|
||||||
<TableCell className="px-6 py-4 w-24">
|
|
||||||
{(!!bill.autopay_enabled || !!bill.has_2fa) ? (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{!!bill.autopay_enabled && (
|
|
||||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">AP</span>
|
|
||||||
)}
|
|
||||||
{!!bill.has_2fa && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400">2FA</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground/40 text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'h-7 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-7 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-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => onDelete?.(bill)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
</TableRow>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</div>
|
||||||
|
|
||||||
</Table>
|
<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">
|
||||||
|
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Bill</TableHead>
|
||||||
|
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Category</TableHead>
|
||||||
|
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Due</TableHead>
|
||||||
|
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28 text-right">Expected</TableHead>
|
||||||
|
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28">Cycle</TableHead>
|
||||||
|
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Flags</TableHead>
|
||||||
|
<TableHead className="px-6 py-3 w-72" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{bills.map((bill) => (
|
||||||
|
<TableRow
|
||||||
|
key={bill.id}
|
||||||
|
className="group border-b border-border/50 last:border-0 hover:bg-accent/60 transition-colors"
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Bill name */}
|
||||||
|
<TableCell className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-left text-sm font-medium 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>
|
||||||
|
{hasHistoricalVisibility(bill) && (
|
||||||
|
<span
|
||||||
|
className="inline-flex h-5 w-5 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>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<TableCell className="px-6 py-4">
|
||||||
|
{bill.category_name ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{bill.category_name}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/40 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Due day */}
|
||||||
|
<TableCell className="px-6 py-4 w-24">
|
||||||
|
<span className="text-sm text-muted-foreground">Day {bill.due_day}</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Expected amount */}
|
||||||
|
<TableCell className="px-6 py-4 w-28 text-right">
|
||||||
|
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||||
|
${Number(bill.expected_amount).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Billing cycle — field is billing_cycle, not cycle */}
|
||||||
|
<TableCell className="px-6 py-4 w-28">
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
{bill.billing_cycle || 'monthly'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Flags */}
|
||||||
|
<TableCell className="px-6 py-4 w-24">
|
||||||
|
{(!!bill.autopay_enabled || !!bill.has_2fa) ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{!!bill.autopay_enabled && (
|
||||||
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">AP</span>
|
||||||
|
)}
|
||||||
|
{!!bill.has_2fa && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400">2FA</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/40 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 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-100 transition-opacity lg:opacity-0 lg:group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-7 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-7 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-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => onDelete?.(bill)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
|
Activity, BarChart3, CalendarDays, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
|
||||||
Settings, ShieldCheck, Tag, User, X,
|
Settings, ShieldCheck, Tag, User, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -19,8 +19,10 @@ import {
|
||||||
|
|
||||||
const userNavItems = [
|
const userNavItems = [
|
||||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||||
|
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||||
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||||
{ to: '/status', icon: Activity, label: 'Status' },
|
{ 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">
|
<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} />
|
<BrandBlock adminMode={adminMode} />
|
||||||
|
|
||||||
<nav className="hidden items-center gap-1 md:flex">
|
<nav className="hidden items-center gap-1 lg:flex">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<NavPill key={item.to} item={item} />
|
<NavPill key={item.to} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -143,7 +145,7 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
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-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'}
|
||||||
aria-expanded={mobileOpen}
|
aria-expanded={mobileOpen}
|
||||||
onClick={() => setMobileOpen(v => !v)}
|
onClick={() => setMobileOpen(v => !v)}
|
||||||
|
|
@ -154,7 +156,7 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mobileOpen && (
|
{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">
|
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ function AlertDialogContent({ className, ...props }) {
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay />
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -131,3 +131,63 @@
|
||||||
@apply surface overflow-hidden shadow-sm;
|
@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 APP_NAME = 'BillTracker';
|
||||||
|
|
||||||
export const RELEASE_NOTES = {
|
export const RELEASE_NOTES = {
|
||||||
version: '0.16.2',
|
version: '0.18.1',
|
||||||
date: '2026-05-03',
|
date: '2026-05-04',
|
||||||
highlights: [
|
highlights: [
|
||||||
{ icon: '🗄️', title: 'SQLite data import', desc: 'Preview and import user-owned SQLite exports created by this app.' },
|
{ icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' },
|
||||||
{ icon: '🧾', title: 'Import tools layout', desc: 'Spreadsheet and SQLite import tools now sit side by side in Profile.' },
|
{ icon: '🧭', title: 'Tablet-safe navigation', desc: 'The top navigation uses the compact menu on tablet sizes to avoid horizontal overflow.' },
|
||||||
{ icon: '📦', title: 'Exports below imports', desc: 'User data export downloads now live below the import tools.' },
|
{ icon: '📊', title: 'Responsive analytics', desc: 'Analytics controls, charts, and the pay heatmap resize or scroll cleanly on smaller screens.' },
|
||||||
{ icon: '🎨', title: 'Material Design theme', desc: 'Light mode defaults to the shadcn Material Design theme tokens.' },
|
{ icon: '🪟', title: 'Viewport-safe dialogs', desc: 'Dialogs and confirmations fit mobile screens and scroll internally when content is long.' },
|
||||||
{ icon: '📅', title: 'Due day editing', desc: 'Bill due dates are edited as recurring day-of-month values.' },
|
{ 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 }) {
|
function FieldRow({ label, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[200px_1fr] items-center gap-4">
|
<div className="grid gap-2 lg:grid-cols-[200px_1fr] lg:items-center lg:gap-4">
|
||||||
<Label className="text-right text-muted-foreground">{label}</Label>
|
<Label className="text-muted-foreground lg:text-right">{label}</Label>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1174,7 +1174,7 @@ function AddUserCard({ onCreated }) {
|
||||||
<CardTitle>Add User</CardTitle>
|
<CardTitle>Add User</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<div className="space-y-1.5 flex-1">
|
||||||
<Label htmlFor="new-uname">Username</Label>
|
<Label htmlFor="new-uname">Username</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -1421,8 +1421,8 @@ function BackupManagementCard() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border overflow-hidden">
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
<table className="w-full text-sm">
|
<table className="min-w-[860px] w-full text-sm">
|
||||||
<thead className="bg-muted/40">
|
<thead className="bg-muted/40">
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Backup</th>
|
<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">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── 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>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
||||||
Manage
|
Manage
|
||||||
|
|
@ -443,7 +443,7 @@ export default function BillsPage() {
|
||||||
|
|
||||||
{/* ── Active Bills ── */}
|
{/* ── Active Bills ── */}
|
||||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
<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">
|
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
Active Bills
|
Active Bills
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -493,7 +493,7 @@ export default function BillsPage() {
|
||||||
|
|
||||||
{showInactive && (
|
{showInactive && (
|
||||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
<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">
|
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
Inactive Bills
|
Inactive Bills
|
||||||
</span>
|
</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">
|
<div className="table-surface">
|
||||||
|
|
||||||
{/* Card header with inline add form */}
|
{/* Card header with inline add form */}
|
||||||
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
<div className="px-4 py-4 border-b border-border/50 flex items-center gap-3 sm:px-6">
|
||||||
<form onSubmit={handleAdd} className="flex gap-2 flex-1 max-w-sm">
|
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-sm sm:flex-row">
|
||||||
<Input
|
<Input
|
||||||
ref={addInputRef}
|
ref={addInputRef}
|
||||||
value={newName}
|
value={newName}
|
||||||
|
|
@ -151,7 +151,7 @@ export default function CategoriesPage() {
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<div
|
<div
|
||||||
key={cat.id}
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium">{cat.name}</span>
|
<span className="text-sm font-medium">{cat.name}</span>
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export default function LoginPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ function SectionCard({ title, children }) {
|
||||||
|
|
||||||
function SettingRow({ label, description, children }) {
|
function SettingRow({ label, description, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-4 flex items-center justify-between">
|
<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 mr-8">
|
<div className="flex-1 min-w-0 sm:mr-8">
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ export default function StatusPage() {
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
{/* Page header — flat on background */}
|
{/* 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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<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 && (
|
{row.payments && row.payments.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="icon" variant="ghost"
|
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"
|
title="Edit payment"
|
||||||
onClick={() => setEditPayment(row.payments[0])}
|
onClick={() => setEditPayment(row.payments[0])}
|
||||||
>
|
>
|
||||||
|
|
@ -661,7 +661,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
{/* Monthly state editor (gear icon) — always available */}
|
{/* Monthly state editor (gear icon) — always available */}
|
||||||
<Button
|
<Button
|
||||||
size="icon" variant="ghost"
|
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)`}
|
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||||
onClick={() => setShowMbs(true)}
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Bucket ─────────────────────────────────────────────────────────────────
|
||||||
function Bucket({ label, rows, year, month, refresh, onEditBill }) {
|
function Bucket({ label, rows, year, month, refresh, onEditBill }) {
|
||||||
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
||||||
|
|
@ -746,35 +926,51 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
<div className="grid gap-3 p-3 lg:hidden">
|
||||||
<TableHeader>
|
{rows.map((r, i) => (
|
||||||
<TableRow className="border-border hover:bg-transparent">
|
<MobileTrackerRow
|
||||||
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead>
|
key={r.id}
|
||||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Due</TableHead>
|
row={r}
|
||||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Expected</TableHead>
|
year={year}
|
||||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Paid</TableHead>
|
month={month}
|
||||||
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Paid Date</TableHead>
|
refresh={refresh}
|
||||||
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Status</TableHead>
|
index={i}
|
||||||
<TableHead className="w-[10%] py-2.5" />
|
onEditBill={onEditBill}
|
||||||
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-l border-border pl-4">
|
/>
|
||||||
Notes
|
))}
|
||||||
</TableHead>
|
</div>
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
<div className="hidden lg:block">
|
||||||
<TableBody>
|
<Table className="min-w-[1120px]">
|
||||||
{rows.map((r, i) => (
|
<TableHeader>
|
||||||
<Row
|
<TableRow className="border-border hover:bg-transparent">
|
||||||
key={r.id}
|
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead>
|
||||||
row={r}
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Due</TableHead>
|
||||||
year={year}
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Expected</TableHead>
|
||||||
month={month}
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Paid</TableHead>
|
||||||
refresh={refresh}
|
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Paid Date</TableHead>
|
||||||
index={i}
|
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Status</TableHead>
|
||||||
onEditBill={onEditBill}
|
<TableHead className="w-[10%] py-2.5" />
|
||||||
/>
|
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-l border-border pl-4">
|
||||||
))}
|
Notes
|
||||||
</TableBody>
|
</TableHead>
|
||||||
</Table>
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<Row
|
||||||
|
key={r.id}
|
||||||
|
row={r}
|
||||||
|
year={year}
|
||||||
|
month={month}
|
||||||
|
refresh={refresh}
|
||||||
|
index={i}
|
||||||
|
onEditBill={onEditBill}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -835,7 +1031,7 @@ export default function TrackerPage() {
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── 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>
|
<div>
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
|
||||||
Monthly Overview
|
Monthly Overview
|
||||||
|
|
@ -875,7 +1071,7 @@ export default function TrackerPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
{/* ── 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="expected" value={summary.total_expected} />
|
||||||
<SummaryCard type="paid" value={summary.total_paid} />
|
<SummaryCard type="paid" value={summary.total_paid} />
|
||||||
<SummaryCard type="remaining" value={summary.remaining} />
|
<SummaryCard type="remaining" value={summary.remaining} />
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 297 KiB |
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.17",
|
"version": "0.18.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.17",
|
"version": "0.18.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.18",
|
"version": "0.18.1",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"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/payments', requireAuth, requireUser, require('./routes/payments'));
|
||||||
app.use('/api/categories', requireAuth, requireUser, require('./routes/categories'));
|
app.use('/api/categories', requireAuth, requireUser, require('./routes/categories'));
|
||||||
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
|
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
|
||||||
|
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
|
||||||
|
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
|
||||||
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
||||||
app.use('/api/status', requireAuth, require('./routes/status'));
|
app.use('/api/status', requireAuth, require('./routes/status'));
|
||||||
app.use('/api/version', require('./routes/version')); // public
|
app.use('/api/version', require('./routes/version')); // public
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue