Compare commits
No commits in common. "3228332e8c7705d6ec13878b3de5036fa7df4ba4" and "43bd58910aab3c7864fc88506f9fc88e31f621a5" have entirely different histories.
3228332e8c
...
43bd58910a
30
HISTORY.md
30
HISTORY.md
|
|
@ -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 month’s combined starting amount.
|
||||
- The Tracker Starting card now has an edit control for setting 1st, 15th, and Other monthly amounts.
|
||||
- Summary now uses monthly starting balances as the planning base and shows a Starting Balance section.
|
||||
- Remaining balances deduct paid bills: due days 1-14 from the 1st bucket, due days 15-31 from the 15th bucket, and total paid from combined remaining.
|
||||
- Added monthly starting amounts to user SQLite and Excel exports, and to user SQLite imports.
|
||||
- Added a public About page with app version, stack, AI-assistance note, and Release Notes access.
|
||||
- Release Notes are now available without login.
|
||||
|
||||
### Notes
|
||||
- Starting balances are not bills and are not payments.
|
||||
- Remaining values can go negative when paid bills exceed starting cash; overages are not blocked.
|
||||
- Previous month remaining is exposed to Summary as informational context only when available.
|
||||
- Navigation now groups Overview, Summary, Bills, and Categories under Tracker, and groups Profile, Settings, and Data in the user menu.
|
||||
- System Status is admin-only and appears in the Admin Panel navigation.
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.18.4",
|
||||
"version": "0.18.1",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue