Compare commits

...

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

34 changed files with 253 additions and 1266 deletions

View File

@ -1,35 +1,5 @@
# 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,7 +2,6 @@
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';
@ -15,7 +14,6 @@ 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';
@ -42,10 +40,6 @@ 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 />;
@ -54,17 +48,6 @@ 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();
@ -75,8 +58,6 @@ export default function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/release-notes" element={<ReleaseNotesPage />} />
<Route
path="/admin"
@ -86,24 +67,6 @@ 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={
@ -121,6 +84,8 @@ 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,7 +41,6 @@ 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),
@ -115,8 +114,6 @@ 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'),
@ -165,7 +162,6 @@ 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 { Link, Outlet } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import AppNavigation from './Sidebar';
export default function Layout() {
@ -11,10 +11,6 @@ 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, useLocation, useNavigate } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import {
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Menu, Receipt,
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, LayoutGrid, LogOut, Menu, Receipt,
Settings, ShieldCheck, Tag, User, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@ -18,20 +18,18 @@ 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 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' },
{ to: '/admin', icon: ShieldCheck, label: 'Admin', end: true },
];
function BrandBlock({ adminMode = false }) {
@ -76,50 +74,10 @@ 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 {}
@ -143,40 +101,16 @@ function UserMenu({ adminMode = false }) {
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
<DropdownMenuSeparator />
{user?.role === 'admin' && !adminMode && (
<>
<DropdownMenuItem onSelect={() => navigate('/admin')}>
<ShieldCheck className="h-4 w-4" />
Admin Panel
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate('/admin/status')}>
<Activity className="h-4 w-4" />
System Status
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{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 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>
)}
<DropdownMenuSeparator />
<DropdownMenuItem destructive onSelect={handleLogout}>
<LogOut className="h-4 w-4" />
@ -190,7 +124,9 @@ function UserMenu({ adminMode = false }) {
export default function Sidebar({ adminMode = false }) {
const [mobileOpen, setMobileOpen] = useState(false);
const { user } = useAuth();
const items = adminMode ? adminNavItems : userNavItems;
const items = user?.role === 'admin'
? [...userNavItems, ...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">
@ -198,7 +134,6 @@ 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} />
))}
@ -224,9 +159,6 @@ 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.4';
export const APP_VERSION = '0.18.1';
export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
version: '0.18.4',
version: '0.18.1',
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

@ -1,86 +0,0 @@
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,7 +961,6 @@ 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
@ -1013,19 +1012,6 @@ 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>
@ -1039,7 +1025,6 @@ 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" />
@ -1051,12 +1036,7 @@ 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">
<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 font-medium">{user.username}</td>
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>
@ -1074,18 +1054,6 @@ 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>
@ -1093,38 +1061,40 @@ function UsersTable({ users, onRefresh, currentUser }) {
}
</td>
<td className="px-6 py-3">
{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'}
{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
</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">
{!isSelf && (
{user.role !== 'admin' && (
<Button
size="sm"
variant="destructive"
@ -1140,7 +1110,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
})}
{!users?.length && (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
<td colSpan={5} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
</tr>
)}
</tbody>
@ -1155,9 +1125,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
<AlertDialogDescription>
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.
This user will be permanently removed. All their sessions will be invalidated.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@ -157,32 +157,30 @@ function ExpandedBills({ category }) {
}
return (
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-5">
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-6">
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
<table className="w-full text-sm">
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-semibold">Bill</th>
<th className="px-4 py-3 text-left font-semibold">State</th>
<th className="px-4 py-3 text-left font-semibold">Status</th>
<th className="px-4 py-3 text-right font-semibold">Expected</th>
<th className="px-4 py-3 text-right font-semibold">Due</th>
<th className="px-4 py-3 text-right font-semibold">Paid</th>
<th className="px-4 py-3 text-right font-semibold">History</th>
<th className="px-4 py-3 text-right font-semibold">Payments</th>
<th className="px-4 py-3 text-right font-semibold">Last Paid</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{bills.map(bill => (
<tr key={bill.id} className="hover:bg-muted/25">
<td className="px-4 py-3">
<BillName bill={bill} />
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
</td>
<td className="px-4 py-3"><BillName bill={bill} /></td>
<td className="px-4 py-3"><StatusPill active={bill.active} /></td>
<td className="px-4 py-3 text-right font-mono">{fmt(bill.expected_amount)}</td>
<td className="px-4 py-3 text-right tabular-nums">{bill.due_day}</td>
<td className="px-4 py-3 text-right font-mono">{fmt(bill.total_paid)}</td>
<td className="px-4 py-3 text-right">
<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>
<td className="px-4 py-3 text-right tabular-nums">{bill.payment_count || 0}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtDate(bill.last_paid_date)}</td>
</tr>
))}
</tbody>
@ -331,60 +329,41 @@ export default function CategoriesPage() {
}
}
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);
const totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0);
return (
<TooltipProvider delayDuration={180}>
<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>
<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>
<ChipLegend />
</div>
<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 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>
<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 ? (
@ -406,7 +385,7 @@ export default function CategoriesPage() {
onClick={() => toggleCategory(cat.id)}
onKeyDown={event => onRowKeyDown(event, cat.id)}
className={cn(
'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',
'group flex cursor-pointer flex-col gap-4 px-4 py-4 transition-colors sm:px-6 lg:flex-row lg:items-center lg:justify-between',
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
isExpanded && 'bg-muted/25',
)}
@ -433,11 +412,10 @@ 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 opacity-80 transition-opacity group-hover:opacity-100">
<div className="flex items-center justify-end gap-1 self-end opacity-80 transition-opacity group-hover:opacity-100 lg:self-auto">
<Button
type="button"
variant="ghost"
@ -469,8 +447,11 @@ export default function CategoriesPage() {
)}
</div>
<div className="text-xs text-muted-foreground">
Category totals include active and inactive bills in your account only.
<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>
<InputDialog

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { Navigate } from 'react-router-dom';
import { toast } from 'sonner';
import {
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
@ -1410,43 +1411,5 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
// DataPage
export default function DataPage() {
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>
);
return <Navigate to="/profile" replace />;
}

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { 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 = (user) => (user?.is_default_admin ? '/admin' : '/');
const destFor = (role) => (role === 'admin' ? '/admin' : '/');
useEffect(() => {
api.authMode()
@ -45,7 +45,7 @@ export default function LoginPage() {
api.me()
.then(d => {
if (d.user) navigate(destFor(d.user), { replace: true });
if (d.user) navigate(destFor(d.user.role), { replace: true });
})
.catch(() => {});
}, []); // eslint-disable-line
@ -59,7 +59,7 @@ export default function LoginPage() {
setPendingUser(user);
setShowPrivacy(true);
} else {
navigate(destFor(user), { replace: true });
navigate(destFor(user.role), { replace: true });
}
};
@ -104,7 +104,7 @@ export default function LoginPage() {
if (pendingUser?.first_login) {
setShowPrivacy(true);
} else {
navigate(destFor(pendingUser), { replace: true });
navigate(destFor(pendingUser.role), { 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), { replace: true });
navigate(destFor(pendingUser?.role), { replace: true });
};
return (
@ -224,14 +224,6 @@ 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,6 +8,12 @@ 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 || {};
@ -28,9 +34,9 @@ function formatDateTime(value) {
function SectionCard({ title, icon: Icon, subtitle, children }) {
return (
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
<section className="table-surface">
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
<div className="h-9 w-9 rounded-lg border border-primary/15 bg-primary/10 flex items-center justify-center shrink-0">
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
@ -72,7 +78,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 lg:grid-cols-3">
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<FieldRow label="Username" value={profile.username} />
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
<FieldRow label="Role" value={profile.role} />
@ -127,7 +133,7 @@ function NotificationPreferences({ settings, onSaved }) {
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
const payload = {
email: form.email || form.notification_email || '',
email: form.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),
@ -217,7 +223,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-3">
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-[1fr_1fr_1fr_auto] lg:items-end">
<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)} />
@ -230,7 +236,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} className="lg:col-start-3">
<Button type="submit" disabled={saving}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Change Password'}
</Button>
</form>
@ -243,6 +249,7 @@ function ProfileNav() {
['#account', 'Account'],
['#security', 'Security'],
['#notifications', 'Notifications'],
['#data', 'My Data'],
];
return (
<div className="mb-6 flex flex-wrap gap-2">
@ -259,6 +266,42 @@ 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({});
@ -289,11 +332,11 @@ export default function ProfilePage() {
};
return (
<div className="mx-auto w-full max-w-5xl">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<div className="mb-8 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, notification preferences, and password.</p>
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notifications, password, exports, and import history.</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" />
@ -303,7 +346,7 @@ export default function ProfilePage() {
<ProfileNav />
<div className="space-y-5">
<div className="space-y-6">
<div id="account" className="scroll-mt-6 space-y-6">
<ProfileSummary profile={profile} loading={loading} />
{!loading && <EditProfile profile={profile} onSaved={handleProfileSaved} />}
@ -314,6 +357,7 @@ export default function ProfilePage() {
<div id="notifications" className="scroll-mt-6">
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
</div>
<DataManagement />
</div>
</div>
);

View File

@ -83,14 +83,13 @@ export default function ReleaseNotesPage() {
const history = data?.history || '';
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-4xl space-y-5">
<div className="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="/about">
<Link to="/status">
<ArrowLeft className="h-3.5 w-3.5" />
About
Status
</Link>
</Button>
<h1 className="text-2xl font-bold tracking-tight">Release Notes</h1>
@ -130,7 +129,6 @@ 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 === 'Remaining'
? Number(row.amount) >= 0 ? 'Remaining' : 'Shortfall'
label: row.type === 'Savings'
? Number(row.amount) >= 0 ? 'Savings' : '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 === 'Remaining' ? moneyClass(row.amount) : 'text-foreground')}>
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Savings' ? moneyClass(row.amount) : 'text-foreground')}>
{fmt(row.amount)}
</div>
</div>
@ -140,10 +140,9 @@ export default function SummaryPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [startingFirst, setStartingFirst] = useState('0');
const [startingFifteenth, setStartingFifteenth] = useState('0');
const [startingOther, setStartingOther] = useState('0');
const [editingStarting, setEditingStarting] = useState(false);
const [incomeLabel, setIncomeLabel] = useState('Salary');
const [incomeAmount, setIncomeAmount] = useState('0');
const [editingIncome, setEditingIncome] = useState(false);
const loadSummary = useCallback(async () => {
setLoading(true);
@ -151,10 +150,9 @@ export default function SummaryPage() {
try {
const result = await api.summary(selected.year, selected.month);
setData(result);
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);
setIncomeLabel(result.income?.label || 'Salary');
setIncomeAmount(String(result.income?.amount ?? 0));
setEditingIncome(false);
} catch (err) {
setError(err.message || 'Summary could not be loaded.');
toast.error(err.message || 'Summary could not be loaded.');
@ -169,35 +167,31 @@ 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 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.');
async function saveIncome() {
const amount = Number(incomeAmount);
if (!Number.isFinite(amount) || amount < 0) {
toast.error('Enter a valid income amount.');
return;
}
setSaving(true);
try {
await api.updateMonthlyStartingAmounts({
await api.saveSummaryIncome({
year: selected.year,
month: selected.month,
first_amount: first,
fifteenth_amount: fifteenth,
other_amount: other,
label: incomeLabel.trim() || 'Salary',
amount,
});
toast.success('Starting amounts saved.');
toast.success('Income saved.');
await loadSummary();
} catch (err) {
toast.error(err.message || 'Starting amounts could not be saved.');
toast.error(err.message || 'Income could not be saved.');
} finally {
setSaving(false);
}
@ -222,7 +216,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 starting balance, expenses, and monthly result.</p>
<p className="mt-1 text-sm text-muted-foreground">Plan income, expenses, and monthly result.</p>
</div>
<div className="summary-actions flex gap-2">
<Button variant="outline" onClick={resetToday} className="sm:w-auto">
@ -278,91 +272,46 @@ 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">Starting Balance</h2>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Income</h2>
<Button
type="button"
variant="ghost"
size="sm"
className="summary-edit-actions h-7 px-2"
onClick={() => setEditingStarting(value => !value)}
onClick={() => setEditingIncome(value => !value)}
>
<Edit3 className="h-3.5 w-3.5" />
{editingStarting ? 'Close' : 'Edit'}
{editingIncome ? 'Close' : 'Edit'}
</Button>
</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 className="summary-income-display flex items-center justify-between gap-4 rounded-2xl bg-muted/45 px-4 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{data.income?.label || 'Salary'}</div>
{Number(summary.income_total || 0) === 0 && (
<div className="mt-0.5 text-xs text-muted-foreground">Add income to calculate savings.</div>
)}
</div>
<div className="shrink-0 text-lg font-bold text-foreground">{fmt(summary.income_total)}</div>
</div>
{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">
{editingIncome && (
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[minmax(0,1fr)_10rem_auto] md:items-end">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">1st</span>
<span className="text-xs font-medium text-muted-foreground">Label</span>
<Input value={incomeLabel} onChange={event => setIncomeLabel(event.target.value)} placeholder="Salary" />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Amount</span>
<Input
type="number"
min="0"
step="0.01"
value={startingFirst}
onChange={event => setStartingFirst(event.target.value)}
value={incomeAmount}
onChange={event => setIncomeAmount(event.target.value)}
/>
</label>
<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">
<Button onClick={saveIncome} disabled={saving} className="summary-edit-actions w-full md:w-auto">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</Button>
@ -420,7 +369,7 @@ export default function SummaryPage() {
<CardHeader className="pb-3">
<CardTitle className="text-xl">Total amount per type</CardTitle>
<CardDescription>
Starting balance, planned expenses, and {Number(summary.result || 0) >= 0 ? 'remaining' : 'shortfall'} for {monthLabel(data.year, data.month)}.
Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}.
</CardDescription>
</CardHeader>
<CardContent>

View File

@ -60,8 +60,8 @@ const STATUS_META = {
// Summary cards
const CARD_DEFS = {
starting: {
label: 'Starting',
expected: {
label: 'Total Expected',
icon: TrendingUp,
bar: 'from-slate-400 to-slate-300',
glow: '',
@ -96,7 +96,7 @@ const CARD_DEFS = {
},
};
function SummaryCard({ type, value, onEdit, hint }) {
function SummaryCard({ type, value }) {
const def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0);
const Icon = def.icon;
@ -118,16 +118,6 @@ function SummaryCard({ type, value, onEdit, hint }) {
<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',
@ -135,7 +125,6 @@ function SummaryCard({ type, value, onEdit, hint }) {
)}>
{fmt(value)}
</p>
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
</div>
);
}
@ -408,186 +397,6 @@ 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));
@ -1174,8 +983,6 @@ 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 {
@ -1265,12 +1072,7 @@ export default function TrackerPage() {
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
<div className="grid grid-cols-2 gap-3 lg:flex">
<SummaryCard
type="starting"
value={summary.total_starting}
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
onEdit={() => setEditStartingOpen(true)}
/>
<SummaryCard type="expected" value={summary.total_expected} />
<SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard type="remaining" value={summary.remaining} />
<SummaryCard type="overdue" value={summary.overdue} />
@ -1303,15 +1105,6 @@ 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,8 +110,6 @@ 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'],
@ -122,25 +120,6 @@ 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);
@ -189,30 +168,7 @@ function runMigrations() {
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
// -- 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');
}
// ── import_sessions: temporary preview state (v0.38) ─────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS import_sessions (
id TEXT PRIMARY KEY,

View File

@ -54,8 +54,6 @@ 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, active, is_default_admin FROM users WHERE id = ? AND role = 'user' AND active = 1"
"SELECT id, username, display_name, role, must_change_password, first_login FROM users WHERE id = ? AND role = 'user'"
).get(userId);
return row ? publicUser(row) : null;
}
@ -27,9 +27,6 @@ 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.4",
"version": "0.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bill-tracker",
"version": "0.18.4",
"version": "0.18.1",
"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.4",
"version": "0.18.1",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -1,24 +0,0 @@
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 id != ?').get(req.user.id).n;
const count = getDb().prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'").get().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, active, is_default_admin, must_change_password, first_login, created_at FROM users ORDER BY is_default_admin DESC, role DESC, username ASC'
'SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY 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, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?')
db.prepare('SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?')
.get(result.lastInsertRowid)
);
});
@ -170,6 +170,7 @@ 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=?")
@ -207,46 +208,20 @@ router.put('/users/:id/role', (req, res) => {
.run(role, targetId);
const updated = db.prepare(
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
'SELECT id, username, role, 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 (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 });
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 });
});
// ── Cleanup endpoints ─────────────────────────────────────────────────────────

View File

@ -106,25 +106,8 @@ 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' });
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 });
db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ success: true });
});
module.exports = router;

View File

@ -109,12 +109,6 @@ 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 })),
@ -123,17 +117,16 @@ function getUserExportData(userId) {
const metadata = {
exported_at: new Date().toISOString(),
export_type: 'user_data',
includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Monthly starting amounts', 'Notes', 'Export metadata'],
includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', '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, monthly_starting_amounts: monthlyStartingAmounts, notes };
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
}
router.get('/user-excel', (req, res) => {
@ -144,7 +137,6 @@ 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');
@ -163,7 +155,6 @@ 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 (?, ?)');
@ -179,7 +170,6 @@ 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

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

View File

@ -25,85 +25,6 @@ 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
@ -188,57 +109,26 @@ 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 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,
};
}
}
const result = incomeTotal - expenseTotal;
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: starting_amounts.paid_total,
paid_total: paidTotal,
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
result,
},
chart: [
{ type: 'Starting', amount: planBaseTotal },
{ type: 'Income', amount: incomeTotal },
{ type: 'Expenses', amount: expenseTotal },
{ type: 'Remaining', amount: result },
{ type: 'Savings', amount: result },
],
generated_at: new Date().toISOString(),
};

View File

@ -51,32 +51,20 @@ 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: activeTotalExpected,
total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid),
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)),
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,11 +48,9 @@ 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, requireAdmin, require('./routes/status'));
app.use('/api/about', require('./routes/about')); // public
app.use('/api/status', requireAuth, require('./routes/status'));
app.use('/api/version', require('./routes/version')); // public
// Profile — password-change rate limit applied inside the route file

View File

@ -46,7 +46,6 @@ 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') {
@ -80,7 +79,6 @@ 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)
@ -100,11 +98,10 @@ 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,
u.active, u.is_default_admin
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1
WHERE s.id = ? AND s.expires_at > datetime('now')
`).get(sessionId);
return row || null;
}
@ -119,8 +116,6 @@ 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 active = 1 AND role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''"
"SELECT * FROM users WHERE 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,10 +475,6 @@ 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,23 +187,6 @@ 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));
@ -227,16 +210,12 @@ 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, monthly_starting_amounts: monthlyStartingAmounts, notes };
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
}
function existingLookups(db, userId) {
@ -292,13 +271,6 @@ 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: {
@ -325,12 +297,6 @@ 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,
@ -353,7 +319,6 @@ 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,
@ -362,7 +327,6 @@ 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,
};
@ -506,24 +470,6 @@ 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');
@ -545,7 +491,6 @@ 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;
@ -584,10 +529,6 @@ 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,
@ -600,7 +541,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.monthly_starting_amounts.length + (data.notes?.length || 0),
data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.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, is_default_admin)
VALUES (?, ?, ?, 0, 0, ?)
`).run(username, hash, role, role === 'admin' ? 1 : 0);
INSERT INTO users (username, password_hash, role, first_login, must_change_password)
VALUES (?, ?, ?, 0, 0)
`).run(username, hash, role);
}
async function runFromEnv(db) {