Compare commits

..

No commits in common. "43bd58910aab3c7864fc88506f9fc88e31f621a5" and "3228332e8c7705d6ec13878b3de5036fa7df4ba4" have entirely different histories.

34 changed files with 1265 additions and 252 deletions

View File

@ -1,5 +1,35 @@
# Bill Tracker — Changelog
## v0.18.4
### Added
- Added user active/inactive management in Admin. Inactive users cannot log in and active sessions are invalidated when they are disabled.
- Added a durable default-admin marker so the built-in default admin remains an admin-only operator account.
- Admin users created after first run can now sign in directly to Tracker while retaining Admin Panel access from the menu.
- Admins can delete any other user, including other admins, with a destructive 2026 warning that all user-owned bill data will be permanently removed.
- 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 months 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.
- Profile now focuses on account details, display name, password, and notification preferences in a narrower modern layout.
- Data is restored as a dedicated import/export/history page instead of redirecting into Profile.
- Fixed Admin Panel availability when all managed users have been promoted to admin.
- The default admin account is blocked from Tracker/user-data routes; non-default admins keep regular Tracker access.
- No payment behavior, bill behavior, Calendar, or Analytics behavior was changed.
## v0.18.1
### Changed

View File

@ -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';
@ -40,6 +42,10 @@ function RequireAuth({ children, role }) {
const roleAllowed = !role || user.role === role || (role === 'user' && user.role === 'admin');
if (role === 'user' && user.is_default_admin) {
return <Navigate to="/admin" replace />;
}
// Role mismatch
if (!roleAllowed) {
return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />;
@ -48,6 +54,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 +75,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 +86,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 +121,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>

View File

@ -41,6 +41,7 @@ export const api = {
createUser: (data) => post('/admin/users', data),
resetPassword: (id, data) => put(`/admin/users/${id}/password`, data),
updateUserRole: (id, data) => put(`/admin/users/${id}/role`, data),
updateUserActive: (id, data) => put(`/admin/users/${id}/active`, data),
deleteUser: (id) => del(`/admin/users/${id}`),
authModeConfig: () => get('/admin/auth-mode'),
setAuthMode: (data) => put('/admin/auth-mode', data),
@ -114,6 +115,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 +165,7 @@ export const api = {
status: () => get('/status'),
// Version (public)
about: () => get('/about'),
version: () => get('/version'),
releaseHistory: () => get('/version/history'),

View File

@ -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>
);
}

View File

@ -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,10 +76,50 @@ 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();
const name = user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile');
const accountToolsAllowed = !user?.is_default_admin;
const handleLogout = async () => {
try { await logout(); } catch {}
@ -101,16 +143,40 @@ function UserMenu({ adminMode = false }) {
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
<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('/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 />
</>
)}
{accountToolsAllowed && (
<>
<DropdownMenuItem onSelect={() => navigate('/profile')}>
<User className="h-4 w-4" />
Profile
</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 +190,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 +198,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 +224,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)} />
))}

View File

@ -1,8 +1,8 @@
export const APP_VERSION = '0.18.1';
export const APP_VERSION = '0.18.4';
export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
version: '0.18.1',
version: '0.18.4',
date: '2026-05-04',
highlights: [
{ icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' },

View File

@ -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>
);
}

View File

@ -961,6 +961,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
const [deleting, setDeleting] = useState(null);
const [resetting, setResetting] = useState(null);
const [roleUpdating, setRoleUpdating] = useState(null);
const [activeUpdating, setActiveUpdating] = useState(null);
// Delete confirmation dialog
const [deleteTarget, setDeleteTarget] = useState(null); // user object
@ -1012,6 +1013,19 @@ function UsersTable({ users, onRefresh, currentUser }) {
}
};
const handleActiveChange = async (user, active) => {
setActiveUpdating(user.id);
try {
await api.updateUserActive(user.id, { active });
toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`);
onRefresh();
} catch (err) {
toast.error(err.message || 'Failed to update user status.');
} finally {
setActiveUpdating(null);
}
};
return (
<>
<Card>
@ -1025,6 +1039,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
<tr className="border-b border-border">
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Username</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Role</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Status</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Password</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Reset Password</th>
<th className="px-6 py-3" />
@ -1036,7 +1051,12 @@ function UsersTable({ users, onRefresh, currentUser }) {
const isSelf = currentUser?.id === user.id;
return (
<tr key={user.id} className="group border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-6 py-3 font-medium">{user.username}</td>
<td className="px-6 py-3 font-medium">
<div className="flex items-center gap-2">
<span>{user.username}</span>
{user.is_default_admin && <Badge variant="secondary">default admin</Badge>}
</div>
</td>
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>
@ -1054,6 +1074,18 @@ function UsersTable({ users, onRefresh, currentUser }) {
</select>
</div>
</td>
<td className="px-6 py-3">
<select
value={user.active === false || user.active === 0 ? 'inactive' : 'active'}
disabled={isSelf || activeUpdating === user.id}
onChange={e => handleActiveChange(user, e.target.value === 'active')}
className="h-8 rounded-md border border-input bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
title={isSelf ? 'You cannot deactivate your own account' : 'Change user status'}
>
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
</td>
<td className="px-6 py-3">
{user.must_change_password
? <Badge variant="due_soon">Temporary</Badge>
@ -1061,40 +1093,38 @@ function UsersTable({ users, onRefresh, currentUser }) {
}
</td>
<td className="px-6 py-3">
{user.role !== 'admin' && (
form.open ? (
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="New password"
value={form.pw || ''}
onChange={e => setReset(user.id, { pw: e.target.value })}
className="h-8 text-sm w-36"
/>
<Button
size="sm"
onClick={() => handleReset(user)}
disabled={resetting === user.id}
>
{resetting === user.id ? '…' : 'Save'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setReset(user.id, { open: false, pw: '' })}
>
Cancel
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setReset(user.id, { open: true })}>
Reset
{form.open ? (
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="New password"
value={form.pw || ''}
onChange={e => setReset(user.id, { pw: e.target.value })}
className="h-8 text-sm w-36"
/>
<Button
size="sm"
onClick={() => handleReset(user)}
disabled={resetting === user.id}
>
{resetting === user.id ? '…' : 'Save'}
</Button>
)
<Button
size="sm"
variant="ghost"
onClick={() => setReset(user.id, { open: false, pw: '' })}
>
Cancel
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setReset(user.id, { open: true })}>
Reset
</Button>
)}
</td>
<td className="px-6 py-3 text-right">
{user.role !== 'admin' && (
{!isSelf && (
<Button
size="sm"
variant="destructive"
@ -1110,7 +1140,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
})}
{!users?.length && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
</tr>
)}
</tbody>
@ -1125,7 +1155,9 @@ function UsersTable({ users, onRefresh, currentUser }) {
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
<AlertDialogDescription>
This user will be permanently removed. All their sessions will be invalidated.
This is permanent in 2026. The user account and all user-owned data will be deleted, including bills,
payments, categories, monthly state, monthly starting amounts, imports, import history, and sessions.
This cannot be undone from BillTracker.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@ -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

View File

@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { Navigate } from 'react-router-dom';
import { toast } from 'sonner';
import {
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
@ -1411,5 +1410,43 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
// DataPage
export default function DataPage() {
return <Navigate to="/profile" replace />;
const [history, setHistory] = useState(null);
const [historyLoading, setHistoryLoading] = useState(true);
const loadHistory = async () => {
setHistoryLoading(true);
try {
const { history } = await api.importHistory();
setHistory(history);
} catch {
setHistory([]);
} finally {
setHistoryLoading(false);
}
};
useEffect(() => { loadHistory(); }, []);
return (
<div className="mx-auto w-full max-w-6xl space-y-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Data</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Import, export, and review your user-owned bill tracker records.
</p>
</div>
<div className="rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground">
User data only
</div>
</div>
<div className="grid gap-5 xl:grid-cols-2 xl:items-start">
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
<ImportMyDataSection onHistoryRefresh={loadHistory} />
</div>
<DownloadMyDataSection />
<ImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} />
</div>
);
}

View File

@ -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';
@ -33,7 +33,7 @@ export default function LoginPage() {
const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
const destFor = (role) => (role === 'admin' ? '/admin' : '/');
const destFor = (user) => (user?.is_default_admin ? '/admin' : '/');
useEffect(() => {
api.authMode()
@ -45,7 +45,7 @@ export default function LoginPage() {
api.me()
.then(d => {
if (d.user) navigate(destFor(d.user.role), { replace: true });
if (d.user) navigate(destFor(d.user), { replace: true });
})
.catch(() => {});
}, []); // eslint-disable-line
@ -59,7 +59,7 @@ export default function LoginPage() {
setPendingUser(user);
setShowPrivacy(true);
} else {
navigate(destFor(user.role), { replace: true });
navigate(destFor(user), { replace: true });
}
};
@ -104,7 +104,7 @@ export default function LoginPage() {
if (pendingUser?.first_login) {
setShowPrivacy(true);
} else {
navigate(destFor(pendingUser.role), { replace: true });
navigate(destFor(pendingUser), { replace: true });
}
} catch (err) {
toast.error(err.message || 'Failed to change password.');
@ -120,7 +120,7 @@ export default function LoginPage() {
refresh();
setShowPrivacy(false);
navigate(destFor(pendingUser?.role), { replace: true });
navigate(destFor(pendingUser), { replace: true });
};
return (
@ -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>

View File

@ -8,12 +8,6 @@ import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
ImportSpreadsheetSection,
DownloadMyDataSection,
ImportMyDataSection,
ImportHistorySection as DataImportHistorySection,
} from '@/pages/DataPage';
function asProfile(data) {
return data?.profile || data?.user || data || {};
@ -34,9 +28,9 @@ function formatDateTime(value) {
function SectionCard({ title, icon: Icon, subtitle, children }) {
return (
<section className="table-surface">
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<div className="h-9 w-9 rounded-lg border border-primary/15 bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
@ -78,7 +72,7 @@ function ProfileSummary({ profile, loading }) {
return (
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<FieldRow label="Username" value={profile.username} />
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
<FieldRow label="Role" value={profile.role} />
@ -133,7 +127,7 @@ function NotificationPreferences({ settings, onSaved }) {
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
const payload = {
email: form.email || '',
email: form.email || form.notification_email || '',
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
notify_3_day: !!(form.notify_3_day ?? form.notify_3d),
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
@ -223,7 +217,7 @@ function ChangePassword() {
return (
<SectionCard title="Change Password" icon={KeyRound} subtitle="Update your password without exposing it in logs or page state beyond this form.">
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-[1fr_1fr_1fr_auto] lg:items-end">
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-3">
<div className="space-y-1.5">
<label htmlFor="current-password" className="text-xs font-medium text-muted-foreground">Current password</label>
<Input id="current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} />
@ -236,7 +230,7 @@ function ChangePassword() {
<label htmlFor="confirm-password" className="text-xs font-medium text-muted-foreground">Confirm new password</label>
<Input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
</div>
<Button type="submit" disabled={saving}>
<Button type="submit" disabled={saving} className="lg:col-start-3">
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Change Password'}
</Button>
</form>
@ -249,7 +243,6 @@ function ProfileNav() {
['#account', 'Account'],
['#security', 'Security'],
['#notifications', 'Notifications'],
['#data', 'My Data'],
];
return (
<div className="mb-6 flex flex-wrap gap-2">
@ -266,42 +259,6 @@ function ProfileNav() {
);
}
function DataManagement() {
const [history, setHistory] = useState(null);
const [historyLoading, setHistoryLoading] = useState(true);
const loadHistory = async () => {
setHistoryLoading(true);
try {
const { history } = await api.importHistory();
setHistory(history);
} catch {
setHistory([]);
} finally {
setHistoryLoading(false);
}
};
useEffect(() => { loadHistory(); }, []);
return (
<div id="data" className="scroll-mt-6 space-y-6">
<div>
<h2 className="text-sm font-semibold tracking-tight">My Data</h2>
<p className="text-sm text-muted-foreground mt-0.5">
Import spreadsheet history, import your SQLite data export, and export your user-owned records.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
<ImportMyDataSection onHistoryRefresh={loadHistory} />
</div>
<DownloadMyDataSection />
<DataImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} />
</div>
);
}
export default function ProfilePage() {
const { setUser, refresh } = useAuth();
const [profile, setProfile] = useState({});
@ -332,11 +289,11 @@ export default function ProfilePage() {
};
return (
<div>
<div className="mb-8 flex items-start justify-between gap-4">
<div className="mx-auto w-full max-w-5xl">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notifications, password, exports, and import history.</p>
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notification preferences, and password.</p>
</div>
<div className="hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
@ -346,7 +303,7 @@ export default function ProfilePage() {
<ProfileNav />
<div className="space-y-6">
<div className="space-y-5">
<div id="account" className="scroll-mt-6 space-y-6">
<ProfileSummary profile={profile} loading={loading} />
{!loading && <EditProfile profile={profile} onSaved={handleProfileSaved} />}
@ -357,7 +314,6 @@ export default function ProfilePage() {
<div id="notifications" className="scroll-mt-6">
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
</div>
<DataManagement />
</div>
</div>
);

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -110,6 +110,8 @@ function runMigrations() {
// ── users: notification columns ───────────────────────────────────────────
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const newUserCols = [
['active', 'INTEGER NOT NULL DEFAULT 1'],
['is_default_admin', 'INTEGER NOT NULL DEFAULT 0'],
['notification_email', 'TEXT'],
['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'],
['notify_3d', 'INTEGER NOT NULL DEFAULT 1'],
@ -120,6 +122,25 @@ function runMigrations() {
for (const [col, def] of newUserCols) {
if (!userCols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
}
const defaultAdminName = process.env.INIT_ADMIN_USER || 'admin';
db.prepare(`
UPDATE users
SET is_default_admin = 1
WHERE role = 'admin'
AND username = ?
AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1)
`).run(defaultAdminName);
db.exec(`
UPDATE users
SET is_default_admin = 1
WHERE id = (
SELECT id FROM users
WHERE role = 'admin'
ORDER BY id
LIMIT 1
)
AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1)
`);
// ── payments: soft-delete column (v0.2) ──────────────────────────────────
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
@ -168,7 +189,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,

View File

@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
active INTEGER NOT NULL DEFAULT 1,
is_default_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),

View File

@ -6,7 +6,7 @@ function getSingleModeUser() {
const userId = getSetting('default_user_id');
if (!userId) return null;
const row = getDb().prepare(
"SELECT id, username, display_name, role, must_change_password, first_login FROM users WHERE id = ? AND role = 'user'"
"SELECT id, username, display_name, role, must_change_password, first_login, active, is_default_admin FROM users WHERE id = ? AND role = 'user' AND active = 1"
).get(userId);
return row ? publicUser(row) : null;
}
@ -27,6 +27,9 @@ function requireAuth(req, res, next) {
}
function requireUser(req, res, next) {
if (req.user?.is_default_admin) {
return res.status(403).json({ error: 'Default admin account does not have tracker access' });
}
if (!['user', 'admin'].includes(req.user?.role)) {
return res.status(403).json({ error: 'Access denied: user account required' });
}

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "bill-tracker",
"version": "0.18.1",
"version": "0.18.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bill-tracker",
"version": "0.18.1",
"version": "0.18.4",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2",

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.18.1",
"version": "0.18.4",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

24
routes/about.js Normal file
View File

@ -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;

View File

@ -30,7 +30,7 @@ function sendError(res, err) {
// GET /api/admin/has-users
router.get('/has-users', (req, res) => {
const count = getDb().prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'").get().n;
const count = getDb().prepare('SELECT COUNT(*) AS n FROM users WHERE id != ?').get(req.user.id).n;
res.json({ has_users: count > 0 });
});
@ -38,7 +38,7 @@ router.get('/has-users', (req, res) => {
router.get('/users', (req, res) => {
res.json(
getDb().prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC'
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users ORDER BY is_default_admin DESC, role DESC, username ASC'
).all()
);
});
@ -156,7 +156,7 @@ router.post('/users', async (req, res) => {
).run(username, hash);
res.status(201).json(
db.prepare('SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?')
db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?')
.get(result.lastInsertRowid)
);
});
@ -170,7 +170,6 @@ router.put('/users/:id/password', async (req, res) => {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.role === 'admin') return res.status(403).json({ error: 'Cannot reset admin password this way' });
const hash = await hashPassword(password);
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
@ -208,20 +207,46 @@ router.put('/users/:id/role', (req, res) => {
.run(role, targetId);
const updated = db.prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
).get(targetId);
res.json(updated);
});
// PUT /api/admin/users/:id/active
router.put('/users/:id/active', (req, res) => {
const active = req.body?.active ? 1 : 0;
const targetId = Number(req.params.id);
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' });
if (req.user?.id === targetId) {
return res.status(400).json({ error: 'You cannot deactivate your own account.' });
}
db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run(active, targetId);
if (!active) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
res.json(db.prepare(
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
).get(targetId));
});
// DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.role === 'admin') return res.status(403).json({ error: 'Cannot delete the admin account' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.json({ success: true });
if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' });
const deleteUser = db.transaction(() => {
db.prepare('DELETE FROM import_sessions WHERE user_id = ?').run(user.id);
db.prepare('DELETE FROM import_history WHERE user_id = ?').run(user.id);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
});
deleteUser();
res.json({ success: true, deleted_user_id: user.id });
});
// ── Cleanup endpoints ─────────────────────────────────────────────────────────

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -18,7 +18,7 @@ const { passwordLimiter } = require('../middleware/rateLimiter');
router.get('/', (req, res) => {
const db = getDb();
const user = db.prepare(`
SELECT id, username, display_name, role,
SELECT id, username, display_name, role, active, is_default_admin,
first_login, created_at, updated_at,
last_password_change_at,
notification_email, notifications_enabled,
@ -33,6 +33,8 @@ router.get('/', (req, res) => {
username: user.username,
display_name: user.display_name || null,
role: user.role,
active: !!user.active,
is_default_admin: !!user.is_default_admin,
created_at: user.created_at,
updated_at: user.updated_at,
last_password_change_at: user.last_password_change_at || null,
@ -73,7 +75,14 @@ router.patch('/', (req, res) => {
).run(trimmed || null, req.user.id);
}
res.json({ success: true });
const updated = getDb().prepare(`
SELECT id, username, display_name, role, active, is_default_admin,
first_login, created_at, updated_at,
last_password_change_at
FROM users WHERE id = ?
`).get(req.user.id);
res.json({ success: true, profile: updated });
});
// ── GET /api/profile/settings ─────────────────────────────────────────────────
@ -105,15 +114,17 @@ router.get('/settings', (req, res) => {
router.patch('/settings', (req, res) => {
const db = getDb();
const {
notification_email, notifications_enabled,
notification_email, email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue,
} = req.body;
if (notification_email !== undefined && notification_email !== null) {
if (typeof notification_email !== 'string') {
const nextEmail = notification_email !== undefined ? notification_email : email;
if (nextEmail !== undefined && nextEmail !== null) {
if (typeof nextEmail !== 'string') {
return res.status(400).json({ error: 'notification_email must be a string' });
}
if (notification_email.trim().length > 255) {
if (nextEmail.trim().length > 255) {
return res.status(400).json({ error: 'notification_email is too long' });
}
}
@ -124,8 +135,8 @@ router.patch('/settings', (req, res) => {
FROM users WHERE id = ?
`).get(req.user.id);
const emailVal = notification_email !== undefined
? (notification_email ? notification_email.trim() || null : null)
const emailVal = nextEmail !== undefined
? (nextEmail ? nextEmail.trim() || null : null)
: current.notification_email;
const boolVal = (incoming, fallback) =>

View File

@ -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(),
};

View File

@ -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,

View File

@ -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

View File

@ -46,6 +46,7 @@ async function login(username, password) {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) return null;
if (user.active === 0) return null;
// Reject OIDC-only accounts from local login
if (user.auth_provider && user.auth_provider !== 'local') {
@ -79,6 +80,7 @@ async function createSession(userId) {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) return null;
if (user.active === 0) return null;
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000)
@ -98,10 +100,11 @@ function logout(sessionId) {
function getSessionUser(sessionId) {
if (!sessionId) return null;
const row = getDb().prepare(`
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login,
u.active, u.is_default_admin
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > datetime('now')
WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1
`).get(sessionId);
return row || null;
}
@ -116,6 +119,8 @@ function publicUser(u) {
username: u.username,
display_name: u.display_name || null,
role: u.role,
active: u.active !== 0,
is_default_admin: !!u.is_default_admin,
must_change_password: !!u.must_change_password,
first_login: !!u.first_login,
};

View File

@ -186,7 +186,7 @@ async function runNotifications() {
if (allowUserConfig) {
const users = db.prepare(
"SELECT * FROM users WHERE role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''"
"SELECT * FROM users WHERE active = 1 AND role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''"
).all();
recipients.push(...users);
} else if (globalRecipient) {

View File

@ -475,6 +475,10 @@ async function findOrProvisionUser(claims, config) {
console.log(`[oidc] Provisioned new ${role} user from OIDC login`);
}
if (user.active === 0) {
throw Object.assign(new Error('This account is inactive.'), { status: 403 });
}
// Refresh login timestamp and display name from provider claims
db.prepare(`
UPDATE users

View 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,

View File

@ -54,9 +54,9 @@ function promptPassword(label) {
async function createUser(db, username, password, role) {
const hash = await bcrypt.hash(password, 12);
db.prepare(`
INSERT INTO users (username, password_hash, role, first_login, must_change_password)
VALUES (?, ?, ?, 0, 0)
`).run(username, hash, role);
INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin)
VALUES (?, ?, ?, 0, 0, ?)
`).run(username, hash, role, role === 'admin' ? 1 : 0);
}
async function runFromEnv(db) {