Compare commits
No commits in common. "43bd58910aab3c7864fc88506f9fc88e31f621a5" and "3228332e8c7705d6ec13878b3de5036fa7df4ba4" have entirely different histories.
43bd58910a
...
3228332e8c
30
HISTORY.md
30
HISTORY.md
|
|
@ -1,5 +1,35 @@
|
||||||
# Bill Tracker — Changelog
|
# 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
|
## v0.18.1
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import Layout from '@/components/layout/Layout';
|
import Layout from '@/components/layout/Layout';
|
||||||
|
import AppNavigation from '@/components/layout/Sidebar';
|
||||||
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
|
||||||
import LoginPage from '@/pages/LoginPage';
|
import LoginPage from '@/pages/LoginPage';
|
||||||
import AdminPage from '@/pages/AdminPage';
|
import AdminPage from '@/pages/AdminPage';
|
||||||
|
|
@ -14,6 +15,7 @@ import SettingsPage from '@/pages/SettingsPage';
|
||||||
import StatusPage from '@/pages/StatusPage';
|
import StatusPage from '@/pages/StatusPage';
|
||||||
import AnalyticsPage from '@/pages/AnalyticsPage';
|
import AnalyticsPage from '@/pages/AnalyticsPage';
|
||||||
import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
|
import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
|
||||||
|
import AboutPage from '@/pages/AboutPage';
|
||||||
import DataPage from '@/pages/DataPage';
|
import DataPage from '@/pages/DataPage';
|
||||||
import ProfilePage from '@/pages/ProfilePage';
|
import ProfilePage from '@/pages/ProfilePage';
|
||||||
|
|
||||||
|
|
@ -40,6 +42,10 @@ function RequireAuth({ children, role }) {
|
||||||
|
|
||||||
const roleAllowed = !role || user.role === role || (role === 'user' && user.role === 'admin');
|
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
|
// Role mismatch
|
||||||
if (!roleAllowed) {
|
if (!roleAllowed) {
|
||||||
return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />;
|
return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />;
|
||||||
|
|
@ -48,6 +54,17 @@ function RequireAuth({ children, role }) {
|
||||||
return children;
|
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() {
|
export default function App() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
|
@ -58,6 +75,8 @@ export default function App() {
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/about" element={<AboutPage />} />
|
||||||
|
<Route path="/release-notes" element={<ReleaseNotesPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
|
|
@ -67,6 +86,24 @@ export default function App() {
|
||||||
</RequireAuth>
|
</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
|
<Route
|
||||||
element={
|
element={
|
||||||
|
|
@ -84,8 +121,6 @@ export default function App() {
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="data" element={<DataPage />} />
|
<Route path="data" element={<DataPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="status" element={<StatusPage />} />
|
|
||||||
<Route path="release-notes" element={<ReleaseNotesPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export const api = {
|
||||||
createUser: (data) => post('/admin/users', data),
|
createUser: (data) => post('/admin/users', data),
|
||||||
resetPassword: (id, data) => put(`/admin/users/${id}/password`, data),
|
resetPassword: (id, data) => put(`/admin/users/${id}/password`, data),
|
||||||
updateUserRole: (id, data) => put(`/admin/users/${id}/role`, 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}`),
|
deleteUser: (id) => del(`/admin/users/${id}`),
|
||||||
authModeConfig: () => get('/admin/auth-mode'),
|
authModeConfig: () => get('/admin/auth-mode'),
|
||||||
setAuthMode: (data) => put('/admin/auth-mode', data),
|
setAuthMode: (data) => put('/admin/auth-mode', data),
|
||||||
|
|
@ -114,6 +115,8 @@ export const api = {
|
||||||
// Summary
|
// Summary
|
||||||
summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
|
summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
|
||||||
saveSummaryIncome: (data) => put('/summary/income', data),
|
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
|
||||||
bills: () => get('/bills'),
|
bills: () => get('/bills'),
|
||||||
|
|
@ -162,6 +165,7 @@ export const api = {
|
||||||
status: () => get('/status'),
|
status: () => get('/status'),
|
||||||
|
|
||||||
// Version (public)
|
// Version (public)
|
||||||
|
about: () => get('/about'),
|
||||||
version: () => get('/version'),
|
version: () => get('/version'),
|
||||||
releaseHistory: () => get('/version/history'),
|
releaseHistory: () => get('/version/history'),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Link, Outlet } from 'react-router-dom';
|
||||||
import AppNavigation from './Sidebar';
|
import AppNavigation from './Sidebar';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
|
@ -11,6 +11,10 @@ export default function Layout() {
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, LayoutGrid, LogOut, Menu, Receipt,
|
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Menu, Receipt,
|
||||||
Settings, ShieldCheck, Tag, User, X,
|
Settings, ShieldCheck, Tag, User, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -18,18 +18,20 @@ import {
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
const userNavItems = [
|
const userNavItems = [
|
||||||
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
|
|
||||||
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
|
{ 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: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
|
||||||
{ to: '/status', icon: Activity, label: 'Status' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ to: '/admin', icon: ShieldCheck, label: 'Admin', end: true },
|
{ to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
|
||||||
|
{ to: '/admin/status', icon: Activity, label: 'System Status' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const trackerItems = [
|
||||||
|
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
|
||||||
|
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||||
|
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||||
|
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function BrandBlock({ adminMode = false }) {
|
function BrandBlock({ adminMode = false }) {
|
||||||
|
|
@ -74,10 +76,50 @@ function NavPill({ item, onNavigate }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TrackerMenu({ onNavigate }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isTrackerActive = trackerItems.some(item => (
|
||||||
|
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||||
|
isTrackerActive
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground hover:shadow-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
Tracker
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 opacity-75" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
|
{trackerItems.map(item => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={item.to} onSelect={() => { navigate(item.to); onNavigate?.(); }}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UserMenu({ adminMode = false }) {
|
function UserMenu({ adminMode = false }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const name = user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile');
|
const name = user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile');
|
||||||
|
const accountToolsAllowed = !user?.is_default_admin;
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try { await logout(); } catch {}
|
try { await logout(); } catch {}
|
||||||
|
|
@ -101,16 +143,40 @@ function UserMenu({ adminMode = false }) {
|
||||||
<DropdownMenuContent align="end" className="w-52">
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
|
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={() => navigate('/profile')}>
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
Profile
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{user?.role === 'admin' && !adminMode && (
|
{user?.role === 'admin' && !adminMode && (
|
||||||
<DropdownMenuItem onSelect={() => navigate('/admin')}>
|
<>
|
||||||
<ShieldCheck className="h-4 w-4" />
|
<DropdownMenuItem onSelect={() => navigate('/admin')}>
|
||||||
Admin
|
<ShieldCheck className="h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
Admin Panel
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => navigate('/admin/status')}>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
System Status
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{accountToolsAllowed && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onSelect={() => navigate('/profile')}>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => navigate('/data')}>
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
Data
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onSelect={() => navigate('/about')}>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
About
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem destructive onSelect={handleLogout}>
|
<DropdownMenuItem destructive onSelect={handleLogout}>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
|
|
@ -124,9 +190,7 @@ function UserMenu({ adminMode = false }) {
|
||||||
export default function Sidebar({ adminMode = false }) {
|
export default function Sidebar({ adminMode = false }) {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const items = user?.role === 'admin'
|
const items = adminMode ? adminNavItems : userNavItems;
|
||||||
? [...userNavItems, ...adminNavItems]
|
|
||||||
: userNavItems;
|
|
||||||
|
|
||||||
return (
|
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">
|
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/85 shadow-sm shadow-foreground/5 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70">
|
||||||
|
|
@ -134,6 +198,7 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
<BrandBlock adminMode={adminMode} />
|
<BrandBlock adminMode={adminMode} />
|
||||||
|
|
||||||
<nav className="hidden items-center gap-1 lg:flex">
|
<nav className="hidden items-center gap-1 lg:flex">
|
||||||
|
{!adminMode && <TrackerMenu />}
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<NavPill key={item.to} item={item} />
|
<NavPill key={item.to} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -159,6 +224,9 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden">
|
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden">
|
||||||
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
||||||
|
{!adminMode && trackerItems.map(item => (
|
||||||
|
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
||||||
|
))}
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
export const APP_VERSION = '0.18.1';
|
export const APP_VERSION = '0.18.4';
|
||||||
export const APP_NAME = 'BillTracker';
|
export const APP_NAME = 'BillTracker';
|
||||||
|
|
||||||
export const RELEASE_NOTES = {
|
export const RELEASE_NOTES = {
|
||||||
version: '0.18.1',
|
version: '0.18.4',
|
||||||
date: '2026-05-04',
|
date: '2026-05-04',
|
||||||
highlights: [
|
highlights: [
|
||||||
{ icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' },
|
{ icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Info, Sparkles } from 'lucide-react';
|
||||||
|
import { api } from '@/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
const [about, setAbout] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setAbout(await api.about());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const stack = about?.stack || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
|
||||||
|
<main className="mx-auto w-full max-w-3xl space-y-5">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
|
<Link to="/login">
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
|
||||||
|
<Info className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">{about?.name || 'BillTracker'}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{loading ? 'Loading app information...' : about?.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Version</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-bold">v{about?.version || '...'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backend</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold">{stack.backend || 'Node.js / Express'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Storage</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold">{stack.database || 'SQLite'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border/70 bg-muted/35 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Produced with AI assistance</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
|
BillTracker is self-hosted software for personal bill planning and history. This product was produced with the assistance of AI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/release-notes">Release Notes</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to="/login">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -961,6 +961,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
const [deleting, setDeleting] = useState(null);
|
const [deleting, setDeleting] = useState(null);
|
||||||
const [resetting, setResetting] = useState(null);
|
const [resetting, setResetting] = useState(null);
|
||||||
const [roleUpdating, setRoleUpdating] = useState(null);
|
const [roleUpdating, setRoleUpdating] = useState(null);
|
||||||
|
const [activeUpdating, setActiveUpdating] = useState(null);
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null); // user object
|
const [deleteTarget, setDeleteTarget] = useState(null); // user object
|
||||||
|
|
@ -1012,6 +1013,19 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleActiveChange = async (user, active) => {
|
||||||
|
setActiveUpdating(user.id);
|
||||||
|
try {
|
||||||
|
await api.updateUserActive(user.id, { active });
|
||||||
|
toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to update user status.');
|
||||||
|
} finally {
|
||||||
|
setActiveUpdating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -1025,6 +1039,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
<tr className="border-b border-border">
|
<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">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">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">Password</th>
|
||||||
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Reset Password</th>
|
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Reset Password</th>
|
||||||
<th className="px-6 py-3" />
|
<th className="px-6 py-3" />
|
||||||
|
|
@ -1036,7 +1051,12 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
const isSelf = currentUser?.id === user.id;
|
const isSelf = currentUser?.id === user.id;
|
||||||
return (
|
return (
|
||||||
<tr key={user.id} className="group border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
|
<tr key={user.id} className="group border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
|
||||||
<td className="px-6 py-3 font-medium">{user.username}</td>
|
<td className="px-6 py-3 font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{user.username}</span>
|
||||||
|
{user.is_default_admin && <Badge variant="secondary">default admin</Badge>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>
|
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>
|
||||||
|
|
@ -1054,6 +1074,18 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td className="px-6 py-3">
|
||||||
{user.must_change_password
|
{user.must_change_password
|
||||||
? <Badge variant="due_soon">Temporary</Badge>
|
? <Badge variant="due_soon">Temporary</Badge>
|
||||||
|
|
@ -1061,40 +1093,38 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
{user.role !== 'admin' && (
|
{form.open ? (
|
||||||
form.open ? (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Input
|
||||||
<Input
|
type="password"
|
||||||
type="password"
|
placeholder="New password"
|
||||||
placeholder="New password"
|
value={form.pw || ''}
|
||||||
value={form.pw || ''}
|
onChange={e => setReset(user.id, { pw: e.target.value })}
|
||||||
onChange={e => setReset(user.id, { pw: e.target.value })}
|
className="h-8 text-sm w-36"
|
||||||
className="h-8 text-sm w-36"
|
/>
|
||||||
/>
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
onClick={() => handleReset(user)}
|
||||||
onClick={() => handleReset(user)}
|
disabled={resetting === user.id}
|
||||||
disabled={resetting === user.id}
|
>
|
||||||
>
|
{resetting === user.id ? '…' : 'Save'}
|
||||||
{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>
|
||||||
)
|
<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>
|
||||||
<td className="px-6 py-3 text-right">
|
<td className="px-6 py-3 text-right">
|
||||||
{user.role !== 'admin' && (
|
{!isSelf && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|
@ -1110,7 +1140,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
})}
|
})}
|
||||||
{!users?.length && (
|
{!users?.length && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
|
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -1125,7 +1155,9 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
|
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This user will be permanently removed. All their sessions will be invalidated.
|
This is permanent in 2026. The user account and all user-owned data will be deleted, including bills,
|
||||||
|
payments, categories, monthly state, monthly starting amounts, imports, import history, and sessions.
|
||||||
|
This cannot be undone from BillTracker.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -157,30 +157,32 @@ function ExpandedBills({ category }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-6">
|
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-5">
|
||||||
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
|
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
|
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-semibold">Bill</th>
|
<th className="px-4 py-3 text-left font-semibold">Bill</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold">Status</th>
|
<th className="px-4 py-3 text-left font-semibold">State</th>
|
||||||
<th className="px-4 py-3 text-right font-semibold">Expected</th>
|
<th className="px-4 py-3 text-right font-semibold">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">Paid</th>
|
||||||
<th className="px-4 py-3 text-right font-semibold">Payments</th>
|
<th className="px-4 py-3 text-right font-semibold">History</th>
|
||||||
<th className="px-4 py-3 text-right font-semibold">Last Paid</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border/50">
|
<tbody className="divide-y divide-border/50">
|
||||||
{bills.map(bill => (
|
{bills.map(bill => (
|
||||||
<tr key={bill.id} className="hover:bg-muted/25">
|
<tr key={bill.id} className="hover:bg-muted/25">
|
||||||
<td className="px-4 py-3"><BillName bill={bill} /></td>
|
<td className="px-4 py-3">
|
||||||
|
<BillName bill={bill} />
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3"><StatusPill active={bill.active} /></td>
|
<td className="px-4 py-3"><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 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 font-mono">{fmt(bill.total_paid)}</td>
|
||||||
<td className="px-4 py-3 text-right tabular-nums">{bill.payment_count || 0}</td>
|
<td className="px-4 py-3 text-right">
|
||||||
<td className="px-4 py-3 text-right tabular-nums">{fmtDate(bill.last_paid_date)}</td>
|
<p className="tabular-nums">{plural(bill.payment_count || 0, 'payment')}</p>
|
||||||
|
<p className="mt-1 text-xs tabular-nums text-muted-foreground">{fmtDate(bill.last_paid_date)}</p>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -329,41 +331,60 @@ export default function CategoriesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0);
|
const activeBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0), 0);
|
||||||
|
const inactiveBills = categories.reduce((sum, cat) => sum + (cat.inactive_bill_count || 0), 0);
|
||||||
|
const paymentCount = categories.reduce((sum, cat) => sum + (cat.payment_count || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={180}>
|
<TooltipProvider delayDuration={180}>
|
||||||
<div>
|
<div className="mx-auto w-full max-w-5xl space-y-5">
|
||||||
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
<div className="flex items-center gap-2">
|
||||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/70 bg-card shadow-sm">
|
||||||
<span>{plural(categories.length, 'category')}</span>
|
<ReceiptText className="h-4 w-4 text-primary" />
|
||||||
<span aria-hidden="true">/</span>
|
</div>
|
||||||
<span>{plural(totalBills, 'bill')}</span>
|
<div>
|
||||||
</p>
|
<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>
|
||||||
<ChipLegend />
|
<ChipLegend />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-surface overflow-hidden">
|
<div className="grid gap-3 md:grid-cols-[1fr_minmax(20rem,26rem)]">
|
||||||
<div className="border-b border-border/50 bg-card/65 px-4 py-4 sm:px-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-xl sm:flex-row">
|
{[
|
||||||
<Input
|
['Categories', categories.length],
|
||||||
ref={addInputRef}
|
['Active bills', activeBills],
|
||||||
value={newName}
|
['Inactive', inactiveBills],
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
['Payments', paymentCount],
|
||||||
placeholder="New category name..."
|
].map(([label, value]) => (
|
||||||
disabled={adding}
|
<div key={label} className="rounded-xl border border-border/70 bg-card/80 px-4 py-3 shadow-sm">
|
||||||
className="h-9 text-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>
|
||||||
<Button type="submit" size="sm" className="h-9 sm:w-auto" disabled={adding || !newName.trim()}>
|
</div>
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
))}
|
||||||
{adding ? 'Adding...' : 'Add'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} className="flex min-w-0 flex-col gap-2 rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm sm:flex-row md:flex-col lg:flex-row">
|
||||||
|
<Input
|
||||||
|
ref={addInputRef}
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="New category name..."
|
||||||
|
disabled={adding}
|
||||||
|
className="h-9 min-w-0 text-sm"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" className="h-9 shrink-0 sm:w-auto" disabled={adding || !newName.trim()}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{adding ? 'Adding...' : 'Add'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-surface overflow-hidden rounded-xl">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
|
||||||
) : categories.length === 0 ? (
|
) : categories.length === 0 ? (
|
||||||
|
|
@ -385,7 +406,7 @@ export default function CategoriesPage() {
|
||||||
onClick={() => toggleCategory(cat.id)}
|
onClick={() => toggleCategory(cat.id)}
|
||||||
onKeyDown={event => onRowKeyDown(event, cat.id)}
|
onKeyDown={event => onRowKeyDown(event, cat.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex cursor-pointer flex-col gap-4 px-4 py-4 transition-colors sm:px-6 lg:flex-row lg:items-center lg:justify-between',
|
'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
|
||||||
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||||
isExpanded && 'bg-muted/25',
|
isExpanded && 'bg-muted/25',
|
||||||
)}
|
)}
|
||||||
|
|
@ -412,10 +433,11 @@ export default function CategoriesPage() {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<StatChips category={cat} />
|
<StatChips category={cat} />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{preview}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 self-end opacity-80 transition-opacity group-hover:opacity-100 lg:self-auto">
|
<div className="flex items-center justify-end gap-1 opacity-80 transition-opacity group-hover:opacity-100">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -447,11 +469,8 @@ export default function CategoriesPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-xs text-muted-foreground">
|
<div className="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">
|
Category totals include active and inactive bills in your account only.
|
||||||
<ReceiptText className="h-3.5 w-3.5" />
|
|
||||||
<span>Category totals include active and inactive bills in your account only.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputDialog
|
<InputDialog
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
|
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
|
||||||
|
|
@ -1411,5 +1410,43 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
||||||
// ─── DataPage ─────────────────────────────────────────────────────────────────
|
// ─── DataPage ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function DataPage() {
|
export default function DataPage() {
|
||||||
return <Navigate to="/profile" replace />;
|
const [history, setHistory] = useState(null);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const { history } = await api.importHistory();
|
||||||
|
setHistory(history);
|
||||||
|
} catch {
|
||||||
|
setHistory([]);
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadHistory(); }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl space-y-5">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Data</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Import, export, and review your user-owned bill tracker records.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
User data only
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2 xl:items-start">
|
||||||
|
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
|
||||||
|
<ImportMyDataSection onHistoryRefresh={loadHistory} />
|
||||||
|
</div>
|
||||||
|
<DownloadMyDataSection />
|
||||||
|
<ImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
@ -33,7 +33,7 @@ export default function LoginPage() {
|
||||||
const [confirmPw, setConfirmPw] = useState('');
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
const [pwLoading, setPwLoading] = useState(false);
|
const [pwLoading, setPwLoading] = useState(false);
|
||||||
|
|
||||||
const destFor = (role) => (role === 'admin' ? '/admin' : '/');
|
const destFor = (user) => (user?.is_default_admin ? '/admin' : '/');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.authMode()
|
api.authMode()
|
||||||
|
|
@ -45,7 +45,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
api.me()
|
api.me()
|
||||||
.then(d => {
|
.then(d => {
|
||||||
if (d.user) navigate(destFor(d.user.role), { replace: true });
|
if (d.user) navigate(destFor(d.user), { replace: true });
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []); // eslint-disable-line
|
}, []); // eslint-disable-line
|
||||||
|
|
@ -59,7 +59,7 @@ export default function LoginPage() {
|
||||||
setPendingUser(user);
|
setPendingUser(user);
|
||||||
setShowPrivacy(true);
|
setShowPrivacy(true);
|
||||||
} else {
|
} else {
|
||||||
navigate(destFor(user.role), { replace: true });
|
navigate(destFor(user), { replace: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ export default function LoginPage() {
|
||||||
if (pendingUser?.first_login) {
|
if (pendingUser?.first_login) {
|
||||||
setShowPrivacy(true);
|
setShowPrivacy(true);
|
||||||
} else {
|
} else {
|
||||||
navigate(destFor(pendingUser.role), { replace: true });
|
navigate(destFor(pendingUser), { replace: true });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to change password.');
|
toast.error(err.message || 'Failed to change password.');
|
||||||
|
|
@ -120,7 +120,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
setShowPrivacy(false);
|
setShowPrivacy(false);
|
||||||
navigate(destFor(pendingUser?.role), { replace: true });
|
navigate(destFor(pendingUser), { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -224,6 +224,14 @@ export default function LoginPage() {
|
||||||
Build v{APP_VERSION}
|
Build v{APP_VERSION}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,6 @@ import { useAuth } from '@/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
|
||||||
ImportSpreadsheetSection,
|
|
||||||
DownloadMyDataSection,
|
|
||||||
ImportMyDataSection,
|
|
||||||
ImportHistorySection as DataImportHistorySection,
|
|
||||||
} from '@/pages/DataPage';
|
|
||||||
|
|
||||||
function asProfile(data) {
|
function asProfile(data) {
|
||||||
return data?.profile || data?.user || data || {};
|
return data?.profile || data?.user || data || {};
|
||||||
|
|
@ -34,9 +28,9 @@ function formatDateTime(value) {
|
||||||
|
|
||||||
function SectionCard({ title, icon: Icon, subtitle, children }) {
|
function SectionCard({ title, icon: Icon, subtitle, children }) {
|
||||||
return (
|
return (
|
||||||
<section className="table-surface">
|
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
|
||||||
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
||||||
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
<div className="h-9 w-9 rounded-lg border border-primary/15 bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
<Icon className="h-4 w-4 text-primary" />
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -78,7 +72,7 @@ function ProfileSummary({ profile, loading }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
|
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
|
||||||
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<FieldRow label="Username" value={profile.username} />
|
<FieldRow label="Username" value={profile.username} />
|
||||||
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
|
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
|
||||||
<FieldRow label="Role" value={profile.role} />
|
<FieldRow label="Role" value={profile.role} />
|
||||||
|
|
@ -133,7 +127,7 @@ function NotificationPreferences({ settings, onSaved }) {
|
||||||
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
|
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
email: form.email || '',
|
email: form.email || form.notification_email || '',
|
||||||
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
|
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
|
||||||
notify_3_day: !!(form.notify_3_day ?? form.notify_3d),
|
notify_3_day: !!(form.notify_3_day ?? form.notify_3d),
|
||||||
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
|
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
|
||||||
|
|
@ -223,7 +217,7 @@ function ChangePassword() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Change Password" icon={KeyRound} subtitle="Update your password without exposing it in logs or page state beyond this form.">
|
<SectionCard title="Change Password" icon={KeyRound} subtitle="Update your password without exposing it in logs or page state beyond this form.">
|
||||||
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-[1fr_1fr_1fr_auto] lg:items-end">
|
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="current-password" className="text-xs font-medium text-muted-foreground">Current password</label>
|
<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)} />
|
<Input id="current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} />
|
||||||
|
|
@ -236,7 +230,7 @@ function ChangePassword() {
|
||||||
<label htmlFor="confirm-password" className="text-xs font-medium text-muted-foreground">Confirm new password</label>
|
<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)} />
|
<Input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={saving}>
|
<Button type="submit" disabled={saving} className="lg:col-start-3">
|
||||||
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</> : 'Change Password'}
|
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</> : 'Change Password'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -249,7 +243,6 @@ function ProfileNav() {
|
||||||
['#account', 'Account'],
|
['#account', 'Account'],
|
||||||
['#security', 'Security'],
|
['#security', 'Security'],
|
||||||
['#notifications', 'Notifications'],
|
['#notifications', 'Notifications'],
|
||||||
['#data', 'My Data'],
|
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 flex flex-wrap gap-2">
|
<div className="mb-6 flex flex-wrap gap-2">
|
||||||
|
|
@ -266,42 +259,6 @@ function ProfileNav() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataManagement() {
|
|
||||||
const [history, setHistory] = useState(null);
|
|
||||||
const [historyLoading, setHistoryLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadHistory = async () => {
|
|
||||||
setHistoryLoading(true);
|
|
||||||
try {
|
|
||||||
const { history } = await api.importHistory();
|
|
||||||
setHistory(history);
|
|
||||||
} catch {
|
|
||||||
setHistory([]);
|
|
||||||
} finally {
|
|
||||||
setHistoryLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { loadHistory(); }, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="data" className="scroll-mt-6 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold tracking-tight">My Data</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
Import spreadsheet history, import your SQLite data export, and export your user-owned records.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
|
|
||||||
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
|
|
||||||
<ImportMyDataSection onHistoryRefresh={loadHistory} />
|
|
||||||
</div>
|
|
||||||
<DownloadMyDataSection />
|
|
||||||
<DataImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { setUser, refresh } = useAuth();
|
const { setUser, refresh } = useAuth();
|
||||||
const [profile, setProfile] = useState({});
|
const [profile, setProfile] = useState({});
|
||||||
|
|
@ -332,11 +289,11 @@ export default function ProfilePage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="mx-auto w-full max-w-5xl">
|
||||||
<div className="mb-8 flex items-start justify-between gap-4">
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notifications, password, exports, and import history.</p>
|
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notification preferences, and password.</p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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" />
|
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
|
@ -346,7 +303,7 @@ export default function ProfilePage() {
|
||||||
|
|
||||||
<ProfileNav />
|
<ProfileNav />
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div id="account" className="scroll-mt-6 space-y-6">
|
<div id="account" className="scroll-mt-6 space-y-6">
|
||||||
<ProfileSummary profile={profile} loading={loading} />
|
<ProfileSummary profile={profile} loading={loading} />
|
||||||
{!loading && <EditProfile profile={profile} onSaved={handleProfileSaved} />}
|
{!loading && <EditProfile profile={profile} onSaved={handleProfileSaved} />}
|
||||||
|
|
@ -357,7 +314,6 @@ export default function ProfilePage() {
|
||||||
<div id="notifications" className="scroll-mt-6">
|
<div id="notifications" className="scroll-mt-6">
|
||||||
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
|
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
|
||||||
</div>
|
</div>
|
||||||
<DataManagement />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -83,13 +83,14 @@ export default function ReleaseNotesPage() {
|
||||||
const history = data?.history || '';
|
const history = data?.history || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
|
||||||
|
<main className="mx-auto w-full max-w-4xl space-y-5">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Button asChild variant="ghost" size="sm" className="mb-2 -ml-2">
|
<Button asChild variant="ghost" size="sm" className="mb-2 -ml-2">
|
||||||
<Link to="/status">
|
<Link to="/about">
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
Status
|
About
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Release Notes</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Release Notes</h1>
|
||||||
|
|
@ -129,6 +130,7 @@ export default function ReleaseNotesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,8 @@ function SummaryChart({ rows = [] }) {
|
||||||
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
|
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
|
||||||
const chartRows = rows.map((row, index) => ({
|
const chartRows = rows.map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
label: row.type === 'Savings'
|
label: row.type === 'Remaining'
|
||||||
? Number(row.amount) >= 0 ? 'Savings' : 'Shortfall'
|
? Number(row.amount) >= 0 ? 'Remaining' : 'Shortfall'
|
||||||
: row.type,
|
: row.type,
|
||||||
color: index === 0
|
color: index === 0
|
||||||
? 'hsl(var(--chart-1))'
|
? 'hsl(var(--chart-1))'
|
||||||
|
|
@ -106,7 +106,7 @@ function SummaryChart({ rows = [] }) {
|
||||||
title={`${row.label}: ${fmt(row.amount)}`}
|
title={`${row.label}: ${fmt(row.amount)}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Savings' ? moneyClass(row.amount) : 'text-foreground')}>
|
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Remaining' ? moneyClass(row.amount) : 'text-foreground')}>
|
||||||
{fmt(row.amount)}
|
{fmt(row.amount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -140,9 +140,10 @@ export default function SummaryPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [incomeLabel, setIncomeLabel] = useState('Salary');
|
const [startingFirst, setStartingFirst] = useState('0');
|
||||||
const [incomeAmount, setIncomeAmount] = useState('0');
|
const [startingFifteenth, setStartingFifteenth] = useState('0');
|
||||||
const [editingIncome, setEditingIncome] = useState(false);
|
const [startingOther, setStartingOther] = useState('0');
|
||||||
|
const [editingStarting, setEditingStarting] = useState(false);
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const loadSummary = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -150,9 +151,10 @@ export default function SummaryPage() {
|
||||||
try {
|
try {
|
||||||
const result = await api.summary(selected.year, selected.month);
|
const result = await api.summary(selected.year, selected.month);
|
||||||
setData(result);
|
setData(result);
|
||||||
setIncomeLabel(result.income?.label || 'Salary');
|
setStartingFirst(String(result.starting_amounts?.first_amount ?? 0));
|
||||||
setIncomeAmount(String(result.income?.amount ?? 0));
|
setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
|
||||||
setEditingIncome(false);
|
setStartingOther(String(result.starting_amounts?.other_amount ?? 0));
|
||||||
|
setEditingStarting(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Summary could not be loaded.');
|
setError(err.message || 'Summary could not be loaded.');
|
||||||
toast.error(err.message || 'Summary could not be loaded.');
|
toast.error(err.message || 'Summary could not be loaded.');
|
||||||
|
|
@ -167,31 +169,35 @@ export default function SummaryPage() {
|
||||||
|
|
||||||
const summary = data?.summary || {};
|
const summary = data?.summary || {};
|
||||||
const expenses = data?.expenses || [];
|
const expenses = data?.expenses || [];
|
||||||
|
const starting = data?.starting_amounts || {};
|
||||||
|
|
||||||
const generatedLabel = useMemo(() => {
|
const generatedLabel = useMemo(() => {
|
||||||
if (!data?.generated_at) return '';
|
if (!data?.generated_at) return '';
|
||||||
return new Date(data.generated_at).toLocaleString();
|
return new Date(data.generated_at).toLocaleString();
|
||||||
}, [data?.generated_at]);
|
}, [data?.generated_at]);
|
||||||
|
|
||||||
async function saveIncome() {
|
async function saveStartingAmounts() {
|
||||||
const amount = Number(incomeAmount);
|
const first = Number(startingFirst);
|
||||||
if (!Number.isFinite(amount) || amount < 0) {
|
const fifteenth = Number(startingFifteenth);
|
||||||
toast.error('Enter a valid income amount.');
|
const other = Number(startingOther);
|
||||||
|
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
|
||||||
|
toast.error('Enter non-negative starting amounts.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.saveSummaryIncome({
|
await api.updateMonthlyStartingAmounts({
|
||||||
year: selected.year,
|
year: selected.year,
|
||||||
month: selected.month,
|
month: selected.month,
|
||||||
label: incomeLabel.trim() || 'Salary',
|
first_amount: first,
|
||||||
amount,
|
fifteenth_amount: fifteenth,
|
||||||
|
other_amount: other,
|
||||||
});
|
});
|
||||||
toast.success('Income saved.');
|
toast.success('Starting amounts saved.');
|
||||||
await loadSummary();
|
await loadSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Income could not be saved.');
|
toast.error(err.message || 'Starting amounts could not be saved.');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +222,7 @@ export default function SummaryPage() {
|
||||||
<div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
<div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Summary</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">Summary</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Plan income, expenses, and monthly result.</p>
|
<p className="mt-1 text-sm text-muted-foreground">Plan starting balance, expenses, and monthly result.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-actions flex gap-2">
|
<div className="summary-actions flex gap-2">
|
||||||
<Button variant="outline" onClick={resetToday} className="sm:w-auto">
|
<Button variant="outline" onClick={resetToday} className="sm:w-auto">
|
||||||
|
|
@ -272,46 +278,91 @@ export default function SummaryPage() {
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Income</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Starting Balance</h2>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="summary-edit-actions h-7 px-2"
|
className="summary-edit-actions h-7 px-2"
|
||||||
onClick={() => setEditingIncome(value => !value)}
|
onClick={() => setEditingStarting(value => !value)}
|
||||||
>
|
>
|
||||||
<Edit3 className="h-3.5 w-3.5" />
|
<Edit3 className="h-3.5 w-3.5" />
|
||||||
{editingIncome ? 'Close' : 'Edit'}
|
{editingStarting ? 'Close' : 'Edit'}
|
||||||
</Button>
|
</Button>
|
||||||
</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="grid gap-3 rounded-2xl bg-muted/45 p-4 sm:grid-cols-3">
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
<div className="truncate text-sm font-semibold text-foreground">{data.income?.label || 'Salary'}</div>
|
<div className="text-xs font-medium text-muted-foreground">1st</div>
|
||||||
{Number(summary.income_total || 0) === 0 && (
|
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.first_amount)}</div>
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">Add income to calculate savings.</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">15th</div>
|
||||||
|
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.fifteenth_amount)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Other</div>
|
||||||
|
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.other_amount)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/60 pt-3 sm:col-span-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Total starting</div>
|
||||||
|
<div className="mt-1 font-mono text-lg font-bold text-foreground">{fmt(starting.combined_amount)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Paid</div>
|
||||||
|
<div className="mt-1 font-mono text-lg font-bold text-emerald-600 dark:text-emerald-400">{fmt(starting.paid_total)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Total remaining</div>
|
||||||
|
<div className={cn('mt-1 font-mono text-lg font-bold', moneyClass(starting.combined_remaining || 0))}>
|
||||||
|
{fmt(starting.combined_remaining)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-lg font-bold text-foreground">{fmt(summary.income_total)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingIncome && (
|
{data.previous_month && (
|
||||||
<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">
|
<div className="rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
Previous month remaining: {fmt(data.previous_month.combined_remaining)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingStarting && (
|
||||||
|
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs font-medium text-muted-foreground">Label</span>
|
<span className="text-xs font-medium text-muted-foreground">1st</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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={incomeAmount}
|
value={startingFirst}
|
||||||
onChange={event => setIncomeAmount(event.target.value)}
|
onChange={event => setStartingFirst(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button onClick={saveIncome} disabled={saving} className="summary-edit-actions w-full md:w-auto">
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">15th</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={startingFifteenth}
|
||||||
|
onChange={event => setStartingFifteenth(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Other</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={startingOther}
|
||||||
|
onChange={event => setStartingOther(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button onClick={saveStartingAmounts} disabled={saving} className="summary-edit-actions w-full md:w-auto">
|
||||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -369,7 +420,7 @@ export default function SummaryPage() {
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-xl">Total amount per type</CardTitle>
|
<CardTitle className="text-xl">Total amount per type</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}.
|
Starting balance, planned expenses, and {Number(summary.result || 0) >= 0 ? 'remaining' : 'shortfall'} for {monthLabel(data.year, data.month)}.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ const STATUS_META = {
|
||||||
|
|
||||||
// ── Summary cards ──────────────────────────────────────────────────────────
|
// ── Summary cards ──────────────────────────────────────────────────────────
|
||||||
const CARD_DEFS = {
|
const CARD_DEFS = {
|
||||||
expected: {
|
starting: {
|
||||||
label: 'Total Expected',
|
label: 'Starting',
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
bar: 'from-slate-400 to-slate-300',
|
bar: 'from-slate-400 to-slate-300',
|
||||||
glow: '',
|
glow: '',
|
||||||
|
|
@ -96,7 +96,7 @@ const CARD_DEFS = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function SummaryCard({ type, value }) {
|
function SummaryCard({ type, value, onEdit, hint }) {
|
||||||
const def = CARD_DEFS[type];
|
const def = CARD_DEFS[type];
|
||||||
const isActive = def.activateWhen(value || 0);
|
const isActive = def.activateWhen(value || 0);
|
||||||
const Icon = def.icon;
|
const Icon = def.icon;
|
||||||
|
|
@ -118,6 +118,16 @@ function SummaryCard({ type, value }) {
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{def.label}
|
{def.label}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
|
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
|
||||||
|
|
@ -125,6 +135,7 @@ function SummaryCard({ type, value }) {
|
||||||
)}>
|
)}>
|
||||||
{fmt(value)}
|
{fmt(value)}
|
||||||
</p>
|
</p>
|
||||||
|
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +408,186 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [firstAmount, setFirstAmount] = useState('0');
|
||||||
|
const [fifteenthAmount, setFifteenthAmount] = useState('0');
|
||||||
|
const [otherAmount, setOtherAmount] = useState('0');
|
||||||
|
const [preview, setPreview] = useState(null);
|
||||||
|
|
||||||
|
const monthName = `${MONTHS[month - 1]} ${year}`;
|
||||||
|
const localFirst = Number(firstAmount) || 0;
|
||||||
|
const localFifteenth = Number(fifteenthAmount) || 0;
|
||||||
|
const localOther = Number(otherAmount) || 0;
|
||||||
|
const totalStarting = localFirst + localFifteenth + localOther;
|
||||||
|
const paidSoFar = Number(preview?.paid_total || 0);
|
||||||
|
const firstRemaining = localFirst - Number(preview?.paid_from_first || 0);
|
||||||
|
const fifteenthRemaining = localFifteenth - Number(preview?.paid_from_fifteenth || 0);
|
||||||
|
const totalRemaining = totalStarting - paidSoFar;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
async function loadStartingAmounts() {
|
||||||
|
if (!open) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.getMonthlyStartingAmounts(year, month);
|
||||||
|
if (!alive) return;
|
||||||
|
setPreview(result);
|
||||||
|
setFirstAmount(String(result.first_amount ?? 0));
|
||||||
|
setFifteenthAmount(String(result.fifteenth_amount ?? 0));
|
||||||
|
setOtherAmount(String(result.other_amount ?? 0));
|
||||||
|
} catch (err) {
|
||||||
|
if (!alive) return;
|
||||||
|
setError(err.message || 'Monthly starting amounts could not be loaded.');
|
||||||
|
} finally {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadStartingAmounts();
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [open, year, month]);
|
||||||
|
|
||||||
|
async function handleSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const first = Number(firstAmount);
|
||||||
|
const fifteenth = Number(fifteenthAmount);
|
||||||
|
const other = Number(otherAmount);
|
||||||
|
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
|
||||||
|
setError('Starting amounts must be non-negative numbers.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.updateMonthlyStartingAmounts({
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
first_amount: first,
|
||||||
|
fifteenth_amount: fifteenth,
|
||||||
|
other_amount: other,
|
||||||
|
});
|
||||||
|
toast.success('Monthly starting amounts saved.');
|
||||||
|
onSave();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Monthly starting amounts could not be saved.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
|
||||||
|
<DialogContent className="max-h-[92vh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-lg font-semibold tracking-tight">Monthly Starting Amounts</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{monthName}</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form id="starting-amounts-form" onSubmit={handleSave} className="space-y-5">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<Label htmlFor="starting-first" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
1st
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="starting-first"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={firstAmount}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={e => setFirstAmount(e.target.value)}
|
||||||
|
className="font-mono bg-background/50 border-border/60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<Label htmlFor="starting-fifteenth" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
15th
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="starting-fifteenth"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={fifteenthAmount}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={e => setFifteenthAmount(e.target.value)}
|
||||||
|
className="font-mono bg-background/50 border-border/60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<Label htmlFor="starting-other" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
Other
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="starting-other"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={otherAmount}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={e => setOtherAmount(e.target.value)}
|
||||||
|
className="font-mono bg-background/50 border-border/60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/35 p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalStarting)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(paidSoFar)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalRemaining)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
|
||||||
|
<div className="flex justify-between gap-3 sm:block">
|
||||||
|
<span className="text-muted-foreground">1st remaining</span>
|
||||||
|
<span className="font-mono font-semibold sm:block">{fmt(firstRemaining)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 sm:block">
|
||||||
|
<span className="text-muted-foreground">15th remaining</span>
|
||||||
|
<span className="font-mono font-semibold sm:block">{fmt(fifteenthRemaining)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 sm:block">
|
||||||
|
<span className="text-muted-foreground">Other</span>
|
||||||
|
<span className="font-mono font-semibold sm:block">{fmt(localOther)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-2">
|
||||||
|
<Button type="button" variant="ghost" disabled={saving} onClick={onClose} className="text-xs">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="starting-amounts-form" disabled={loading || saving} className="text-xs">
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Payment modal ──────────────────────────────────────────────────────────
|
// ── Payment modal ──────────────────────────────────────────────────────────
|
||||||
function PaymentModal({ payment, onClose, onSave }) {
|
function PaymentModal({ payment, onClose, onSave }) {
|
||||||
const [amount, setAmount] = useState(String(payment.amount));
|
const [amount, setAmount] = useState(String(payment.amount));
|
||||||
|
|
@ -983,6 +1174,8 @@ export default function TrackerPage() {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
// Edit Bill modal: { bill, categories } when open, null when closed
|
// Edit Bill modal: { bill, categories } when open, null when closed
|
||||||
const [editBillData, setEditBillData] = useState(null);
|
const [editBillData, setEditBillData] = useState(null);
|
||||||
|
// Edit Starting Amounts modal: true when open, false when closed
|
||||||
|
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1072,7 +1265,12 @@ export default function TrackerPage() {
|
||||||
|
|
||||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
<div className="grid grid-cols-2 gap-3 lg:flex">
|
||||||
<SummaryCard type="expected" value={summary.total_expected} />
|
<SummaryCard
|
||||||
|
type="starting"
|
||||||
|
value={summary.total_starting}
|
||||||
|
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
|
||||||
|
onEdit={() => setEditStartingOpen(true)}
|
||||||
|
/>
|
||||||
<SummaryCard type="paid" value={summary.total_paid} />
|
<SummaryCard type="paid" value={summary.total_paid} />
|
||||||
<SummaryCard type="remaining" value={summary.remaining} />
|
<SummaryCard type="remaining" value={summary.remaining} />
|
||||||
<SummaryCard type="overdue" value={summary.overdue} />
|
<SummaryCard type="overdue" value={summary.overdue} />
|
||||||
|
|
@ -1105,6 +1303,15 @@ export default function TrackerPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Starting Amounts modal */}
|
||||||
|
<StartingAmountsEditDialog
|
||||||
|
open={editStartingOpen}
|
||||||
|
onClose={() => setEditStartingOpen(false)}
|
||||||
|
year={year}
|
||||||
|
month={month}
|
||||||
|
onSave={() => { setEditStartingOpen(false); load(); }}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,8 @@ function runMigrations() {
|
||||||
// ── users: notification columns ───────────────────────────────────────────
|
// ── users: notification columns ───────────────────────────────────────────
|
||||||
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||||
const newUserCols = [
|
const newUserCols = [
|
||||||
|
['active', 'INTEGER NOT NULL DEFAULT 1'],
|
||||||
|
['is_default_admin', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
['notification_email', 'TEXT'],
|
['notification_email', 'TEXT'],
|
||||||
['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'],
|
['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
['notify_3d', 'INTEGER NOT NULL DEFAULT 1'],
|
['notify_3d', 'INTEGER NOT NULL DEFAULT 1'],
|
||||||
|
|
@ -120,6 +122,25 @@ function runMigrations() {
|
||||||
for (const [col, def] of newUserCols) {
|
for (const [col, def] of newUserCols) {
|
||||||
if (!userCols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
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) ──────────────────────────────────
|
// ── payments: soft-delete column (v0.2) ──────────────────────────────────
|
||||||
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
||||||
|
|
@ -168,7 +189,30 @@ function runMigrations() {
|
||||||
`);
|
`);
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
|
||||||
|
|
||||||
// ── import_sessions: temporary preview state (v0.38) ─────────────────────
|
// -- monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th (v0.18.2)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
||||||
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
||||||
|
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
|
||||||
|
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
|
||||||
|
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id, year, month)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
|
||||||
|
|
||||||
|
// ── monthly_starting_amounts: add other_amount column (v0.18.3) ─────────────
|
||||||
|
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
|
||||||
|
if (!startingCols.includes('other_amount')) {
|
||||||
|
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
|
||||||
|
console.log('[migration] monthly_starting_amounts.other_amount column added');
|
||||||
|
}
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS import_sessions (
|
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
|
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,
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
first_login INTEGER NOT NULL DEFAULT 1,
|
first_login INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ function getSingleModeUser() {
|
||||||
const userId = getSetting('default_user_id');
|
const userId = getSetting('default_user_id');
|
||||||
if (!userId) return null;
|
if (!userId) return null;
|
||||||
const row = getDb().prepare(
|
const row = getDb().prepare(
|
||||||
"SELECT id, username, display_name, role, must_change_password, first_login FROM users WHERE id = ? AND role = 'user'"
|
"SELECT id, username, display_name, role, must_change_password, first_login, active, is_default_admin FROM users WHERE id = ? AND role = 'user' AND active = 1"
|
||||||
).get(userId);
|
).get(userId);
|
||||||
return row ? publicUser(row) : null;
|
return row ? publicUser(row) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +27,9 @@ function requireAuth(req, res, next) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireUser(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)) {
|
if (!['user', 'admin'].includes(req.user?.role)) {
|
||||||
return res.status(403).json({ error: 'Access denied: user account required' });
|
return res.status(403).json({ error: 'Access denied: user account required' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.18.1",
|
"version": "0.18.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.18.1",
|
"version": "0.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.18.1",
|
"version": "0.18.4",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
let pkg;
|
||||||
|
try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; }
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
name: 'BillTracker',
|
||||||
|
version: pkg.version,
|
||||||
|
description: 'A self-hosted app for tracking recurring bills, monthly payments, due dates, categories, and personal bill history.',
|
||||||
|
stack: {
|
||||||
|
backend: 'Node.js / Express',
|
||||||
|
frontend: 'React',
|
||||||
|
database: 'SQLite',
|
||||||
|
},
|
||||||
|
ai_assisted: true,
|
||||||
|
links: {
|
||||||
|
release_notes: '/release-notes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -30,7 +30,7 @@ function sendError(res, err) {
|
||||||
|
|
||||||
// GET /api/admin/has-users
|
// GET /api/admin/has-users
|
||||||
router.get('/has-users', (req, res) => {
|
router.get('/has-users', (req, res) => {
|
||||||
const count = getDb().prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'").get().n;
|
const count = getDb().prepare('SELECT COUNT(*) AS n FROM users WHERE id != ?').get(req.user.id).n;
|
||||||
res.json({ has_users: count > 0 });
|
res.json({ has_users: count > 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -38,7 +38,7 @@ router.get('/has-users', (req, res) => {
|
||||||
router.get('/users', (req, res) => {
|
router.get('/users', (req, res) => {
|
||||||
res.json(
|
res.json(
|
||||||
getDb().prepare(
|
getDb().prepare(
|
||||||
'SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC'
|
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users ORDER BY is_default_admin DESC, role DESC, username ASC'
|
||||||
).all()
|
).all()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -156,7 +156,7 @@ router.post('/users', async (req, res) => {
|
||||||
).run(username, hash);
|
).run(username, hash);
|
||||||
|
|
||||||
res.status(201).json(
|
res.status(201).json(
|
||||||
db.prepare('SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?')
|
db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?')
|
||||||
.get(result.lastInsertRowid)
|
.get(result.lastInsertRowid)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -170,7 +170,6 @@ router.put('/users/:id/password', async (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
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) 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);
|
const hash = await hashPassword(password);
|
||||||
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
|
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
|
||||||
|
|
@ -208,20 +207,46 @@ router.put('/users/:id/role', (req, res) => {
|
||||||
.run(role, targetId);
|
.run(role, targetId);
|
||||||
|
|
||||||
const updated = db.prepare(
|
const updated = db.prepare(
|
||||||
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
|
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
|
||||||
).get(targetId);
|
).get(targetId);
|
||||||
|
|
||||||
res.json(updated);
|
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
|
// DELETE /api/admin/users/:id
|
||||||
router.delete('/users/:id', (req, res) => {
|
router.delete('/users/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
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) return res.status(404).json({ error: 'User not found' });
|
||||||
if (user.role === 'admin') return res.status(403).json({ error: 'Cannot delete the admin account' });
|
if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' });
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
|
||||||
res.json({ success: true });
|
const deleteUser = db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM import_sessions WHERE user_id = ?').run(user.id);
|
||||||
|
db.prepare('DELETE FROM import_history WHERE user_id = ?').run(user.id);
|
||||||
|
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||||
|
});
|
||||||
|
deleteUser();
|
||||||
|
res.json({ success: true, deleted_user_id: user.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Cleanup endpoints ─────────────────────────────────────────────────────────
|
// ── Cleanup endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -106,8 +106,25 @@ router.delete('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
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' });
|
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
||||||
db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
|
||||||
res.json({ success: true });
|
const deleteCategory = db.transaction(() => {
|
||||||
|
const bills = db.prepare(`
|
||||||
|
UPDATE bills
|
||||||
|
SET category_id = NULL, updated_at = datetime('now')
|
||||||
|
WHERE category_id = ? AND user_id = ?
|
||||||
|
`).run(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
const deleted = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?')
|
||||||
|
.run(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleted: deleted.changes,
|
||||||
|
uncategorized_bills: bills.changes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = deleteCategory();
|
||||||
|
res.json({ success: true, ...result });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ function getUserExportData(userId) {
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
ORDER BY m.year, m.month, m.bill_id
|
ORDER BY m.year, m.month, m.bill_id
|
||||||
`).all(userId);
|
`).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 = [
|
const notes = [
|
||||||
...bills.filter(b => b.notes).map(b => ({ type: 'bill', bill_id: b.id, notes: b.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 })),
|
...payments.filter(p => p.notes).map(p => ({ type: 'payment', payment_id: p.id, bill_id: p.bill_id, notes: p.notes })),
|
||||||
|
|
@ -117,16 +123,17 @@ function getUserExportData(userId) {
|
||||||
const metadata = {
|
const metadata = {
|
||||||
exported_at: new Date().toISOString(),
|
exported_at: new Date().toISOString(),
|
||||||
export_type: 'user_data',
|
export_type: 'user_data',
|
||||||
includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Notes', 'Export metadata'],
|
includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Monthly starting amounts', 'Notes', 'Export metadata'],
|
||||||
counts: {
|
counts: {
|
||||||
bills: bills.length,
|
bills: bills.length,
|
||||||
payments: payments.length,
|
payments: payments.length,
|
||||||
categories: categories.length,
|
categories: categories.length,
|
||||||
monthly_bill_state: monthlyState.length,
|
monthly_bill_state: monthlyState.length,
|
||||||
|
monthly_starting_amounts: monthlyStartingAmounts.length,
|
||||||
notes: notes.length,
|
notes: notes.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
|
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/user-excel', (req, res) => {
|
router.get('/user-excel', (req, res) => {
|
||||||
|
|
@ -137,6 +144,7 @@ router.get('/user-excel', (req, res) => {
|
||||||
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.payments), 'Payments');
|
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.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.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_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');
|
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.notes), 'Notes');
|
||||||
const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
|
@ -155,6 +163,7 @@ router.get('/user-db', (req, res) => {
|
||||||
CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT);
|
CREATE TABLE 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 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_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);
|
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 (?, ?)');
|
const meta = out.prepare('INSERT INTO export_metadata (key, value) VALUES (?, ?)');
|
||||||
|
|
@ -170,6 +179,7 @@ router.get('/user-db', (req, res) => {
|
||||||
insertRows('bills', data.bills);
|
insertRows('bills', data.bills);
|
||||||
insertRows('payments', data.payments);
|
insertRows('payments', data.payments);
|
||||||
insertRows('monthly_bill_state', data.monthly_bill_state);
|
insertRows('monthly_bill_state', data.monthly_bill_state);
|
||||||
|
insertRows('monthly_starting_amounts', data.monthly_starting_amounts);
|
||||||
insertRows('notes', data.notes.map(n => ({
|
insertRows('notes', data.notes.map(n => ({
|
||||||
type: n.type,
|
type: n.type,
|
||||||
bill_id: n.bill_id ?? null,
|
bill_id: n.bill_id ?? null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const { getCycleRange } = require('../services/statusService');
|
||||||
|
|
||||||
|
function parseYearMonth(source) {
|
||||||
|
const now = new Date();
|
||||||
|
const year = parseInt(source.year || now.getFullYear(), 10);
|
||||||
|
const month = parseInt(source.month || now.getMonth() + 1, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(year) || year < 2000 || year > 2100) {
|
||||||
|
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
|
||||||
|
}
|
||||||
|
if (Number.isNaN(month) || month < 1 || month > 12) {
|
||||||
|
return { error: 'month must be an integer between 1 and 12' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { year, month };
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartingAmounts(db, userId, year, month) {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT first_amount, fifteenth_amount, other_amount
|
||||||
|
FROM monthly_starting_amounts
|
||||||
|
WHERE user_id = ? AND year = ? AND month = ?
|
||||||
|
`).get(userId, year, month);
|
||||||
|
|
||||||
|
return {
|
||||||
|
first_amount: money(row?.first_amount || 0),
|
||||||
|
fifteenth_amount: money(row?.fifteenth_amount || 0),
|
||||||
|
other_amount: money(row?.other_amount || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePaidDeductions(db, userId, year, month) {
|
||||||
|
const { start, end } = getCycleRange(year, month);
|
||||||
|
|
||||||
|
// Paid from first bucket: bills with due_day 1-14
|
||||||
|
const firstPaid = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||||
|
FROM payments p
|
||||||
|
JOIN bills b ON b.id = p.bill_id
|
||||||
|
WHERE b.user_id = ?
|
||||||
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND b.due_day BETWEEN 1 AND 14
|
||||||
|
`).get(userId, start, end);
|
||||||
|
|
||||||
|
// Paid from fifteenth bucket: bills with due_day 15-31
|
||||||
|
const fifteenthPaid = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||||
|
FROM payments p
|
||||||
|
JOIN bills b ON b.id = p.bill_id
|
||||||
|
WHERE b.user_id = ?
|
||||||
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND b.due_day BETWEEN 15 AND 31
|
||||||
|
`).get(userId, start, end);
|
||||||
|
|
||||||
|
const totalPaid = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||||
|
FROM payments p
|
||||||
|
JOIN bills b ON b.id = p.bill_id
|
||||||
|
WHERE b.user_id = ?
|
||||||
|
AND p.paid_date BETWEEN ? AND ?
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
`).get(userId, start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid_from_first: money(firstPaid.paid),
|
||||||
|
paid_from_fifteenth: money(fifteenthPaid.paid),
|
||||||
|
paid_total: money(totalPaid.paid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStartingAmountsResponse(db, userId, year, month) {
|
||||||
|
const amounts = getStartingAmounts(db, userId, year, month);
|
||||||
|
const paid = calculatePaidDeductions(db, userId, year, month);
|
||||||
|
|
||||||
|
const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
|
||||||
|
const paid_total = paid.paid_total;
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
first_amount: amounts.first_amount,
|
||||||
|
fifteenth_amount: amounts.fifteenth_amount,
|
||||||
|
other_amount: amounts.other_amount,
|
||||||
|
combined_amount,
|
||||||
|
paid_from_first: paid.paid_from_first,
|
||||||
|
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||||
|
paid_total,
|
||||||
|
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||||
|
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||||
|
other_remaining: amounts.other_amount,
|
||||||
|
combined_remaining: combined_amount - paid_total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const parsed = parseYearMonth(req.query);
|
||||||
|
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/', (req, res) => {
|
||||||
|
const parsed = parseYearMonth(req.body || {});
|
||||||
|
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||||
|
|
||||||
|
const firstAmount = Number(req.body?.first_amount);
|
||||||
|
if (!Number.isFinite(firstAmount) || firstAmount < 0 || firstAmount > 1000000000) {
|
||||||
|
return res.status(400).json({ error: 'first_amount must be a number between 0 and 1000000000' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fifteenthAmount = Number(req.body?.fifteenth_amount);
|
||||||
|
if (!Number.isFinite(fifteenthAmount) || fifteenthAmount < 0 || fifteenthAmount > 1000000000) {
|
||||||
|
return res.status(400).json({ error: 'fifteenth_amount must be a number between 0 and 1000000000' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherAmount = Number(req.body?.other_amount);
|
||||||
|
if (!Number.isFinite(otherAmount) || otherAmount < 0 || otherAmount > 1000000000) {
|
||||||
|
return res.status(400).json({ error: 'other_amount must be a number between 0 and 1000000000' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id, year, month) DO UPDATE SET
|
||||||
|
first_amount = excluded.first_amount,
|
||||||
|
fifteenth_amount = excluded.fifteenth_amount,
|
||||||
|
other_amount = excluded.other_amount,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
`).run(req.user.id, parsed.year, parsed.month, firstAmount, fifteenthAmount, otherAmount);
|
||||||
|
|
||||||
|
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -18,7 +18,7 @@ const { passwordLimiter } = require('../middleware/rateLimiter');
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare(`
|
const user = db.prepare(`
|
||||||
SELECT id, username, display_name, role,
|
SELECT id, username, display_name, role, active, is_default_admin,
|
||||||
first_login, created_at, updated_at,
|
first_login, created_at, updated_at,
|
||||||
last_password_change_at,
|
last_password_change_at,
|
||||||
notification_email, notifications_enabled,
|
notification_email, notifications_enabled,
|
||||||
|
|
@ -33,6 +33,8 @@ router.get('/', (req, res) => {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
display_name: user.display_name || null,
|
display_name: user.display_name || null,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
active: !!user.active,
|
||||||
|
is_default_admin: !!user.is_default_admin,
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
updated_at: user.updated_at,
|
updated_at: user.updated_at,
|
||||||
last_password_change_at: user.last_password_change_at || null,
|
last_password_change_at: user.last_password_change_at || null,
|
||||||
|
|
@ -73,7 +75,14 @@ router.patch('/', (req, res) => {
|
||||||
).run(trimmed || null, req.user.id);
|
).run(trimmed || null, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
const updated = getDb().prepare(`
|
||||||
|
SELECT id, username, display_name, role, active, is_default_admin,
|
||||||
|
first_login, created_at, updated_at,
|
||||||
|
last_password_change_at
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
`).get(req.user.id);
|
||||||
|
|
||||||
|
res.json({ success: true, profile: updated });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET /api/profile/settings ─────────────────────────────────────────────────
|
// ── GET /api/profile/settings ─────────────────────────────────────────────────
|
||||||
|
|
@ -105,15 +114,17 @@ router.get('/settings', (req, res) => {
|
||||||
router.patch('/settings', (req, res) => {
|
router.patch('/settings', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const {
|
const {
|
||||||
notification_email, notifications_enabled,
|
notification_email, email, notifications_enabled,
|
||||||
notify_3d, notify_1d, notify_due, notify_overdue,
|
notify_3d, notify_1d, notify_due, notify_overdue,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (notification_email !== undefined && notification_email !== null) {
|
const nextEmail = notification_email !== undefined ? notification_email : email;
|
||||||
if (typeof notification_email !== 'string') {
|
|
||||||
|
if (nextEmail !== undefined && nextEmail !== null) {
|
||||||
|
if (typeof nextEmail !== 'string') {
|
||||||
return res.status(400).json({ error: 'notification_email must be a string' });
|
return res.status(400).json({ error: 'notification_email must be a string' });
|
||||||
}
|
}
|
||||||
if (notification_email.trim().length > 255) {
|
if (nextEmail.trim().length > 255) {
|
||||||
return res.status(400).json({ error: 'notification_email is too long' });
|
return res.status(400).json({ error: 'notification_email is too long' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,8 +135,8 @@ router.patch('/settings', (req, res) => {
|
||||||
FROM users WHERE id = ?
|
FROM users WHERE id = ?
|
||||||
`).get(req.user.id);
|
`).get(req.user.id);
|
||||||
|
|
||||||
const emailVal = notification_email !== undefined
|
const emailVal = nextEmail !== undefined
|
||||||
? (notification_email ? notification_email.trim() || null : null)
|
? (nextEmail ? nextEmail.trim() || null : null)
|
||||||
: current.notification_email;
|
: current.notification_email;
|
||||||
|
|
||||||
const boolVal = (incoming, fallback) =>
|
const boolVal = (incoming, fallback) =>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,85 @@ function money(value) {
|
||||||
return Number.isFinite(n) ? n : 0;
|
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) {
|
function getIncome(db, userId, year, month) {
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT id, label, amount
|
SELECT id, label, amount
|
||||||
|
|
@ -109,26 +188,57 @@ function buildSummary(db, userId, year, month) {
|
||||||
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
|
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
|
||||||
const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
|
const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
|
||||||
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
|
||||||
const result = incomeTotal - expenseTotal;
|
const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
|
||||||
|
const planBaseTotal = money(starting_amounts.combined_amount);
|
||||||
|
const result = planBaseTotal - expenseTotal;
|
||||||
|
|
||||||
|
// Previous month context
|
||||||
|
let previous_month = null;
|
||||||
|
if (month > 1) {
|
||||||
|
const prevMonth = month - 1;
|
||||||
|
const prevYear = year;
|
||||||
|
const prevStarting = buildStartingAmountsSummary(db, userId, prevYear, prevMonth);
|
||||||
|
if (prevStarting.combined_amount > 0) {
|
||||||
|
previous_month = {
|
||||||
|
year: prevYear,
|
||||||
|
month: prevMonth,
|
||||||
|
combined_remaining: prevStarting.combined_remaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (year > 2000) {
|
||||||
|
const prevMonth = 12;
|
||||||
|
const prevYear = year - 1;
|
||||||
|
const prevStarting = buildStartingAmountsSummary(db, userId, prevYear, prevMonth);
|
||||||
|
if (prevStarting.combined_amount > 0) {
|
||||||
|
previous_month = {
|
||||||
|
year: prevYear,
|
||||||
|
month: prevMonth,
|
||||||
|
combined_remaining: prevStarting.combined_remaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
income,
|
income,
|
||||||
expenses,
|
expenses,
|
||||||
|
starting_amounts,
|
||||||
|
previous_month,
|
||||||
summary: {
|
summary: {
|
||||||
income_total: incomeTotal,
|
income_total: incomeTotal,
|
||||||
|
starting_total: planBaseTotal,
|
||||||
expense_total: expenseTotal,
|
expense_total: expenseTotal,
|
||||||
paid_expense_count: paidExpenseCount,
|
paid_expense_count: paidExpenseCount,
|
||||||
expense_count: countedExpenses.length,
|
expense_count: countedExpenses.length,
|
||||||
paid_total: paidTotal,
|
paid_total: starting_amounts.paid_total,
|
||||||
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
|
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
|
||||||
result,
|
result,
|
||||||
},
|
},
|
||||||
chart: [
|
chart: [
|
||||||
{ type: 'Income', amount: incomeTotal },
|
{ type: 'Starting', amount: planBaseTotal },
|
||||||
{ type: 'Expenses', amount: expenseTotal },
|
{ type: 'Expenses', amount: expenseTotal },
|
||||||
{ type: 'Savings', amount: result },
|
{ type: 'Remaining', amount: result },
|
||||||
],
|
],
|
||||||
generated_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -51,20 +51,32 @@ router.get('/', (req, res) => {
|
||||||
return row;
|
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
|
const totalOverdue = rows
|
||||||
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
|
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
|
||||||
.reduce((s, r) => s + r.balance, 0);
|
.reduce((s, r) => s + r.balance, 0);
|
||||||
|
|
||||||
const activeRows = rows.filter(r => !r.is_skipped);
|
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({
|
res.json({
|
||||||
year, month, today: todayStr,
|
year, month, today: todayStr,
|
||||||
summary: {
|
summary: {
|
||||||
total_expected: activeRows.reduce((s, r) => s + r.expected_amount, 0),
|
total_expected: activeTotalExpected,
|
||||||
total_paid: activeRows.reduce((s, r) => s + r.total_paid, 0),
|
total_starting: totalStarting,
|
||||||
remaining: Math.max(0, activeRows.reduce((s, r) => s + r.expected_amount, 0) - activeRows.reduce((s, r) => s + r.total_paid, 0)),
|
has_starting_amounts: hasStartingAmounts,
|
||||||
|
total_paid: activeTotalPaid,
|
||||||
|
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid),
|
||||||
overdue: totalOverdue,
|
overdue: totalOverdue,
|
||||||
count_paid: activeRows.filter(r => r.status === 'paid').length,
|
count_paid: activeRows.filter(r => r.status === 'paid').length,
|
||||||
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
|
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,11 @@ app.use('/api/categories', requireAuth, requireUser, require('./routes/catego
|
||||||
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
|
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
|
||||||
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
|
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
|
||||||
app.use('/api/summary', requireAuth, requireUser, require('./routes/summary'));
|
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/analytics', requireAuth, requireUser, require('./routes/analytics'));
|
||||||
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
|
||||||
app.use('/api/status', requireAuth, require('./routes/status'));
|
app.use('/api/status', requireAuth, requireAdmin, require('./routes/status'));
|
||||||
|
app.use('/api/about', require('./routes/about')); // public
|
||||||
app.use('/api/version', require('./routes/version')); // public
|
app.use('/api/version', require('./routes/version')); // public
|
||||||
|
|
||||||
// Profile — password-change rate limit applied inside the route file
|
// Profile — password-change rate limit applied inside the route file
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ async function login(username, password) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
if (user.active === 0) return null;
|
||||||
|
|
||||||
// Reject OIDC-only accounts from local login
|
// Reject OIDC-only accounts from local login
|
||||||
if (user.auth_provider && user.auth_provider !== 'local') {
|
if (user.auth_provider && user.auth_provider !== 'local') {
|
||||||
|
|
@ -79,6 +80,7 @@ async function createSession(userId) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
if (user.active === 0) return null;
|
||||||
|
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000)
|
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000)
|
||||||
|
|
@ -98,10 +100,11 @@ function logout(sessionId) {
|
||||||
function getSessionUser(sessionId) {
|
function getSessionUser(sessionId) {
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
const row = getDb().prepare(`
|
const row = getDb().prepare(`
|
||||||
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login
|
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login,
|
||||||
|
u.active, u.is_default_admin
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.id = ? AND s.expires_at > datetime('now')
|
WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1
|
||||||
`).get(sessionId);
|
`).get(sessionId);
|
||||||
return row || null;
|
return row || null;
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +119,8 @@ function publicUser(u) {
|
||||||
username: u.username,
|
username: u.username,
|
||||||
display_name: u.display_name || null,
|
display_name: u.display_name || null,
|
||||||
role: u.role,
|
role: u.role,
|
||||||
|
active: u.active !== 0,
|
||||||
|
is_default_admin: !!u.is_default_admin,
|
||||||
must_change_password: !!u.must_change_password,
|
must_change_password: !!u.must_change_password,
|
||||||
first_login: !!u.first_login,
|
first_login: !!u.first_login,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ async function runNotifications() {
|
||||||
|
|
||||||
if (allowUserConfig) {
|
if (allowUserConfig) {
|
||||||
const users = db.prepare(
|
const users = db.prepare(
|
||||||
"SELECT * FROM users WHERE role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''"
|
"SELECT * FROM users WHERE active = 1 AND role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''"
|
||||||
).all();
|
).all();
|
||||||
recipients.push(...users);
|
recipients.push(...users);
|
||||||
} else if (globalRecipient) {
|
} else if (globalRecipient) {
|
||||||
|
|
|
||||||
|
|
@ -475,6 +475,10 @@ async function findOrProvisionUser(claims, config) {
|
||||||
console.log(`[oidc] Provisioned new ${role} user from OIDC login`);
|
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
|
// Refresh login timestamp and display name from provider claims
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,23 @@ function sanitizeMonthlyState(row, validBillIds) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeMonthlyStartingAmounts(row) {
|
||||||
|
const year = toInt(row.year);
|
||||||
|
const month = toInt(row.month);
|
||||||
|
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
|
||||||
|
return {
|
||||||
|
old_id: toInt(row.id),
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
first_amount: Math.max(0, toNumber(row.first_amount, 0) ?? 0),
|
||||||
|
fifteenth_amount: Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0),
|
||||||
|
other_amount: Math.max(0, toNumber(row.other_amount, 0) ?? 0),
|
||||||
|
notes: cleanText(row.notes, 2000),
|
||||||
|
created_at: cleanText(row.created_at, 32),
|
||||||
|
updated_at: cleanText(row.updated_at, 32),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readExportData(src) {
|
function readExportData(src) {
|
||||||
const names = tableNames(src);
|
const names = tableNames(src);
|
||||||
const missing = REQUIRED_TABLES.filter(t => !names.has(t));
|
const missing = REQUIRED_TABLES.filter(t => !names.has(t));
|
||||||
|
|
@ -210,12 +227,16 @@ function readExportData(src) {
|
||||||
.map(row => sanitizePayment(row, validBillIds)).filter(Boolean);
|
.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'])
|
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);
|
.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')
|
const notes = names.has('notes')
|
||||||
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', '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)
|
.map(n => ({ ...n, notes: cleanText(n.notes, 2000) })).filter(n => n.notes)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
|
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function existingLookups(db, userId) {
|
function existingLookups(db, userId) {
|
||||||
|
|
@ -271,6 +292,13 @@ function buildPreview(userId, data, originalFilename) {
|
||||||
action: billPlanByOldId.has(m.bill_id) ? 'create_or_skip_duplicate' : 'conflict',
|
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',
|
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 = {
|
const summary = {
|
||||||
categories: {
|
categories: {
|
||||||
|
|
@ -297,6 +325,12 @@ function buildPreview(userId, data, originalFilename) {
|
||||||
skip: 0,
|
skip: 0,
|
||||||
conflict: monthlyPlan.filter(x => x.action === 'conflict').length,
|
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: {
|
notes: {
|
||||||
total: data.notes.length,
|
total: data.notes.length,
|
||||||
create: 0,
|
create: 0,
|
||||||
|
|
@ -319,6 +353,7 @@ function buildPreview(userId, data, originalFilename) {
|
||||||
categories: data.categories.length,
|
categories: data.categories.length,
|
||||||
payments: data.payments.length,
|
payments: data.payments.length,
|
||||||
monthly_bill_state: data.monthly_bill_state.length,
|
monthly_bill_state: data.monthly_bill_state.length,
|
||||||
|
monthly_starting_amounts: data.monthly_starting_amounts.length,
|
||||||
notes: data.notes.length,
|
notes: data.notes.length,
|
||||||
},
|
},
|
||||||
summary,
|
summary,
|
||||||
|
|
@ -327,6 +362,7 @@ function buildPreview(userId, data, originalFilename) {
|
||||||
bills: billPlan.slice(0, 100),
|
bills: billPlan.slice(0, 100),
|
||||||
payments: paymentPlan.slice(0, 100),
|
payments: paymentPlan.slice(0, 100),
|
||||||
monthly_bill_state: monthlyPlan.slice(0, 100),
|
monthly_bill_state: monthlyPlan.slice(0, 100),
|
||||||
|
monthly_starting_amounts: monthlyStartingAmountsPlan.slice(0, 100),
|
||||||
},
|
},
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
|
|
@ -470,6 +506,24 @@ function importMonthlyState(db, targetBillId, row, summary, details) {
|
||||||
details.monthly_bill_state.created++;
|
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 = {}) {
|
async function applyUserDbImport(userId, importSessionId, options = {}) {
|
||||||
if (options.overwrite) {
|
if (options.overwrite) {
|
||||||
throw importError(400, 'Overwrite is not supported for user SQLite imports. Existing data is skipped.', 'USER_DB_IMPORT_OVERWRITE_UNSUPPORTED');
|
throw importError(400, 'Overwrite is not supported for user SQLite imports. Existing data is skipped.', 'USER_DB_IMPORT_OVERWRITE_UNSUPPORTED');
|
||||||
|
|
@ -491,6 +545,7 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
|
||||||
bills: { created: 0, skipped: 0, errored: 0 },
|
bills: { created: 0, skipped: 0, errored: 0 },
|
||||||
payments: { created: 0, skipped: 0, errored: 0 },
|
payments: { created: 0, skipped: 0, errored: 0 },
|
||||||
monthly_bill_state: { 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 },
|
notes: { created: 0, skipped: data.notes?.length || 0, errored: 0 },
|
||||||
};
|
};
|
||||||
summary.rows_skipped += details.notes.skipped;
|
summary.rows_skipped += details.notes.skipped;
|
||||||
|
|
@ -529,6 +584,10 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
|
||||||
importMonthlyState(db, targetBillId, row, summary, details);
|
importMonthlyState(db, targetBillId, row, summary, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const row of data.monthly_starting_amounts) {
|
||||||
|
importMonthlyStartingAmounts(db, userId, row, summary, details);
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO import_history
|
INSERT INTO import_history
|
||||||
(user_id, imported_at, source_filename, file_type, sheet_name, rows_parsed,
|
(user_id, imported_at, source_filename, file_type, sheet_name, rows_parsed,
|
||||||
|
|
@ -541,7 +600,7 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
|
||||||
session.source_filename || null,
|
session.source_filename || null,
|
||||||
'sqlite',
|
'sqlite',
|
||||||
'User SQLite export',
|
'User SQLite export',
|
||||||
data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + (data.notes?.length || 0),
|
data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + data.monthly_starting_amounts.length + (data.notes?.length || 0),
|
||||||
summary.rows_created,
|
summary.rows_created,
|
||||||
summary.rows_updated,
|
summary.rows_updated,
|
||||||
summary.rows_skipped,
|
summary.rows_skipped,
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,9 @@ function promptPassword(label) {
|
||||||
async function createUser(db, username, password, role) {
|
async function createUser(db, username, password, role) {
|
||||||
const hash = await bcrypt.hash(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO users (username, password_hash, role, first_login, must_change_password)
|
INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin)
|
||||||
VALUES (?, ?, ?, 0, 0)
|
VALUES (?, ?, ?, 0, 0, ?)
|
||||||
`).run(username, hash, role);
|
`).run(username, hash, role, role === 'admin' ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runFromEnv(db) {
|
async function runFromEnv(db) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue