push
This commit is contained in:
parent
b019487423
commit
d1efeece04
22
HISTORY.md
22
HISTORY.md
|
|
@ -1,5 +1,27 @@
|
|||
# Bill Tracker — Changelog
|
||||
|
||||
## v0.18.3
|
||||
|
||||
### Added
|
||||
- Added an `Other` monthly starting amount alongside the 1st and 15th amounts.
|
||||
- New `monthly_starting_amounts` records store user-scoped, month-specific starting cash with `first_amount`, `fifteenth_amount`, and `other_amount`.
|
||||
- New `GET /api/monthly-starting-amounts` and `PUT /api/monthly-starting-amounts` endpoints manage monthly starting balances.
|
||||
- Tracker renamed the “Total Expected” card to “Starting” and shows the selected month’s combined starting amount.
|
||||
- The Tracker Starting card now has an edit control for setting 1st, 15th, and Other monthly amounts.
|
||||
- Summary now uses monthly starting balances as the planning base and shows a Starting Balance section.
|
||||
- Remaining balances deduct paid bills: due days 1-14 from the 1st bucket, due days 15-31 from the 15th bucket, and total paid from combined remaining.
|
||||
- Added monthly starting amounts to user SQLite and Excel exports, and to user SQLite imports.
|
||||
- Added a public About page with app version, stack, AI-assistance note, and Release Notes access.
|
||||
- Release Notes are now available without login.
|
||||
|
||||
### Notes
|
||||
- Starting balances are not bills and are not payments.
|
||||
- Remaining values can go negative when paid bills exceed starting cash; overages are not blocked.
|
||||
- Previous month remaining is exposed to Summary as informational context only when available.
|
||||
- Navigation now groups Overview, Summary, Bills, and Categories under Tracker, and groups Profile, Settings, and Data in the user menu.
|
||||
- System Status is admin-only and appears in the Admin Panel navigation.
|
||||
- No payment behavior, bill behavior, Calendar, Analytics, auth, or admin behavior was changed.
|
||||
|
||||
## v0.18.1
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import Layout from '@/components/layout/Layout';
|
||||
import AppNavigation from '@/components/layout/Sidebar';
|
||||
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import AdminPage from '@/pages/AdminPage';
|
||||
|
|
@ -14,6 +15,7 @@ import SettingsPage from '@/pages/SettingsPage';
|
|||
import StatusPage from '@/pages/StatusPage';
|
||||
import AnalyticsPage from '@/pages/AnalyticsPage';
|
||||
import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
|
||||
import AboutPage from '@/pages/AboutPage';
|
||||
import DataPage from '@/pages/DataPage';
|
||||
import ProfilePage from '@/pages/ProfilePage';
|
||||
|
||||
|
|
@ -48,6 +50,17 @@ function RequireAuth({ children, role }) {
|
|||
return children;
|
||||
}
|
||||
|
||||
function AdminShell({ children }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground">
|
||||
<AppNavigation adminMode />
|
||||
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { user } = useAuth();
|
||||
|
||||
|
|
@ -58,6 +71,8 @@ export default function App() {
|
|||
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/release-notes" element={<ReleaseNotesPage />} />
|
||||
|
||||
<Route
|
||||
path="/admin"
|
||||
|
|
@ -67,6 +82,24 @@ export default function App() {
|
|||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/status"
|
||||
element={
|
||||
<RequireAuth role="admin">
|
||||
<AdminShell>
|
||||
<StatusPage />
|
||||
</AdminShell>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/status"
|
||||
element={
|
||||
<RequireAuth role="admin">
|
||||
<Navigate to="/admin/status" replace />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
element={
|
||||
|
|
@ -84,8 +117,6 @@ export default function App() {
|
|||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="data" element={<DataPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="status" element={<StatusPage />} />
|
||||
<Route path="release-notes" element={<ReleaseNotesPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ export const api = {
|
|||
// Summary
|
||||
summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
|
||||
saveSummaryIncome: (data) => put('/summary/income', data),
|
||||
getMonthlyStartingAmounts: (y, m) => get(`/monthly-starting-amounts?year=${y}&month=${m}`),
|
||||
updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data),
|
||||
|
||||
// Bills
|
||||
bills: () => get('/bills'),
|
||||
|
|
@ -162,6 +164,7 @@ export const api = {
|
|||
status: () => get('/status'),
|
||||
|
||||
// Version (public)
|
||||
about: () => get('/about'),
|
||||
version: () => get('/version'),
|
||||
releaseHistory: () => get('/version/history'),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Outlet } from 'react-router-dom';
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
import AppNavigation from './Sidebar';
|
||||
|
||||
export default function Layout() {
|
||||
|
|
@ -11,6 +11,10 @@ export default function Layout() {
|
|||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<footer className="mx-auto flex w-full max-w-[1500px] flex-wrap items-center justify-center gap-x-4 gap-y-2 px-4 pb-6 text-xs text-muted-foreground sm:px-6 lg:px-8">
|
||||
<Link to="/about" className="underline-offset-4 hover:text-foreground hover:underline">About</Link>
|
||||
<Link to="/release-notes" className="underline-offset-4 hover:text-foreground hover:underline">Release Notes</Link>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, LayoutGrid, LogOut, Menu, Receipt,
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Menu, Receipt,
|
||||
Settings, ShieldCheck, Tag, User, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -18,18 +18,20 @@ import {
|
|||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
const userNavItems = [
|
||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
||||
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
{ to: '/status', icon: Activity, label: 'Status' },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
{ to: '/admin', icon: ShieldCheck, label: 'Admin', end: true },
|
||||
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
||||
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
||||
];
|
||||
|
||||
const trackerItems = [
|
||||
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
|
||||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||
];
|
||||
|
||||
function BrandBlock({ adminMode = false }) {
|
||||
|
|
@ -74,6 +76,45 @@ function NavPill({ item, onNavigate }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TrackerMenu({ onNavigate }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isTrackerActive = trackerItems.some(item => (
|
||||
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
||||
));
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
isTrackerActive
|
||||
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground hover:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Tracker
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-75" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
{trackerItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<DropdownMenuItem key={item.to} onSelect={() => { navigate(item.to); onNavigate?.(); }}>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ adminMode = false }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -101,16 +142,36 @@ function UserMenu({ adminMode = false }) {
|
|||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{user?.role === 'admin' && !adminMode && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => navigate('/admin')}>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Admin Panel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => navigate('/admin/status')}>
|
||||
<Activity className="h-4 w-4" />
|
||||
System Status
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => navigate('/profile')}>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
{user?.role === 'admin' && !adminMode && (
|
||||
<DropdownMenuItem onSelect={() => navigate('/admin')}>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Admin
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => navigate('/data')}>
|
||||
<Database className="h-4 w-4" />
|
||||
Data
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => navigate('/about')}>
|
||||
<Info className="h-4 w-4" />
|
||||
About
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive onSelect={handleLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
|
|
@ -124,9 +185,7 @@ function UserMenu({ adminMode = false }) {
|
|||
export default function Sidebar({ adminMode = false }) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const items = user?.role === 'admin'
|
||||
? [...userNavItems, ...adminNavItems]
|
||||
: userNavItems;
|
||||
const items = adminMode ? adminNavItems : userNavItems;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/85 shadow-sm shadow-foreground/5 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70">
|
||||
|
|
@ -134,6 +193,7 @@ export default function Sidebar({ adminMode = false }) {
|
|||
<BrandBlock adminMode={adminMode} />
|
||||
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{!adminMode && <TrackerMenu />}
|
||||
{items.map(item => (
|
||||
<NavPill key={item.to} item={item} />
|
||||
))}
|
||||
|
|
@ -159,6 +219,9 @@ export default function Sidebar({ adminMode = false }) {
|
|||
{mobileOpen && (
|
||||
<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">
|
||||
{!adminMode && trackerItems.map(item => (
|
||||
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
||||
))}
|
||||
{items.map(item => (
|
||||
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Info, Sparkles } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function AboutPage() {
|
||||
const [about, setAbout] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setAbout(await api.about());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const stack = about?.stack || {};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
|
||||
<main className="mx-auto w-full max-w-3xl space-y-5">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||
<Link to="/login">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
|
||||
<Info className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{about?.name || 'BillTracker'}</CardTitle>
|
||||
<CardDescription>
|
||||
{loading ? 'Loading app information...' : about?.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Version</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold">v{about?.version || '...'}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backend</p>
|
||||
<p className="mt-1 text-sm font-semibold">{stack.backend || 'Node.js / Express'}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Storage</p>
|
||||
<p className="mt-1 text-sm font-semibold">{stack.database || 'SQLite'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-muted/35 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Produced with AI assistance</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
BillTracker is self-hosted software for personal bill planning and history. This product was produced with the assistance of AI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button asChild>
|
||||
<Link to="/release-notes">Release Notes</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/login">Sign In</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -157,30 +157,32 @@ function ExpandedBills({ category }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-6">
|
||||
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-5">
|
||||
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold">Bill</th>
|
||||
<th className="px-4 py-3 text-left font-semibold">Status</th>
|
||||
<th className="px-4 py-3 text-left font-semibold">State</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Expected</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Due</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Paid</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Payments</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Last Paid</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">History</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{bills.map(bill => (
|
||||
<tr key={bill.id} className="hover:bg-muted/25">
|
||||
<td className="px-4 py-3"><BillName bill={bill} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<BillName bill={bill} />
|
||||
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusPill active={bill.active} /></td>
|
||||
<td className="px-4 py-3 text-right font-mono">{fmt(bill.expected_amount)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{bill.due_day}</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{fmt(bill.total_paid)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{bill.payment_count || 0}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{fmtDate(bill.last_paid_date)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<p className="tabular-nums">{plural(bill.payment_count || 0, 'payment')}</p>
|
||||
<p className="mt-1 text-xs tabular-nums text-muted-foreground">{fmtDate(bill.last_paid_date)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -329,41 +331,60 @@ export default function CategoriesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0);
|
||||
const activeBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0), 0);
|
||||
const inactiveBills = categories.reduce((sum, cat) => sum + (cat.inactive_bill_count || 0), 0);
|
||||
const paymentCount = categories.reduce((sum, cat) => sum + (cat.payment_count || 0), 0);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{plural(categories.length, 'category')}</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{plural(totalBills, 'bill')}</span>
|
||||
</p>
|
||||
<div className="mx-auto w-full max-w-5xl space-y-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/70 bg-card shadow-sm">
|
||||
<ReceiptText className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">Organize bills by purpose, status, and payment activity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChipLegend />
|
||||
</div>
|
||||
|
||||
<div className="table-surface overflow-hidden">
|
||||
<div className="border-b border-border/50 bg-card/65 px-4 py-4 sm:px-6">
|
||||
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-xl sm:flex-row">
|
||||
<Input
|
||||
ref={addInputRef}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="New category name..."
|
||||
disabled={adding}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="h-9 sm:w-auto" disabled={adding || !newName.trim()}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{adding ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_minmax(20rem,26rem)]">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
['Categories', categories.length],
|
||||
['Active bills', activeBills],
|
||||
['Inactive', inactiveBills],
|
||||
['Payments', paymentCount],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-xl border border-border/70 bg-card/80 px-4 py-3 shadow-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 font-mono text-xl font-bold text-foreground">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAdd} className="flex min-w-0 flex-col gap-2 rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm sm:flex-row md:flex-col lg:flex-row">
|
||||
<Input
|
||||
ref={addInputRef}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="New category name..."
|
||||
disabled={adding}
|
||||
className="h-9 min-w-0 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="h-9 shrink-0 sm:w-auto" disabled={adding || !newName.trim()}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{adding ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="table-surface overflow-hidden rounded-xl">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
||||
) : categories.length === 0 ? (
|
||||
|
|
@ -385,7 +406,7 @@ export default function CategoriesPage() {
|
|||
onClick={() => toggleCategory(cat.id)}
|
||||
onKeyDown={event => onRowKeyDown(event, cat.id)}
|
||||
className={cn(
|
||||
'group flex cursor-pointer flex-col gap-4 px-4 py-4 transition-colors sm:px-6 lg:flex-row lg:items-center lg:justify-between',
|
||||
'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
|
||||
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
isExpanded && 'bg-muted/25',
|
||||
)}
|
||||
|
|
@ -412,10 +433,11 @@ export default function CategoriesPage() {
|
|||
</Tooltip>
|
||||
<StatChips category={cat} />
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{preview}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 self-end opacity-80 transition-opacity group-hover:opacity-100 lg:self-auto">
|
||||
<div className="flex items-center justify-end gap-1 opacity-80 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -447,11 +469,8 @@ export default function CategoriesPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<ReceiptText className="h-3.5 w-3.5" />
|
||||
<span>Category totals include active and inactive bills in your account only.</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Category totals include active and inactive bills in your account only.
|
||||
</div>
|
||||
|
||||
<InputDialog
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
|
@ -224,6 +224,14 @@ export default function LoginPage() {
|
|||
Build v{APP_VERSION}
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 text-xs text-muted-foreground">
|
||||
<Link to="/about" className="underline-offset-4 transition-colors hover:text-foreground hover:underline">
|
||||
About
|
||||
</Link>
|
||||
<Link to="/release-notes" className="underline-offset-4 transition-colors hover:text-foreground hover:underline">
|
||||
Release Notes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -83,13 +83,14 @@ export default function ReleaseNotesPage() {
|
|||
const history = data?.history || '';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
|
||||
<main className="mx-auto w-full max-w-4xl space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<Button asChild variant="ghost" size="sm" className="mb-2 -ml-2">
|
||||
<Link to="/status">
|
||||
<Link to="/about">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Status
|
||||
About
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Release Notes</h1>
|
||||
|
|
@ -129,6 +130,7 @@ export default function ReleaseNotesPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,8 +81,8 @@ function SummaryChart({ rows = [] }) {
|
|||
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
|
||||
const chartRows = rows.map((row, index) => ({
|
||||
...row,
|
||||
label: row.type === 'Savings'
|
||||
? Number(row.amount) >= 0 ? 'Savings' : 'Shortfall'
|
||||
label: row.type === 'Remaining'
|
||||
? Number(row.amount) >= 0 ? 'Remaining' : 'Shortfall'
|
||||
: row.type,
|
||||
color: index === 0
|
||||
? 'hsl(var(--chart-1))'
|
||||
|
|
@ -106,7 +106,7 @@ function SummaryChart({ rows = [] }) {
|
|||
title={`${row.label}: ${fmt(row.amount)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Savings' ? moneyClass(row.amount) : 'text-foreground')}>
|
||||
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Remaining' ? moneyClass(row.amount) : 'text-foreground')}>
|
||||
{fmt(row.amount)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -140,9 +140,10 @@ export default function SummaryPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [incomeLabel, setIncomeLabel] = useState('Salary');
|
||||
const [incomeAmount, setIncomeAmount] = useState('0');
|
||||
const [editingIncome, setEditingIncome] = useState(false);
|
||||
const [startingFirst, setStartingFirst] = useState('0');
|
||||
const [startingFifteenth, setStartingFifteenth] = useState('0');
|
||||
const [startingOther, setStartingOther] = useState('0');
|
||||
const [editingStarting, setEditingStarting] = useState(false);
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -150,9 +151,10 @@ export default function SummaryPage() {
|
|||
try {
|
||||
const result = await api.summary(selected.year, selected.month);
|
||||
setData(result);
|
||||
setIncomeLabel(result.income?.label || 'Salary');
|
||||
setIncomeAmount(String(result.income?.amount ?? 0));
|
||||
setEditingIncome(false);
|
||||
setStartingFirst(String(result.starting_amounts?.first_amount ?? 0));
|
||||
setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
|
||||
setStartingOther(String(result.starting_amounts?.other_amount ?? 0));
|
||||
setEditingStarting(false);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Summary could not be loaded.');
|
||||
toast.error(err.message || 'Summary could not be loaded.');
|
||||
|
|
@ -167,31 +169,35 @@ export default function SummaryPage() {
|
|||
|
||||
const summary = data?.summary || {};
|
||||
const expenses = data?.expenses || [];
|
||||
const starting = data?.starting_amounts || {};
|
||||
|
||||
const generatedLabel = useMemo(() => {
|
||||
if (!data?.generated_at) return '';
|
||||
return new Date(data.generated_at).toLocaleString();
|
||||
}, [data?.generated_at]);
|
||||
|
||||
async function saveIncome() {
|
||||
const amount = Number(incomeAmount);
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
toast.error('Enter a valid income amount.');
|
||||
async function saveStartingAmounts() {
|
||||
const first = Number(startingFirst);
|
||||
const fifteenth = Number(startingFifteenth);
|
||||
const other = Number(startingOther);
|
||||
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
|
||||
toast.error('Enter non-negative starting amounts.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveSummaryIncome({
|
||||
await api.updateMonthlyStartingAmounts({
|
||||
year: selected.year,
|
||||
month: selected.month,
|
||||
label: incomeLabel.trim() || 'Salary',
|
||||
amount,
|
||||
first_amount: first,
|
||||
fifteenth_amount: fifteenth,
|
||||
other_amount: other,
|
||||
});
|
||||
toast.success('Income saved.');
|
||||
toast.success('Starting amounts saved.');
|
||||
await loadSummary();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Income could not be saved.');
|
||||
toast.error(err.message || 'Starting amounts could not be saved.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -216,7 +222,7 @@ export default function SummaryPage() {
|
|||
<div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Summary</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Plan income, expenses, and monthly result.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Plan starting balance, expenses, and monthly result.</p>
|
||||
</div>
|
||||
<div className="summary-actions flex gap-2">
|
||||
<Button variant="outline" onClick={resetToday} className="sm:w-auto">
|
||||
|
|
@ -272,46 +278,91 @@ export default function SummaryPage() {
|
|||
<CardContent className="space-y-5">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Income</h2>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Starting Balance</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="summary-edit-actions h-7 px-2"
|
||||
onClick={() => setEditingIncome(value => !value)}
|
||||
onClick={() => setEditingStarting(value => !value)}
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
{editingIncome ? 'Close' : 'Edit'}
|
||||
{editingStarting ? 'Close' : 'Edit'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="summary-income-display flex items-center justify-between gap-4 rounded-2xl bg-muted/45 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-foreground">{data.income?.label || 'Salary'}</div>
|
||||
{Number(summary.income_total || 0) === 0 && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">Add income to calculate savings.</div>
|
||||
)}
|
||||
<div className="grid gap-3 rounded-2xl bg-muted/45 p-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">1st</div>
|
||||
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.first_amount)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">15th</div>
|
||||
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.fifteenth_amount)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">Other</div>
|
||||
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.other_amount)}</div>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-3 sm:col-span-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">Total starting</div>
|
||||
<div className="mt-1 font-mono text-lg font-bold text-foreground">{fmt(starting.combined_amount)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">Paid</div>
|
||||
<div className="mt-1 font-mono text-lg font-bold text-emerald-600 dark:text-emerald-400">{fmt(starting.paid_total)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">Total remaining</div>
|
||||
<div className={cn('mt-1 font-mono text-lg font-bold', moneyClass(starting.combined_remaining || 0))}>
|
||||
{fmt(starting.combined_remaining)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-lg font-bold text-foreground">{fmt(summary.income_total)}</div>
|
||||
</div>
|
||||
|
||||
{editingIncome && (
|
||||
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[minmax(0,1fr)_10rem_auto] md:items-end">
|
||||
{data.previous_month && (
|
||||
<div className="rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
Previous month remaining: {fmt(data.previous_month.combined_remaining)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingStarting && (
|
||||
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Label</span>
|
||||
<Input value={incomeLabel} onChange={event => setIncomeLabel(event.target.value)} placeholder="Salary" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Amount</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">1st</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={incomeAmount}
|
||||
onChange={event => setIncomeAmount(event.target.value)}
|
||||
value={startingFirst}
|
||||
onChange={event => setStartingFirst(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={saveIncome} disabled={saving} className="summary-edit-actions w-full md:w-auto">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">15th</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={startingFifteenth}
|
||||
onChange={event => setStartingFifteenth(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Other</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={startingOther}
|
||||
onChange={event => setStartingOther(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={saveStartingAmounts} disabled={saving} className="summary-edit-actions w-full md:w-auto">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Save
|
||||
</Button>
|
||||
|
|
@ -369,7 +420,7 @@ export default function SummaryPage() {
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-xl">Total amount per type</CardTitle>
|
||||
<CardDescription>
|
||||
Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}.
|
||||
Starting balance, planned expenses, and {Number(summary.result || 0) >= 0 ? 'remaining' : 'shortfall'} for {monthLabel(data.year, data.month)}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ const STATUS_META = {
|
|||
|
||||
// ── Summary cards ──────────────────────────────────────────────────────────
|
||||
const CARD_DEFS = {
|
||||
expected: {
|
||||
label: 'Total Expected',
|
||||
starting: {
|
||||
label: 'Starting',
|
||||
icon: TrendingUp,
|
||||
bar: 'from-slate-400 to-slate-300',
|
||||
glow: '',
|
||||
|
|
@ -96,7 +96,7 @@ const CARD_DEFS = {
|
|||
},
|
||||
};
|
||||
|
||||
function SummaryCard({ type, value }) {
|
||||
function SummaryCard({ type, value, onEdit, hint }) {
|
||||
const def = CARD_DEFS[type];
|
||||
const isActive = def.activateWhen(value || 0);
|
||||
const Icon = def.icon;
|
||||
|
|
@ -118,6 +118,16 @@ function SummaryCard({ type, value }) {
|
|||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{def.label}
|
||||
</p>
|
||||
{type === 'starting' && onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit monthly starting amounts"
|
||||
aria-label="Edit monthly starting amounts"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={cn(
|
||||
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
|
||||
|
|
@ -125,6 +135,7 @@ function SummaryCard({ type, value }) {
|
|||
)}>
|
||||
{fmt(value)}
|
||||
</p>
|
||||
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -397,6 +408,186 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
|
|||
);
|
||||
}
|
||||
|
||||
function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [firstAmount, setFirstAmount] = useState('0');
|
||||
const [fifteenthAmount, setFifteenthAmount] = useState('0');
|
||||
const [otherAmount, setOtherAmount] = useState('0');
|
||||
const [preview, setPreview] = useState(null);
|
||||
|
||||
const monthName = `${MONTHS[month - 1]} ${year}`;
|
||||
const localFirst = Number(firstAmount) || 0;
|
||||
const localFifteenth = Number(fifteenthAmount) || 0;
|
||||
const localOther = Number(otherAmount) || 0;
|
||||
const totalStarting = localFirst + localFifteenth + localOther;
|
||||
const paidSoFar = Number(preview?.paid_total || 0);
|
||||
const firstRemaining = localFirst - Number(preview?.paid_from_first || 0);
|
||||
const fifteenthRemaining = localFifteenth - Number(preview?.paid_from_fifteenth || 0);
|
||||
const totalRemaining = totalStarting - paidSoFar;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
async function loadStartingAmounts() {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.getMonthlyStartingAmounts(year, month);
|
||||
if (!alive) return;
|
||||
setPreview(result);
|
||||
setFirstAmount(String(result.first_amount ?? 0));
|
||||
setFifteenthAmount(String(result.fifteenth_amount ?? 0));
|
||||
setOtherAmount(String(result.other_amount ?? 0));
|
||||
} catch (err) {
|
||||
if (!alive) return;
|
||||
setError(err.message || 'Monthly starting amounts could not be loaded.');
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadStartingAmounts();
|
||||
return () => { alive = false; };
|
||||
}, [open, year, month]);
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault();
|
||||
const first = Number(firstAmount);
|
||||
const fifteenth = Number(fifteenthAmount);
|
||||
const other = Number(otherAmount);
|
||||
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
|
||||
setError('Starting amounts must be non-negative numbers.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.updateMonthlyStartingAmounts({
|
||||
year,
|
||||
month,
|
||||
first_amount: first,
|
||||
fifteenth_amount: fifteenth,
|
||||
other_amount: other,
|
||||
});
|
||||
toast.success('Monthly starting amounts saved.');
|
||||
onSave();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Monthly starting amounts could not be saved.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold tracking-tight">Monthly Starting Amounts</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">{monthName}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="starting-amounts-form" onSubmit={handleSave} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="space-y-1.5">
|
||||
<Label htmlFor="starting-first" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
1st
|
||||
</Label>
|
||||
<Input
|
||||
id="starting-first"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={firstAmount}
|
||||
disabled={loading || saving}
|
||||
onChange={e => setFirstAmount(e.target.value)}
|
||||
className="font-mono bg-background/50 border-border/60"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<Label htmlFor="starting-fifteenth" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
15th
|
||||
</Label>
|
||||
<Input
|
||||
id="starting-fifteenth"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={fifteenthAmount}
|
||||
disabled={loading || saving}
|
||||
onChange={e => setFifteenthAmount(e.target.value)}
|
||||
className="font-mono bg-background/50 border-border/60"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<Label htmlFor="starting-other" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Other
|
||||
</Label>
|
||||
<Input
|
||||
id="starting-other"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={otherAmount}
|
||||
disabled={loading || saving}
|
||||
onChange={e => setOtherAmount(e.target.value)}
|
||||
className="font-mono bg-background/50 border-border/60"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-muted/35 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalStarting)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(paidSoFar)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalRemaining)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
|
||||
<div className="flex justify-between gap-3 sm:block">
|
||||
<span className="text-muted-foreground">1st remaining</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(firstRemaining)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 sm:block">
|
||||
<span className="text-muted-foreground">15th remaining</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(fifteenthRemaining)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 sm:block">
|
||||
<span className="text-muted-foreground">Other</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(localOther)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button type="button" variant="ghost" disabled={saving} onClick={onClose} className="text-xs">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="starting-amounts-form" disabled={loading || saving} className="text-xs">
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment modal ──────────────────────────────────────────────────────────
|
||||
function PaymentModal({ payment, onClose, onSave }) {
|
||||
const [amount, setAmount] = useState(String(payment.amount));
|
||||
|
|
@ -983,6 +1174,8 @@ export default function TrackerPage() {
|
|||
const [data, setData] = useState(null);
|
||||
// Edit Bill modal: { bill, categories } when open, null when closed
|
||||
const [editBillData, setEditBillData] = useState(null);
|
||||
// Edit Starting Amounts modal: true when open, false when closed
|
||||
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -1072,7 +1265,12 @@ export default function TrackerPage() {
|
|||
|
||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
||||
<SummaryCard type="expected" value={summary.total_expected} />
|
||||
<SummaryCard
|
||||
type="starting"
|
||||
value={summary.total_starting}
|
||||
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
|
||||
onEdit={() => setEditStartingOpen(true)}
|
||||
/>
|
||||
<SummaryCard type="paid" value={summary.total_paid} />
|
||||
<SummaryCard type="remaining" value={summary.remaining} />
|
||||
<SummaryCard type="overdue" value={summary.overdue} />
|
||||
|
|
@ -1105,6 +1303,15 @@ export default function TrackerPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Starting Amounts modal */}
|
||||
<StartingAmountsEditDialog
|
||||
open={editStartingOpen}
|
||||
onClose={() => setEditStartingOpen(false)}
|
||||
year={year}
|
||||
month={month}
|
||||
onSave={() => { setEditStartingOpen(false); load(); }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,7 +168,30 @@ function runMigrations() {
|
|||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
|
||||
|
||||
// ── import_sessions: temporary preview state (v0.38) ─────────────────────
|
||||
// -- monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th (v0.18.2)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
|
||||
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
|
||||
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, year, month)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
|
||||
|
||||
// ── monthly_starting_amounts: add other_amount column (v0.18.3) ─────────────
|
||||
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
|
||||
if (!startingCols.includes('other_amount')) {
|
||||
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
|
||||
console.log('[migration] monthly_starting_amounts.other_amount column added');
|
||||
}
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.18.1",
|
||||
"version": "0.18.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bill-tracker",
|
||||
"version": "0.18.1",
|
||||
"version": "0.18.3",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.18.1",
|
||||
"version": "0.18.3",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
let pkg;
|
||||
try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; }
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'BillTracker',
|
||||
version: pkg.version,
|
||||
description: 'A self-hosted app for tracking recurring bills, monthly payments, due dates, categories, and personal bill history.',
|
||||
stack: {
|
||||
backend: 'Node.js / Express',
|
||||
frontend: 'React',
|
||||
database: 'SQLite',
|
||||
},
|
||||
ai_assisted: true,
|
||||
links: {
|
||||
release_notes: '/release-notes',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -106,8 +106,25 @@ router.delete('/:id', (req, res) => {
|
|||
const db = getDb();
|
||||
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
||||
db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
||||
res.json({ success: true });
|
||||
|
||||
const deleteCategory = db.transaction(() => {
|
||||
const bills = db.prepare(`
|
||||
UPDATE bills
|
||||
SET category_id = NULL, updated_at = datetime('now')
|
||||
WHERE category_id = ? AND user_id = ?
|
||||
`).run(req.params.id, req.user.id);
|
||||
|
||||
const deleted = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?')
|
||||
.run(req.params.id, req.user.id);
|
||||
|
||||
return {
|
||||
deleted: deleted.changes,
|
||||
uncategorized_bills: bills.changes,
|
||||
};
|
||||
});
|
||||
|
||||
const result = deleteCategory();
|
||||
res.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ function getUserExportData(userId) {
|
|||
WHERE b.user_id = ?
|
||||
ORDER BY m.year, m.month, m.bill_id
|
||||
`).all(userId);
|
||||
const monthlyStartingAmounts = db.prepare(`
|
||||
SELECT id, year, month, first_amount, fifteenth_amount, other_amount, notes, created_at, updated_at
|
||||
FROM monthly_starting_amounts
|
||||
WHERE user_id = ?
|
||||
ORDER BY year, month
|
||||
`).all(userId);
|
||||
const notes = [
|
||||
...bills.filter(b => b.notes).map(b => ({ type: 'bill', bill_id: b.id, notes: b.notes })),
|
||||
...payments.filter(p => p.notes).map(p => ({ type: 'payment', payment_id: p.id, bill_id: p.bill_id, notes: p.notes })),
|
||||
|
|
@ -117,16 +123,17 @@ function getUserExportData(userId) {
|
|||
const metadata = {
|
||||
exported_at: new Date().toISOString(),
|
||||
export_type: 'user_data',
|
||||
includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Notes', 'Export metadata'],
|
||||
includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Monthly starting amounts', 'Notes', 'Export metadata'],
|
||||
counts: {
|
||||
bills: bills.length,
|
||||
payments: payments.length,
|
||||
categories: categories.length,
|
||||
monthly_bill_state: monthlyState.length,
|
||||
monthly_starting_amounts: monthlyStartingAmounts.length,
|
||||
notes: notes.length,
|
||||
},
|
||||
};
|
||||
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
|
||||
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
|
||||
}
|
||||
|
||||
router.get('/user-excel', (req, res) => {
|
||||
|
|
@ -137,6 +144,7 @@ router.get('/user-excel', (req, res) => {
|
|||
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.payments), 'Payments');
|
||||
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.categories), 'Categories');
|
||||
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_bill_state), 'Monthly State');
|
||||
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_starting_amounts), 'Monthly Starting Amounts');
|
||||
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.notes), 'Notes');
|
||||
const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
|
|
@ -155,6 +163,7 @@ router.get('/user-db', (req, res) => {
|
|||
CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT);
|
||||
CREATE TABLE payments (id INTEGER PRIMARY KEY, bill_id INTEGER, amount REAL, paid_date TEXT, method TEXT, notes TEXT, created_at TEXT, updated_at TEXT);
|
||||
CREATE TABLE monthly_bill_state (id INTEGER PRIMARY KEY, bill_id INTEGER, year INTEGER, month INTEGER, actual_amount REAL, notes TEXT, is_skipped INTEGER, created_at TEXT, updated_at TEXT);
|
||||
CREATE TABLE monthly_starting_amounts (id INTEGER PRIMARY KEY, year INTEGER, month INTEGER, first_amount REAL, fifteenth_amount REAL, other_amount REAL, notes TEXT, created_at TEXT, updated_at TEXT);
|
||||
CREATE TABLE notes (type TEXT, bill_id INTEGER, payment_id INTEGER, monthly_state_id INTEGER, year INTEGER, month INTEGER, notes TEXT);
|
||||
`);
|
||||
const meta = out.prepare('INSERT INTO export_metadata (key, value) VALUES (?, ?)');
|
||||
|
|
@ -170,6 +179,7 @@ router.get('/user-db', (req, res) => {
|
|||
insertRows('bills', data.bills);
|
||||
insertRows('payments', data.payments);
|
||||
insertRows('monthly_bill_state', data.monthly_bill_state);
|
||||
insertRows('monthly_starting_amounts', data.monthly_starting_amounts);
|
||||
insertRows('notes', data.notes.map(n => ({
|
||||
type: n.type,
|
||||
bill_id: n.bill_id ?? null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database');
|
||||
const { getCycleRange } = require('../services/statusService');
|
||||
|
||||
function parseYearMonth(source) {
|
||||
const now = new Date();
|
||||
const year = parseInt(source.year || now.getFullYear(), 10);
|
||||
const month = parseInt(source.month || now.getMonth() + 1, 10);
|
||||
|
||||
if (Number.isNaN(year) || year < 2000 || year > 2100) {
|
||||
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
|
||||
}
|
||||
if (Number.isNaN(month) || month < 1 || month > 12) {
|
||||
return { error: 'month must be an integer between 1 and 12' };
|
||||
}
|
||||
|
||||
return { year, month };
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function getStartingAmounts(db, userId, year, month) {
|
||||
const row = db.prepare(`
|
||||
SELECT first_amount, fifteenth_amount, other_amount
|
||||
FROM monthly_starting_amounts
|
||||
WHERE user_id = ? AND year = ? AND month = ?
|
||||
`).get(userId, year, month);
|
||||
|
||||
return {
|
||||
first_amount: money(row?.first_amount || 0),
|
||||
fifteenth_amount: money(row?.fifteenth_amount || 0),
|
||||
other_amount: money(row?.other_amount || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function calculatePaidDeductions(db, userId, year, month) {
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
|
||||
// Paid from first bucket: bills with due_day 1-14
|
||||
const firstPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
AND b.due_day BETWEEN 1 AND 14
|
||||
`).get(userId, start, end);
|
||||
|
||||
// Paid from fifteenth bucket: bills with due_day 15-31
|
||||
const fifteenthPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
AND b.due_day BETWEEN 15 AND 31
|
||||
`).get(userId, start, end);
|
||||
|
||||
const totalPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
`).get(userId, start, end);
|
||||
|
||||
return {
|
||||
paid_from_first: money(firstPaid.paid),
|
||||
paid_from_fifteenth: money(fifteenthPaid.paid),
|
||||
paid_total: money(totalPaid.paid),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStartingAmountsResponse(db, userId, year, month) {
|
||||
const amounts = getStartingAmounts(db, userId, year, month);
|
||||
const paid = calculatePaidDeductions(db, userId, year, month);
|
||||
|
||||
const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
|
||||
const paid_total = paid.paid_total;
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
first_amount: amounts.first_amount,
|
||||
fifteenth_amount: amounts.fifteenth_amount,
|
||||
other_amount: amounts.other_amount,
|
||||
combined_amount,
|
||||
paid_from_first: paid.paid_from_first,
|
||||
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||
paid_total,
|
||||
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||
other_remaining: amounts.other_amount,
|
||||
combined_remaining: combined_amount - paid_total,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const parsed = parseYearMonth(req.query);
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const db = getDb();
|
||||
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
|
||||
});
|
||||
|
||||
router.put('/', (req, res) => {
|
||||
const parsed = parseYearMonth(req.body || {});
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const firstAmount = Number(req.body?.first_amount);
|
||||
if (!Number.isFinite(firstAmount) || firstAmount < 0 || firstAmount > 1000000000) {
|
||||
return res.status(400).json({ error: 'first_amount must be a number between 0 and 1000000000' });
|
||||
}
|
||||
|
||||
const fifteenthAmount = Number(req.body?.fifteenth_amount);
|
||||
if (!Number.isFinite(fifteenthAmount) || fifteenthAmount < 0 || fifteenthAmount > 1000000000) {
|
||||
return res.status(400).json({ error: 'fifteenth_amount must be a number between 0 and 1000000000' });
|
||||
}
|
||||
|
||||
const otherAmount = Number(req.body?.other_amount);
|
||||
if (!Number.isFinite(otherAmount) || otherAmount < 0 || otherAmount > 1000000000) {
|
||||
return res.status(400).json({ error: 'other_amount must be a number between 0 and 1000000000' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, year, month) DO UPDATE SET
|
||||
first_amount = excluded.first_amount,
|
||||
fifteenth_amount = excluded.fifteenth_amount,
|
||||
other_amount = excluded.other_amount,
|
||||
updated_at = datetime('now')
|
||||
`).run(req.user.id, parsed.year, parsed.month, firstAmount, fifteenthAmount, otherAmount);
|
||||
|
||||
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -25,6 +25,85 @@ function money(value) {
|
|||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function getStartingAmounts(db, userId, year, month) {
|
||||
const row = db.prepare(`
|
||||
SELECT first_amount, fifteenth_amount, other_amount
|
||||
FROM monthly_starting_amounts
|
||||
WHERE user_id = ? AND year = ? AND month = ?
|
||||
`).get(userId, year, month);
|
||||
|
||||
return {
|
||||
first_amount: money(row?.first_amount || 0),
|
||||
fifteenth_amount: money(row?.fifteenth_amount || 0),
|
||||
other_amount: money(row?.other_amount || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function calculatePaidDeductions(db, userId, year, month) {
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
|
||||
// Paid from first bucket: bills with due_day 1-14
|
||||
const firstPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
AND b.due_day BETWEEN 1 AND 14
|
||||
`).get(userId, start, end);
|
||||
|
||||
// Paid from fifteenth bucket: bills with due_day 15-31
|
||||
const fifteenthPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
AND b.due_day BETWEEN 15 AND 31
|
||||
`).get(userId, start, end);
|
||||
|
||||
const totalPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
`).get(userId, start, end);
|
||||
|
||||
return {
|
||||
paid_from_first: money(firstPaid.paid),
|
||||
paid_from_fifteenth: money(fifteenthPaid.paid),
|
||||
paid_total: money(totalPaid.paid),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStartingAmountsSummary(db, userId, year, month) {
|
||||
const amounts = getStartingAmounts(db, userId, year, month);
|
||||
const paid = calculatePaidDeductions(db, userId, year, month);
|
||||
|
||||
const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
|
||||
const paid_total = paid.paid_total;
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
first_amount: amounts.first_amount,
|
||||
fifteenth_amount: amounts.fifteenth_amount,
|
||||
other_amount: amounts.other_amount,
|
||||
combined_amount,
|
||||
paid_from_first: paid.paid_from_first,
|
||||
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||
paid_total,
|
||||
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||
other_remaining: amounts.other_amount,
|
||||
combined_remaining: combined_amount - paid_total,
|
||||
};
|
||||
}
|
||||
|
||||
function getIncome(db, userId, year, month) {
|
||||
const row = db.prepare(`
|
||||
SELECT id, label, amount
|
||||
|
|
@ -109,26 +188,57 @@ function buildSummary(db, userId, year, month) {
|
|||
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
|
||||
const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
|
||||
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
||||
const result = incomeTotal - expenseTotal;
|
||||
const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
|
||||
const planBaseTotal = money(starting_amounts.combined_amount);
|
||||
const result = planBaseTotal - expenseTotal;
|
||||
|
||||
// Previous month context
|
||||
let previous_month = null;
|
||||
if (month > 1) {
|
||||
const prevMonth = month - 1;
|
||||
const prevYear = year;
|
||||
const prevStarting = buildStartingAmountsSummary(db, userId, prevYear, prevMonth);
|
||||
if (prevStarting.combined_amount > 0) {
|
||||
previous_month = {
|
||||
year: prevYear,
|
||||
month: prevMonth,
|
||||
combined_remaining: prevStarting.combined_remaining,
|
||||
};
|
||||
}
|
||||
} else if (year > 2000) {
|
||||
const prevMonth = 12;
|
||||
const prevYear = year - 1;
|
||||
const prevStarting = buildStartingAmountsSummary(db, userId, prevYear, prevMonth);
|
||||
if (prevStarting.combined_amount > 0) {
|
||||
previous_month = {
|
||||
year: prevYear,
|
||||
month: prevMonth,
|
||||
combined_remaining: prevStarting.combined_remaining,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
income,
|
||||
expenses,
|
||||
starting_amounts,
|
||||
previous_month,
|
||||
summary: {
|
||||
income_total: incomeTotal,
|
||||
starting_total: planBaseTotal,
|
||||
expense_total: expenseTotal,
|
||||
paid_expense_count: paidExpenseCount,
|
||||
expense_count: countedExpenses.length,
|
||||
paid_total: paidTotal,
|
||||
paid_total: starting_amounts.paid_total,
|
||||
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
|
||||
result,
|
||||
},
|
||||
chart: [
|
||||
{ type: 'Income', amount: incomeTotal },
|
||||
{ type: 'Starting', amount: planBaseTotal },
|
||||
{ type: 'Expenses', amount: expenseTotal },
|
||||
{ type: 'Savings', amount: result },
|
||||
{ type: 'Remaining', amount: result },
|
||||
],
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,20 +51,32 @@ router.get('/', (req, res) => {
|
|||
return row;
|
||||
});
|
||||
|
||||
const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
|
||||
const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
|
||||
const totalOverdue = rows
|
||||
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
|
||||
.reduce((s, r) => s + r.balance, 0);
|
||||
|
||||
const activeRows = rows.filter(r => !r.is_skipped);
|
||||
|
||||
// Get starting amounts for this month
|
||||
const startingAmounts = db.prepare(`
|
||||
SELECT COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
|
||||
FROM monthly_starting_amounts
|
||||
WHERE user_id = ? AND year = ? AND month = ?
|
||||
`).get(req.user.id, year, month);
|
||||
|
||||
const totalStarting = startingAmounts?.combined_amount || 0;
|
||||
const hasStartingAmounts = !!startingAmounts;
|
||||
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
|
||||
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
|
||||
|
||||
res.json({
|
||||
year, month, today: todayStr,
|
||||
summary: {
|
||||
total_expected: activeRows.reduce((s, r) => s + r.expected_amount, 0),
|
||||
total_paid: activeRows.reduce((s, r) => s + r.total_paid, 0),
|
||||
remaining: Math.max(0, activeRows.reduce((s, r) => s + r.expected_amount, 0) - activeRows.reduce((s, r) => s + r.total_paid, 0)),
|
||||
total_expected: activeTotalExpected,
|
||||
total_starting: totalStarting,
|
||||
has_starting_amounts: hasStartingAmounts,
|
||||
total_paid: activeTotalPaid,
|
||||
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid),
|
||||
overdue: totalOverdue,
|
||||
count_paid: activeRows.filter(r => r.status === 'paid').length,
|
||||
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
|
||||
|
|
|
|||
|
|
@ -48,9 +48,11 @@ app.use('/api/categories', requireAuth, requireUser, require('./routes/catego
|
|||
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
|
||||
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
|
||||
app.use('/api/summary', requireAuth, requireUser, require('./routes/summary'));
|
||||
app.use('/api/monthly-starting-amounts', requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
|
||||
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
|
||||
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
||||
app.use('/api/status', requireAuth, require('./routes/status'));
|
||||
app.use('/api/status', requireAuth, requireAdmin, require('./routes/status'));
|
||||
app.use('/api/about', require('./routes/about')); // public
|
||||
app.use('/api/version', require('./routes/version')); // public
|
||||
|
||||
// Profile — password-change rate limit applied inside the route file
|
||||
|
|
|
|||
|
|
@ -187,6 +187,23 @@ function sanitizeMonthlyState(row, validBillIds) {
|
|||
};
|
||||
}
|
||||
|
||||
function sanitizeMonthlyStartingAmounts(row) {
|
||||
const year = toInt(row.year);
|
||||
const month = toInt(row.month);
|
||||
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
|
||||
return {
|
||||
old_id: toInt(row.id),
|
||||
year,
|
||||
month,
|
||||
first_amount: Math.max(0, toNumber(row.first_amount, 0) ?? 0),
|
||||
fifteenth_amount: Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0),
|
||||
other_amount: Math.max(0, toNumber(row.other_amount, 0) ?? 0),
|
||||
notes: cleanText(row.notes, 2000),
|
||||
created_at: cleanText(row.created_at, 32),
|
||||
updated_at: cleanText(row.updated_at, 32),
|
||||
};
|
||||
}
|
||||
|
||||
function readExportData(src) {
|
||||
const names = tableNames(src);
|
||||
const missing = REQUIRED_TABLES.filter(t => !names.has(t));
|
||||
|
|
@ -210,12 +227,16 @@ function readExportData(src) {
|
|||
.map(row => sanitizePayment(row, validBillIds)).filter(Boolean);
|
||||
const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at'])
|
||||
.map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean);
|
||||
const monthlyStartingAmounts = names.has('monthly_starting_amounts')
|
||||
? selectKnown(src, 'monthly_starting_amounts', ['id', 'year', 'month', 'first_amount', 'fifteenth_amount', 'other_amount', 'notes', 'created_at', 'updated_at'])
|
||||
.map(sanitizeMonthlyStartingAmounts).filter(Boolean)
|
||||
: [];
|
||||
const notes = names.has('notes')
|
||||
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes'])
|
||||
.map(n => ({ ...n, notes: cleanText(n.notes, 2000) })).filter(n => n.notes)
|
||||
: [];
|
||||
|
||||
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
|
||||
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
|
||||
}
|
||||
|
||||
function existingLookups(db, userId) {
|
||||
|
|
@ -271,6 +292,13 @@ function buildPreview(userId, data, originalFilename) {
|
|||
action: billPlanByOldId.has(m.bill_id) ? 'create_or_skip_duplicate' : 'conflict',
|
||||
reason: billPlanByOldId.has(m.bill_id) ? 'Will import if monthly state is missing' : 'Referenced bill is not present in export',
|
||||
}));
|
||||
const monthlyStartingAmountsPlan = data.monthly_starting_amounts.map(m => ({
|
||||
old_id: m.old_id,
|
||||
year: m.year,
|
||||
month: m.month,
|
||||
action: 'create_or_skip_duplicate',
|
||||
reason: 'Will import if monthly starting amounts are missing',
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
categories: {
|
||||
|
|
@ -297,6 +325,12 @@ function buildPreview(userId, data, originalFilename) {
|
|||
skip: 0,
|
||||
conflict: monthlyPlan.filter(x => x.action === 'conflict').length,
|
||||
},
|
||||
monthly_starting_amounts: {
|
||||
total: data.monthly_starting_amounts.length,
|
||||
create: data.monthly_starting_amounts.length,
|
||||
skip: 0,
|
||||
conflict: 0,
|
||||
},
|
||||
notes: {
|
||||
total: data.notes.length,
|
||||
create: 0,
|
||||
|
|
@ -319,6 +353,7 @@ function buildPreview(userId, data, originalFilename) {
|
|||
categories: data.categories.length,
|
||||
payments: data.payments.length,
|
||||
monthly_bill_state: data.monthly_bill_state.length,
|
||||
monthly_starting_amounts: data.monthly_starting_amounts.length,
|
||||
notes: data.notes.length,
|
||||
},
|
||||
summary,
|
||||
|
|
@ -327,6 +362,7 @@ function buildPreview(userId, data, originalFilename) {
|
|||
bills: billPlan.slice(0, 100),
|
||||
payments: paymentPlan.slice(0, 100),
|
||||
monthly_bill_state: monthlyPlan.slice(0, 100),
|
||||
monthly_starting_amounts: monthlyStartingAmountsPlan.slice(0, 100),
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
|
|
@ -470,6 +506,24 @@ function importMonthlyState(db, targetBillId, row, summary, details) {
|
|||
details.monthly_bill_state.created++;
|
||||
}
|
||||
|
||||
function importMonthlyStartingAmounts(db, userId, row, summary, details) {
|
||||
const duplicate = db.prepare(`
|
||||
SELECT id FROM monthly_starting_amounts WHERE user_id = ? AND year = ? AND month = ?
|
||||
`).get(userId, row.year, row.month);
|
||||
if (duplicate) {
|
||||
summary.rows_skipped++;
|
||||
summary.rows_conflicted++;
|
||||
details.monthly_starting_amounts.skipped++;
|
||||
return;
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, row.year, row.month, row.first_amount, row.fifteenth_amount, row.other_amount, row.notes);
|
||||
summary.rows_created++;
|
||||
details.monthly_starting_amounts.created++;
|
||||
}
|
||||
|
||||
async function applyUserDbImport(userId, importSessionId, options = {}) {
|
||||
if (options.overwrite) {
|
||||
throw importError(400, 'Overwrite is not supported for user SQLite imports. Existing data is skipped.', 'USER_DB_IMPORT_OVERWRITE_UNSUPPORTED');
|
||||
|
|
@ -491,6 +545,7 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
|
|||
bills: { created: 0, skipped: 0, errored: 0 },
|
||||
payments: { created: 0, skipped: 0, errored: 0 },
|
||||
monthly_bill_state: { created: 0, skipped: 0, errored: 0 },
|
||||
monthly_starting_amounts: { created: 0, skipped: 0, errored: 0 },
|
||||
notes: { created: 0, skipped: data.notes?.length || 0, errored: 0 },
|
||||
};
|
||||
summary.rows_skipped += details.notes.skipped;
|
||||
|
|
@ -529,6 +584,10 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
|
|||
importMonthlyState(db, targetBillId, row, summary, details);
|
||||
}
|
||||
|
||||
for (const row of data.monthly_starting_amounts) {
|
||||
importMonthlyStartingAmounts(db, userId, row, summary, details);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO import_history
|
||||
(user_id, imported_at, source_filename, file_type, sheet_name, rows_parsed,
|
||||
|
|
@ -541,7 +600,7 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
|
|||
session.source_filename || null,
|
||||
'sqlite',
|
||||
'User SQLite export',
|
||||
data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + (data.notes?.length || 0),
|
||||
data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + data.monthly_starting_amounts.length + (data.notes?.length || 0),
|
||||
summary.rows_created,
|
||||
summary.rows_updated,
|
||||
summary.rows_skipped,
|
||||
|
|
|
|||
Loading…
Reference in New Issue