refactor: component splits, PWA support, CommandPalette

Component Splits:
  - AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
  - DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
  - TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)

PWA:
  - vite-plugin-pwa installed with NetworkFirst caching for API routes
  - Square PWA icons (192x192, 512x512, apple-touch-icon)
  - theme-color, apple meta tags, touch icon in index.html
  - Build generates dist/sw.js + Workbox runtime

CommandPalette:
  - Navigation commands, Add bill action, month jumps
  - Grouped results with empty/filtered states
This commit is contained in:
null 2026-05-28 20:53:22 -05:00
parent 92cc667947
commit 71dfbe36cc
31 changed files with 9725 additions and 5517 deletions

View File

@ -1,6 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Loader2, Receipt, Search, X } from 'lucide-react'; import {
BarChart2, Calendar, CreditCard, Loader2, Navigation, Plus,
Receipt, Search, Settings, Snowflake, Tag, Upload, User, X,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt } from '@/lib/utils'; import { cn, fmt } from '@/lib/utils';
@ -8,6 +11,48 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
// Navigation commands
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
const NAV_COMMANDS = [
{ id: 'nav-tracker', label: 'Go to Tracker', icon: Receipt, path: '/', group: 'Navigate' },
{ id: 'nav-bills', label: 'Go to Bills', icon: CreditCard, path: '/bills', group: 'Navigate' },
{ id: 'nav-calendar', label: 'Go to Calendar', icon: Calendar, path: '/calendar', group: 'Navigate' },
{ id: 'nav-summary', label: 'Go to Summary', icon: BarChart2, path: '/summary', group: 'Navigate' },
{ id: 'nav-analytics', label: 'Go to Analytics', icon: BarChart2, path: '/analytics', group: 'Navigate' },
{ id: 'nav-snowball', label: 'Go to Snowball', icon: Snowflake, path: '/snowball', group: 'Navigate' },
{ id: 'nav-categories', label: 'Go to Categories', icon: Tag, path: '/categories', group: 'Navigate' },
{ id: 'nav-data', label: 'Go to Data', icon: Upload, path: '/data', group: 'Navigate' },
{ id: 'nav-settings', label: 'Go to Settings', icon: Settings, path: '/settings', group: 'Navigate' },
{ id: 'nav-profile', label: 'Go to Profile', icon: User, path: '/profile', group: 'Navigate' },
{ id: 'action-new-bill', label: 'Add a new bill', icon: Plus, path: '/bills?new=1', group: 'Actions' },
];
// Generate jump-to-month commands for the current year ± 1
function buildMonthCommands() {
const now = new Date();
const year = now.getFullYear();
const commands = [];
for (let y = year - 1; y <= year + 1; y++) {
for (let m = 1; m <= 12; m++) {
commands.push({
id: `jump-${y}-${m}`,
label: `Jump to ${MONTHS[m - 1]} ${y}`,
icon: Calendar,
path: `/?year=${y}&month=${m}`,
group: 'Jump to Month',
});
}
}
return commands;
}
// Helpers
function amountSearchText(...values) { function amountSearchText(...values) {
return values return values
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
@ -42,6 +87,76 @@ function shortcutLabel() {
return 'Ctrl K'; return 'Ctrl K';
} }
// Result item components
function BillResult({ bill, onOpenBills, onOpenTracker }) {
return (
<div
className={cn(
'group grid gap-2 rounded-lg border border-transparent p-2 transition-colors',
'hover:border-border/70 hover:bg-accent/65 sm:grid-cols-[1fr_auto]',
)}
>
<button
type="button"
onClick={() => onOpenBills(bill)}
className="flex min-w-0 items-center gap-3 rounded-md text-left focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Receipt className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">{bill.name}</span>
{!bill.active && (
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Inactive
</span>
)}
</span>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span>{bill.category_name || 'Uncategorized'}</span>
<span>Due {bill.due_day}</span>
<span>{fmt(bill.expected_amount || 0)}</span>
</span>
</span>
</button>
<div className="flex items-center justify-end gap-1 pl-12 sm:pl-0">
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => onOpenTracker(bill)}>
Tracker
</Button>
<Button type="button" size="sm" variant="default" className="h-8 px-2.5 text-xs" onClick={() => onOpenBills(bill)}>
Bills
</Button>
</div>
</div>
);
}
function CommandResult({ cmd, onRun }) {
const Icon = cmd.icon || Navigation;
return (
<button
type="button"
onClick={() => onRun(cmd)}
className={cn(
'flex w-full items-center gap-3 rounded-lg border border-transparent p-2 text-left transition-colors',
'hover:border-border/70 hover:bg-accent/65 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
)}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">{cmd.label}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{cmd.group}</span>
</span>
</button>
);
}
// Main component
export default function CommandPalette() { export default function CommandPalette() {
const navigate = useNavigate(); const navigate = useNavigate();
const inputRef = useRef(null); const inputRef = useRef(null);
@ -50,6 +165,7 @@ export default function CommandPalette() {
const [bills, setBills] = useState([]); const [bills, setBills] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const monthCommands = useMemo(() => buildMonthCommands(), []);
useEffect(() => { useEffect(() => {
const openPalette = () => setOpen(true); const openPalette = () => setOpen(true);
@ -85,18 +201,6 @@ export default function CommandPalette() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [loaded, loading, open]); }, [loaded, loading, open]);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
const sorted = [...bills].sort((a, b) => {
if (!!a.active !== !!b.active) return a.active ? -1 : 1;
return String(a.name || '').localeCompare(String(b.name || ''));
});
if (!q) return sorted.slice(0, 8);
return sorted
.filter(bill => billSearchText(bill).includes(q))
.slice(0, 12);
}, [bills, query]);
const close = () => { const close = () => {
setOpen(false); setOpen(false);
setQuery(''); setQuery('');
@ -115,6 +219,46 @@ export default function CommandPalette() {
close(); close();
}; };
const runCommand = (cmd) => {
navigate(cmd.path);
close();
};
const allCommands = useMemo(() => [...NAV_COMMANDS, ...monthCommands], [monthCommands]);
const { billResults, commandResults } = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) {
const sortedBills = [...bills]
.sort((a, b) => {
if (!!a.active !== !!b.active) return a.active ? -1 : 1;
return String(a.name || '').localeCompare(String(b.name || ''));
})
.slice(0, 6);
return {
billResults: sortedBills,
commandResults: NAV_COMMANDS.slice(0, 4),
};
}
const matchedBills = [...bills]
.filter(bill => billSearchText(bill).includes(q))
.sort((a, b) => {
if (!!a.active !== !!b.active) return a.active ? -1 : 1;
return String(a.name || '').localeCompare(String(b.name || ''));
})
.slice(0, 8);
const matchedCommands = allCommands
.filter(cmd => cmd.label.toLowerCase().includes(q) || cmd.group.toLowerCase().includes(q))
.slice(0, 5);
return { billResults: matchedBills, commandResults: matchedCommands };
}, [bills, query, allCommands]);
const hasResults = billResults.length > 0 || commandResults.length > 0;
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl"> <DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl">
@ -129,9 +273,12 @@ export default function CommandPalette() {
value={query} value={query}
onChange={event => setQuery(event.target.value)} onChange={event => setQuery(event.target.value)}
onKeyDown={event => { onKeyDown={event => {
if (event.key === 'Enter' && results[0]) openBills(results[0]); if (event.key === 'Enter') {
if (billResults[0]) openBills(billResults[0]);
else if (commandResults[0]) runCommand(commandResults[0]);
}
}} }}
placeholder="Find a bill by name, category, notes, or amount" placeholder="Search bills or type a command…"
className="h-9 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0" className="h-9 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0"
/> />
{query && ( {query && (
@ -148,66 +295,56 @@ export default function CommandPalette() {
)} )}
</div> </div>
<div className="max-h-[min(28rem,70svh)] overflow-y-auto p-2"> <div className="max-h-[min(32rem,75svh)] overflow-y-auto p-2 space-y-1">
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading bills... Loading
</div> </div>
) : results.length > 0 ? ( ) : !hasResults ? (
<div className="space-y-1"> <div className="px-4 py-12 text-center text-sm text-muted-foreground">
{results.map(bill => ( No results.
<div
key={bill.id}
className={cn(
'group grid gap-2 rounded-lg border border-transparent p-2 transition-colors',
'hover:border-border/70 hover:bg-accent/65 sm:grid-cols-[1fr_auto]',
)}
>
<button
type="button"
onClick={() => openBills(bill)}
className="flex min-w-0 items-center gap-3 rounded-md text-left focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Receipt className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">{bill.name}</span>
{!bill.active && (
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Inactive
</span>
)}
</span>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span>{bill.category_name || 'Uncategorized'}</span>
<span>Due {bill.due_day}</span>
<span>{fmt(bill.expected_amount || 0)}</span>
</span>
</span>
</button>
<div className="flex items-center justify-end gap-1 pl-12 sm:pl-0">
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => openTracker(bill)}>
Tracker
</Button>
<Button type="button" size="sm" variant="default" className="h-8 px-2.5 text-xs" onClick={() => openBills(bill)}>
Bills
</Button>
</div>
</div>
))}
</div> </div>
) : ( ) : (
<div className="px-4 py-12 text-center text-sm text-muted-foreground"> <>
No bills found. {commandResults.length > 0 && (
</div> <div>
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
Commands
</p>
<div className="space-y-0.5">
{commandResults.map(cmd => (
<CommandResult key={cmd.id} cmd={cmd} onRun={runCommand} />
))}
</div>
</div>
)}
{billResults.length > 0 && (
<div>
{commandResults.length > 0 && (
<p className="mt-3 mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
Bills
</p>
)}
<div className="space-y-0.5">
{billResults.map(bill => (
<BillResult
key={bill.id}
bill={bill}
onOpenBills={openBills}
onOpenTracker={openTracker}
/>
))}
</div>
</div>
)}
</>
)} )}
</div> </div>
<div className="flex items-center justify-between border-t border-border/70 px-4 py-2 text-xs text-muted-foreground"> <div className="flex items-center justify-between border-t border-border/70 px-4 py-2 text-xs text-muted-foreground">
<span>Enter opens Bills</span> <span>Enter to open · Tab to focus</span>
<span>{shortcutLabel()}</span> <span>{shortcutLabel()}</span>
</div> </div>
</DialogContent> </DialogContent>

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
export default function AddUserCard({ onCreated }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleCreate = async (e) => {
e.preventDefault();
setError('');
if (password.length < 8) {
const msg = 'Password must be at least 8 characters.';
setError(msg);
toast.error(msg);
return;
}
setLoading(true);
try {
await api.createUser({ username, password });
toast.success(`User "${username}" created.`);
setUsername('');
setPassword('');
setError('');
onCreated();
} catch (err) {
const errorMessage = err.message || 'Failed to create user.';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader className="pb-4">
<CardTitle>Add User</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="space-y-1.5 flex-1">
<Label htmlFor="new-uname">Username</Label>
<Input id="new-uname" value={username} onChange={e => setUsername(e.target.value)} placeholder="username" required />
</div>
<div className="space-y-1.5 flex-1">
<Label htmlFor="new-upw">Password</Label>
<Input id="new-upw" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" required />
</div>
<div className="lg:flex-1" />
{error && (
<div className="w-full lg:w-auto rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive lg:col-start-1 lg:col-span-3">
{error}
</div>
)}
<Button type="submit" disabled={loading} className="shrink-0" aria-busy={loading}>
{loading ? 'Creating…' : 'Create User'}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,344 @@
import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { FieldRow, Toggle } from './adminShared';
const AUTHENTIK_ICON_URL = '/img/auth.png';
function defaultOidcRedirectUri() {
if (typeof window === 'undefined') return '';
return `${window.location.origin}/api/auth/oidc/callback`;
}
function looksLikeOidcEndpoint(url) {
const value = String(url || '').toLowerCase();
return /\/(?:authorize|token|userinfo|jwks|certs)\/?$/.test(value);
}
export default function AuthMethodsCard() {
const [data, setData] = useState(null);
const [form, setForm] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testingOidc, setTestingOidc] = useState(false);
const [oidcTest, setOidcTest] = useState(null);
const load = useCallback(async () => {
try {
const d = await api.authModeConfig();
setData(d);
setForm({
local_login_enabled: d.local_login_enabled !== false,
oidc_login_enabled: !!d.oidc_login_enabled,
oidc_provider_name: d.oidc_provider_name || 'authentik',
oidc_issuer_url: d.oidc_issuer_url || '',
oidc_client_id: d.oidc_client_id || '',
oidc_client_secret: '',
oidc_client_secret_clear: false,
oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic',
oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(),
oidc_scopes: d.oidc_scopes || 'openid email profile groups',
oidc_auto_provision: d.oidc_auto_provision !== false,
oidc_admin_group: d.oidc_admin_group || '',
oidc_default_role: d.oidc_default_role || 'user',
});
} catch (err) {
toast.error(err.message || 'Failed to load auth settings.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
async function handleSave() {
setSaving(true);
try {
const d = await api.setAuthMode(form);
setData(d);
setForm({
local_login_enabled: d.local_login_enabled !== false,
oidc_login_enabled: !!d.oidc_login_enabled,
oidc_provider_name: d.oidc_provider_name || 'authentik',
oidc_issuer_url: d.oidc_issuer_url || '',
oidc_client_id: d.oidc_client_id || '',
oidc_client_secret: '',
oidc_client_secret_clear: false,
oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic',
oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(),
oidc_scopes: d.oidc_scopes || 'openid email profile groups',
oidc_auto_provision: d.oidc_auto_provision !== false,
oidc_admin_group: d.oidc_admin_group || '',
oidc_default_role: d.oidc_default_role || 'user',
});
toast.success('Auth method settings saved.');
} catch (err) {
toast.error(err.message || 'Failed to save auth method settings.');
} finally {
setSaving(false);
}
}
async function handleTestOidc() {
setTestingOidc(true);
setOidcTest(null);
try {
const result = await api.testOidcConfig(form);
setOidcTest(result);
toast.success('authentik configuration test passed.');
} catch (err) {
const result = err.data || { ok: false, error: err.message || 'OIDC configuration test failed.' };
setOidcTest(result);
toast.error(result.error || 'OIDC configuration test failed.');
} finally {
setTestingOidc(false);
}
}
if (loading || !form) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Loading auth settings
</CardContent>
</Card>
);
}
const secretAvailable = form.oidc_client_secret.trim()
? true
: form.oidc_client_secret_clear
? false
: !!data?.oidc_client_secret_set;
const oidcConfigured = !!(
form.oidc_issuer_url.trim() &&
form.oidc_client_id.trim() &&
secretAvailable &&
form.oidc_redirect_uri.trim()
);
const adminGroupConfigured = !!form.oidc_admin_group.trim();
const wouldLockOut = !form.local_login_enabled && !form.oidc_login_enabled;
const cantDisableLocal = !form.local_login_enabled && (!oidcConfigured || !form.oidc_login_enabled || !adminGroupConfigured);
const oidcEnabledButIncomplete = form.oidc_login_enabled && !oidcConfigured;
const canSave = !wouldLockOut && !cantDisableLocal && !oidcEnabledButIncomplete && !saving;
const canTestOidc = oidcConfigured && !testingOidc;
const missingFields = [
!form.oidc_issuer_url.trim() && 'Issuer URL',
!form.oidc_client_id.trim() && 'Client ID',
!secretAvailable && 'Client Secret',
!form.oidc_redirect_uri.trim() && 'Redirect URI',
].filter(Boolean);
const issuerEndpointWarning = looksLikeOidcEndpoint(form.oidc_issuer_url);
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Authentication Methods</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Control local login and authentik/OIDC. Settings are saved in the database;
environment variables only fill blank fields as bootstrap defaults.
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
{(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-1">
{wouldLockOut && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Cannot disable all login methods; at least one must remain enabled.
</p>
)}
{cantDisableLocal && !wouldLockOut && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Cannot disable local login without authentik/OIDC configured, enabled, and mapped to an admin group.
</p>
)}
{oidcEnabledButIncomplete && (
<p className="text-sm text-amber-600 dark:text-amber-400">
authentik/OIDC needs {missingFields.join(', ')} before it can be enabled.
</p>
)}
{data?.warnings?.map((w, i) => (
<p key={i} className="text-sm text-amber-600 dark:text-amber-400">{w}</p>
))}
</div>
)}
<FieldRow label="Local username/password login">
<div className="flex items-center gap-3">
<Toggle checked={form.local_login_enabled} onChange={v => set('local_login_enabled', v)} label="Enable local login" />
<span className="text-xs text-muted-foreground">{form.local_login_enabled ? 'Enabled' : 'Disabled'}</span>
</div>
</FieldRow>
<FieldRow label="authentik / OIDC login">
<div className="flex items-center gap-3">
<Toggle checked={form.oidc_login_enabled} onChange={v => set('oidc_login_enabled', v)} label="Enable OIDC login" />
<span className={`text-xs ${oidcConfigured ? 'text-muted-foreground' : 'text-amber-500'}`}>
{!oidcConfigured ? 'Not fully configured' : form.oidc_login_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</FieldRow>
<div className="space-y-4 pt-2 border-t border-border">
<div className="flex items-center gap-2 pt-1 text-sm font-medium text-muted-foreground">
<img src={AUTHENTIK_ICON_URL} alt="" aria-hidden="true" className="h-5 w-5 shrink-0 object-contain" />
<span>authentik / OIDC configuration</span>
</div>
<FieldRow label="Provider name">
<Input value={form.oidc_provider_name} onChange={e => set('oidc_provider_name', e.target.value)} placeholder="authentik" className="max-w-xs h-8 text-sm" />
</FieldRow>
<FieldRow label="Issuer / discovery URL">
<div className="space-y-1">
<Input
value={form.oidc_issuer_url}
onChange={e => set('oidc_issuer_url', e.target.value)}
placeholder="https://yourURL.com/application/o/bills/.well-known/openid-configuration"
className="max-w-xl h-8 text-sm"
/>
<p className={issuerEndpointWarning ? 'text-xs text-amber-500' : 'text-xs text-muted-foreground'}>
Use the authentik provider issuer URL or full discovery URL, for example https://yourURL.com/application/o/bills/.well-known/openid-configuration.
</p>
{issuerEndpointWarning && (
<p className="text-xs text-amber-500">
This looks like an authorization endpoint. In authentik, copy the provider issuer or OpenID Configuration URL.
</p>
)}
</div>
</FieldRow>
<FieldRow label="Client ID">
<Input value={form.oidc_client_id} onChange={e => set('oidc_client_id', e.target.value)} placeholder="authentik client ID" className="max-w-xl h-8 text-sm" />
</FieldRow>
<FieldRow label="Client Secret">
<div className="space-y-2">
<div className="flex max-w-xl items-center gap-3">
<Input
type="password"
value={form.oidc_client_secret}
onChange={e => setForm(prev => ({
...prev,
oidc_client_secret: e.target.value,
oidc_client_secret_clear: e.target.value ? false : prev.oidc_client_secret_clear,
}))}
placeholder="Leave blank to keep existing secret"
className="h-8 text-sm"
/>
<span className={`shrink-0 text-xs ${data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'text-emerald-500' : 'text-muted-foreground'}`}>
{data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'Secret is set' : 'No secret saved'}
</span>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={form.oidc_client_secret_clear} onChange={e => set('oidc_client_secret_clear', e.target.checked)} />
Clear saved secret on save
</label>
</div>
</FieldRow>
<FieldRow label="Client auth method">
<div className="space-y-1">
<select
value={form.oidc_token_auth_method}
onChange={e => set('oidc_token_auth_method', e.target.value)}
className="h-8 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="client_secret_basic">client_secret_basic</option>
<option value="client_secret_post">client_secret_post</option>
</select>
<p className="text-xs text-muted-foreground">
Advanced. Keep client_secret_basic unless your authentik provider explicitly requires client_secret_post.
</p>
</div>
</FieldRow>
<FieldRow label="Redirect URI">
<div className="space-y-1">
<div className="flex max-w-xl gap-2">
<Input value={form.oidc_redirect_uri} onChange={e => set('oidc_redirect_uri', e.target.value)} placeholder={defaultOidcRedirectUri()} className="h-8 text-sm" />
<Button type="button" variant="outline" size="sm" onClick={() => set('oidc_redirect_uri', defaultOidcRedirectUri())}>Use Current</Button>
</div>
<p className="text-xs text-muted-foreground">Add this exact URL to the Redirect URIs allowed by authentik.</p>
</div>
</FieldRow>
<FieldRow label="Scopes">
<Input value={form.oidc_scopes} onChange={e => set('oidc_scopes', e.target.value)} placeholder="openid email profile groups" className="max-w-xl h-8 text-sm" />
</FieldRow>
<FieldRow label="Admin group">
<div className="space-y-1">
<Input value={form.oidc_admin_group} onChange={e => set('oidc_admin_group', e.target.value)} placeholder="e.g. bill-tracker-admins" className="max-w-sm h-8 text-sm" />
<p className="text-xs text-muted-foreground">Only users in this authentik group become app admins. Admin is never granted by default.</p>
</div>
</FieldRow>
<FieldRow label="Auto-provision users">
<div className="space-y-1">
<div className="flex items-center gap-3">
<Toggle checked={form.oidc_auto_provision} onChange={v => set('oidc_auto_provision', v)} label="Auto-provision users" />
<span className="text-xs text-muted-foreground">{form.oidc_auto_provision ? 'Enabled' : 'Disabled'}</span>
</div>
<p className="text-xs text-muted-foreground">When enabled, valid authentik users are created in this app on first login.</p>
</div>
</FieldRow>
<FieldRow label="Default role">
<div className="flex items-center gap-2">
<Input value="user" readOnly className="max-w-[120px] h-8 text-sm" />
<span className="text-xs text-muted-foreground">Admin role only via admin group.</span>
</div>
</FieldRow>
{data?.oidc_env_fallback_used && (
<div className="rounded-lg border border-sky-500/25 bg-sky-500/10 px-4 py-3 text-xs text-sky-700 dark:text-sky-400">
One or more blank database fields are currently using environment fallback values. Saving values here takes precedence.
</div>
)}
{oidcTest && (
<div className={`rounded-lg border px-4 py-3 text-xs ${
oidcTest.ok
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
: 'border-destructive/25 bg-destructive/10 text-destructive'
}`}>
{oidcTest.ok
? `Configuration test passed for ${oidcTest.issuer || form.oidc_issuer_url}.`
: oidcTest.error || 'Configuration test failed.'}
</div>
)}
</div>
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={handleTestOidc} disabled={!canTestOidc}>
{testingOidc ? 'Testing…' : 'Test Configuration'}
</Button>
<Button
variant="outline"
disabled={!data?.oidc_login_enabled || !data?.oidc_configured}
onClick={() => { window.location.href = '/api/auth/oidc/login?redirect_to=/admin'; }}
>
<img src={AUTHENTIK_ICON_URL} alt="" aria-hidden="true" className="mr-2 h-4 w-4 shrink-0 object-contain" />
Test authentik Login
</Button>
<Button onClick={handleSave} disabled={!canSave}>
{saving ? 'Saving…' : 'Save Auth Settings'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,381 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Database, Download, Play, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmtBytes } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { SectionHeading, Toggle, formatDateTime, BackupTypeBadge } from './adminShared';
const DEFAULT_SETTINGS = {
enabled: false,
frequency: 'daily',
time: '02:00',
retention_count: 14,
last_run_at: null,
next_run_at: null,
last_error: null,
};
export default function BackupManagementCard() {
const [backups, setBackups] = useState([]);
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState('');
const [restoreTarget, setRestoreTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [backupData, settingsData] = await Promise.all([
api.adminBackups(),
api.adminBackupSettings(),
]);
setBackups(backupData.backups || []);
setSettings({ ...DEFAULT_SETTINGS, ...settingsData });
} catch (err) {
toast.error(err.message || 'Failed to load backups.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const latest = backups[0];
const setSchedule = (key, value) => setSettings(prev => ({ ...prev, [key]: value }));
async function handleCreate() {
setBusy('create');
try {
await api.createAdminBackup();
toast.success('Backup created.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to create backup.');
} finally {
setBusy('');
}
}
async function handleDownload(backup) {
setBusy(`download:${backup.id}`);
try {
const { blob, filename } = await api.downloadAdminBackup(backup.id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || backup.id;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
toast.error(err.message || 'Failed to download backup.');
} finally {
setBusy('');
}
}
async function handleImport(e) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setBusy('import');
try {
await api.importAdminBackup(file);
toast.success('Backup imported.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to import backup.');
} finally {
setBusy('');
}
}
async function handleRestore() {
if (!restoreTarget) return;
setBusy(`restore:${restoreTarget.id}`);
try {
const result = await api.restoreAdminBackup(restoreTarget.id);
toast.success(`Database restored. Pre-restore backup: ${result.pre_restore_backup}`);
setRestoreTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to restore backup.');
} finally {
setBusy('');
}
}
async function handleDelete() {
if (!deleteTarget) return;
setBusy(`delete:${deleteTarget.id}`);
try {
await api.deleteAdminBackup(deleteTarget.id);
toast.success('Backup deleted.');
setDeleteTarget(null);
await load();
} catch (err) {
toast.error(err.message || 'Failed to delete backup.');
} finally {
setBusy('');
}
}
async function handleSaveSettings() {
setBusy('settings');
try {
const saved = await api.saveAdminBackupSettings({
enabled: !!settings.enabled,
frequency: settings.frequency,
time: settings.time,
retention_count: parseInt(settings.retention_count, 10) || 14,
});
setSettings({ ...DEFAULT_SETTINGS, ...saved });
toast.success('Backup schedule saved.');
} catch (err) {
toast.error(err.message || 'Failed to save backup schedule.');
} finally {
setBusy('');
}
}
async function handleRunScheduledNow() {
setBusy('run-scheduled');
try {
await api.runScheduledBackupNow();
toast.success('Scheduled backup created.');
await load();
} catch (err) {
toast.error(err.message || 'Failed to run scheduled backup.');
} finally {
setBusy('');
}
}
if (loading) {
return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading backups</CardContent></Card>;
}
return (
<>
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Database Backups</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Admin-only SQLite backup, import, download, restore, and schedule controls.
</p>
</div>
<Badge className={settings.last_error ? 'bg-red-500/15 text-red-400 border-red-500/20' : 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'}>
{settings.last_error ? 'Attention' : 'Ready'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-3 md:grid-cols-4">
{[
['Managed backups', backups.length],
['Latest backup', latest ? formatDateTime(latest.modified_at) : '—'],
['Latest size', latest ? fmtBytes(latest.size_bytes) : '—'],
['Scheduled', settings.enabled ? `${settings.frequency} ${settings.time}` : 'Disabled'],
].map(([label, value]) => (
<div key={label} className="rounded-lg border border-border bg-muted/20 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="text-sm font-medium mt-1 truncate">{value}</p>
</div>
))}
</div>
{settings.last_error && (
<div className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{settings.last_error}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button onClick={handleCreate} disabled={!!busy}>
<Database className="h-4 w-4" />
{busy === 'create' ? 'Creating…' : 'Create Backup'}
</Button>
<Button asChild variant="outline" disabled={!!busy}>
<label className={cn('cursor-pointer', busy && 'pointer-events-none opacity-50')}>
<Upload className="h-4 w-4" />
{busy === 'import' ? 'Importing…' : 'Import Backup'}
<input
type="file"
accept=".sqlite,.db,application/octet-stream,application/x-sqlite3,application/vnd.sqlite3"
onChange={handleImport}
className="sr-only"
disabled={!!busy}
/>
</label>
</Button>
<Button variant="outline" onClick={load} disabled={!!busy}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="min-w-[860px] w-full text-sm">
<thead className="bg-muted/40">
<tr className="border-b border-border">
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Backup</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Type</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Modified</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Size</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Checksum</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{backups.map(backup => (
<tr key={backup.id} className="border-b border-border last:border-0 hover:bg-muted/25">
<td className="px-4 py-3 font-mono text-xs max-w-[260px] truncate" title={backup.id}>{backup.id}</td>
<td className="px-4 py-3"><BackupTypeBadge type={backup.type} /></td>
<td className="px-4 py-3 text-muted-foreground">{formatDateTime(backup.modified_at)}</td>
<td className="px-4 py-3 font-mono">{fmtBytes(backup.size_bytes)}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[120px] truncate" title={backup.checksum}>
{backup.checksum || '—'}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button size="icon" variant="ghost" title="Download backup" onClick={() => handleDownload(backup)} disabled={!!busy}>
<Download className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title="Restore backup" onClick={() => setRestoreTarget(backup)} disabled={!!busy}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" title="Delete backup" onClick={() => setDeleteTarget(backup)} disabled={!!busy}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
{!backups.length && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No managed backups yet.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="border-t border-border pt-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<SectionHeading>Scheduled Backups</SectionHeading>
<p className="text-xs text-muted-foreground mt-1">
Scheduled runs create managed backups and only apply retention to scheduled backups.
</p>
</div>
<Toggle checked={!!settings.enabled} onChange={v => setSchedule('enabled', v)} label="Enable scheduled backups" />
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-1.5">
<Label>Frequency</Label>
<Select value={settings.frequency} onValueChange={v => setSchedule('frequency', v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Time</Label>
<Input type="time" value={settings.time} onChange={e => setSchedule('time', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Keep scheduled</Label>
<Input type="number" min="1" max="365" value={settings.retention_count} onChange={e => setSchedule('retention_count', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Next run</Label>
<div className="h-9 rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground truncate">
{formatDateTime(settings.next_run_at)}
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="text-sm text-muted-foreground">Last run: <span className="text-foreground">{formatDateTime(settings.last_run_at)}</span></div>
<div className="text-sm text-muted-foreground">Last error: <span className="text-foreground">{settings.last_error || '—'}</span></div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleRunScheduledNow} disabled={!!busy}>
<Play className="h-4 w-4" />
{busy === 'run-scheduled' ? 'Running…' : 'Run Scheduled Now'}
</Button>
<Button onClick={handleSaveSettings} disabled={!!busy}>
{busy === 'settings' ? 'Saving…' : 'Save Schedule'}
</Button>
</div>
</div>
</CardContent>
</Card>
<AlertDialog open={!!restoreTarget} onOpenChange={(open) => { if (!open) setRestoreTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore this database backup?</AlertDialogTitle>
<AlertDialogDescription>
This replaces the live database with <span className="font-mono">{restoreTarget?.id}</span>.
A pre-restore backup will be created first. Run this during a quiet maintenance window.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy.startsWith('restore:')}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleRestore}
disabled={busy.startsWith('restore:')}
>
{busy.startsWith('restore:') ? 'Restoring…' : 'Restore Database'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this backup?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes <span className="font-mono">{deleteTarget?.id}</span>.
The live database is not affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy.startsWith('delete:')}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={busy.startsWith('delete:')}
>
{busy.startsWith('delete:') ? 'Deleting…' : 'Delete Backup'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,185 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Play, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { FieldRow, Toggle, formatDateTime } from './adminShared';
export default function CleanupPanel() {
const [status, setStatus] = useState(null);
const [form, setForm] = useState({
import_sessions_enabled: true,
temp_exports_enabled: true,
temp_export_max_age_hours: 2,
backup_partials_enabled: true,
import_history_enabled: false,
import_history_max_age_days: 365,
});
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await api.adminCleanup();
setStatus(data);
if (data.settings) setForm(data.settings);
} catch (err) {
toast.error(err.message || 'Failed to load cleanup settings.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
async function handleSave() {
setSaving(true);
try {
const next = await api.saveAdminCleanup(form);
if (next) setForm(next);
toast.success('Cleanup settings saved.');
} catch (err) {
toast.error(err.message || 'Failed to save cleanup settings.');
} finally {
setSaving(false);
}
}
async function handleRunNow() {
setRunning(true);
try {
const result = await api.runAdminCleanup();
setStatus(prev => ({
...prev,
last_run_at: result.ran_at,
last_result: result.tasks,
}));
toast.success('Cleanup tasks completed.');
} catch (err) {
toast.error(err.message || 'Cleanup run failed.');
} finally {
setRunning(false);
}
}
function resultLine(label, task, countKey) {
if (!task || task[countKey] == null) return null;
return `${label}: ${task[countKey]}`;
}
const resultLines = status?.last_result ? [
resultLine('Import sessions pruned', status.last_result.import_sessions, 'pruned'),
resultLine('Temp export files removed', status.last_result.temp_export_files, 'removed'),
resultLine('Backup partials removed', status.last_result.backup_partials, 'removed'),
resultLine('Import history rows pruned', status.last_result.import_history, 'pruned'),
].filter(Boolean) : [];
if (loading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Loading cleanup settings
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Wrench className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle>Cleanup / Maintenance</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Automatic daily cleanup: expired import sessions, stale export temp files, orphaned backup partials. Runs at 6:00 AM.
</p>
</div>
</div>
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 shrink-0">Auto</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border border-border bg-muted/20 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Last Run</p>
<p className="text-sm font-medium mt-1">{status?.last_run_at ? formatDateTime(status.last_run_at) : '—'}</p>
</div>
<div className="rounded-lg border border-border bg-muted/20 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Last Result</p>
{resultLines.length > 0 ? (
<ul className="mt-1 space-y-0.5">
{resultLines.map(line => (
<li key={line} className="text-xs text-muted-foreground">{line}</li>
))}
</ul>
) : (
<p className="text-sm font-medium mt-1 text-muted-foreground/60">No runs recorded yet</p>
)}
</div>
</div>
<div className="space-y-3">
<p className="text-sm font-medium">Task Settings</p>
{[
['import_sessions_enabled', 'Prune expired import sessions (24h TTL)'],
['temp_exports_enabled', 'Remove stale SQLite export temp files'],
['backup_partials_enabled', 'Remove orphaned backup .partial / .upload files'],
].map(([key, label]) => (
<FieldRow key={key} label={label}>
<Toggle checked={!!form[key]} onChange={v => set(key, v)} label={label} />
</FieldRow>
))}
<FieldRow label="Temp file max age (hours, 172)">
<Input
type="number" min="1" max="72"
value={form.temp_export_max_age_hours}
onChange={e => set('temp_export_max_age_hours', parseInt(e.target.value, 10) || 2)}
disabled={!form.temp_exports_enabled}
className="max-w-[120px] h-8 text-sm"
/>
</FieldRow>
<FieldRow label="Trim import history rows (off by default)">
<Toggle checked={!!form.import_history_enabled} onChange={v => set('import_history_enabled', v)} label="Trim import history rows" />
</FieldRow>
{form.import_history_enabled && (
<>
<FieldRow label="Import history max age (days, 303650)">
<Input
type="number" min="30" max="3650"
value={form.import_history_max_age_days}
onChange={e => set('import_history_max_age_days', parseInt(e.target.value, 10) || 365)}
className="max-w-[120px] h-8 text-sm"
/>
</FieldRow>
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 text-sm text-amber-600 dark:text-amber-400">
<strong>Warning:</strong> Import history trimming permanently deletes audit records older than {form.import_history_max_age_days} days. This cannot be undone.
</div>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<Button onClick={handleSave} disabled={saving || running}>
{saving ? 'Saving…' : 'Save Settings'}
</Button>
<Button variant="outline" onClick={handleRunNow} disabled={saving || running}>
<Play className="h-4 w-4" />
{running ? 'Running…' : 'Run Cleanup Now'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,207 @@
import React, { useState, useEffect } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { SectionHeading, FieldRow, Toggle } from './adminShared';
export default function EmailNotifCard() {
const DEFAULTS = {
enabled: false,
sender_name: '', sender_address: '',
smtp_host: '', smtp_port: '587', smtp_encryption: 'starttls',
smtp_self_signed: false,
smtp_username: '', smtp_password: '',
allow_user_config: false,
global_recipient: '',
};
const [cfg, setCfg] = useState(DEFAULTS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showPw, setShowPw] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [testing, setTesting] = useState(false);
useEffect(() => {
api.notifAdmin()
.then(d => setCfg({ ...DEFAULTS, ...d }))
.catch(() => {})
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const set = (k, v) => setCfg(p => ({ ...p, [k]: v }));
const handleSave = async () => {
setSaving(true);
try {
await api.saveNotifAdmin(cfg);
toast.success('Email settings saved.');
} catch (err) {
toast.error(err.message || 'Failed to save.');
} finally {
setSaving(false);
}
};
const handleTest = async () => {
if (!testEmail) { toast.error('Enter a recipient email.'); return; }
setTesting(true);
try {
await api.testEmail({ to: testEmail });
toast.success('Test email sent.');
} catch (err) {
toast.error(err.message || 'Failed to send test email.');
} finally {
setTesting(false);
}
};
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading</CardContent></Card>;
return (
<Card>
<CardHeader className="pb-4">
<CardTitle>Email Notifications</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Enable email notifications</p>
<p className="text-xs text-muted-foreground">Configure SMTP to send bill reminders</p>
</div>
<Toggle checked={cfg.enabled} onChange={v => set('enabled', v)} label="Enable email notifications" />
</div>
<div className="border-t border-border" />
<div className="space-y-4">
<SectionHeading>Sender</SectionHeading>
<FieldRow label="Sender name">
<Input value={cfg.sender_name} onChange={e => set('sender_name', e.target.value)} placeholder="BillTracker" />
</FieldRow>
<FieldRow label="Sender address">
<Input value={cfg.sender_address} onChange={e => set('sender_address', e.target.value)} placeholder="no-reply@example.com" type="email" />
</FieldRow>
</div>
<div className="border-t border-border" />
<div className="space-y-4">
<SectionHeading>SMTP Server</SectionHeading>
<FieldRow label="SMTP host">
<Input value={cfg.smtp_host} onChange={e => set('smtp_host', e.target.value)} placeholder="smtp.example.com" />
</FieldRow>
<FieldRow label="Port">
<Input value={cfg.smtp_port} onChange={e => set('smtp_port', e.target.value)} placeholder="587" type="number" className="w-28" />
</FieldRow>
<FieldRow label="Encryption">
<Select value={cfg.smtp_encryption} onValueChange={v => set('smtp_encryption', v)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</FieldRow>
<FieldRow label="Allow self-signed cert">
<div className="flex items-center h-9">
<input
type="checkbox"
id="self-signed"
checked={cfg.smtp_self_signed}
onChange={e => set('smtp_self_signed', e.target.checked)}
className="h-4 w-4 rounded border-input bg-input accent-primary"
/>
<label htmlFor="self-signed" className="ml-2 text-sm text-muted-foreground">
Accept self-signed certificates
</label>
</div>
</FieldRow>
<FieldRow label="SMTP username">
<Input value={cfg.smtp_username} onChange={e => set('smtp_username', e.target.value)} placeholder="user@example.com" />
</FieldRow>
<FieldRow label="SMTP password">
<div className="relative">
<Input
type={showPw ? 'text' : 'password'}
value={cfg.smtp_password}
onChange={e => set('smtp_password', e.target.value)}
placeholder="••••••••"
className="pr-9"
/>
<button
type="button"
onClick={() => setShowPw(p => !p)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</FieldRow>
</div>
<div className="border-t border-border" />
<div className="space-y-4">
<SectionHeading>User Access</SectionHeading>
<FieldRow label="Allow user config">
<div className="flex items-center h-9">
<input
type="checkbox"
id="allow-user"
checked={cfg.allow_user_config}
onChange={e => set('allow_user_config', e.target.checked)}
className="h-4 w-4 rounded border-input bg-input accent-primary"
/>
<label htmlFor="allow-user" className="ml-2 text-sm text-muted-foreground">
Let users configure their own notification preferences
</label>
</div>
</FieldRow>
<FieldRow label="Global recipient">
<Input
value={cfg.global_recipient}
onChange={e => set('global_recipient', e.target.value)}
placeholder="recipient@example.com"
type="email"
/>
</FieldRow>
</div>
<div className="border-t border-border" />
<div className="space-y-4">
<SectionHeading>Test Email</SectionHeading>
<FieldRow label="Send test to">
<div className="flex gap-2">
<Input
value={testEmail}
onChange={e => setTestEmail(e.target.value)}
placeholder="you@example.com"
type="email"
/>
<Button variant="outline" onClick={handleTest} disabled={testing} className="shrink-0">
{testing ? 'Sending…' : 'Send Test Email'}
</Button>
</div>
</FieldRow>
</div>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,135 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
export default function LoginModeCard({ users }) {
const [modeData, setModeData] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [selectedUser, setSelectedUser] = useState('');
const [confirmSingle, setConfirmSingle] = useState(false);
const [pendingUserId, setPendingUserId] = useState(null);
useEffect(() => {
api.authModeConfig()
.then(d => { setModeData(d); setSelectedUser(d.default_user_id?.toString() || ''); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const doSetMode = async (mode, userId) => {
setSaving(true);
try {
await api.setAuthMode({
auth_mode: mode,
default_user_id: mode === 'single' ? parseInt(userId, 10) : null,
});
const d = await api.authModeConfig();
setModeData(d);
toast.success(mode === 'single' ? 'Single-user mode enabled.' : 'Login requirement restored.');
} catch (err) {
toast.error(err.message || 'Failed to update auth mode.');
} finally {
setSaving(false);
}
};
const handleRequestSingle = () => {
if (!selectedUser) { toast.error('Select a user first.'); return; }
setPendingUserId(selectedUser);
setConfirmSingle(true);
};
const handleConfirmSingle = () => {
setConfirmSingle(false);
doSetMode('single', pendingUserId);
};
if (loading) return <Card><CardContent className="py-8 text-center text-muted-foreground text-sm">Loading</CardContent></Card>;
const isMulti = !modeData || modeData.auth_mode === 'multi';
const activeUser = users?.find(u => u.id === modeData?.default_user_id);
const selectedUsername = users?.find(u => u.id.toString() === selectedUser)?.username ?? selectedUser;
return (
<>
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle>Login Mode</CardTitle>
<Badge className={isMulti ? 'bg-sky-500/15 text-sky-400 border-sky-500/20' : 'bg-amber-500/15 text-amber-400 border-amber-500/20'}>
{isMulti ? 'Multi-user' : 'Single-user'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isMulti ? (
<>
<p className="text-sm text-muted-foreground">
Single-user mode bypasses the login screen and automatically signs in as the selected user.
</p>
<div className="space-y-1.5">
<Label>Default user</Label>
<Select value={selectedUser} onValueChange={setSelectedUser}>
<SelectTrigger>
<SelectValue placeholder="Select a user…" />
</SelectTrigger>
<SelectContent>
{(users || []).filter(u => u.role === 'user').map(u => (
<SelectItem key={u.id} value={u.id.toString()}>{u.username}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleRequestSingle} disabled={saving} className="w-full">
{saving ? 'Enabling…' : 'Enable Single-User Mode'}
</Button>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
Currently auto-signing in as{' '}
<span className="font-medium text-foreground">{activeUser?.username ?? '—'}</span>.
Restoring login requirement will require all users to sign in manually.
</p>
<Button variant="outline" onClick={() => doSetMode('multi', null)} disabled={saving} className="w-full">
{saving ? 'Restoring…' : 'Restore Login Requirement'}
</Button>
</>
)}
</CardContent>
</Card>
<AlertDialog open={confirmSingle} onOpenChange={setConfirmSingle}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Enable Single-User Mode?</AlertDialogTitle>
<AlertDialogDescription>
Anyone who opens the app will be automatically signed in as{' '}
<span className="font-medium text-foreground">{selectedUsername}</span>.
The admin login still requires a password.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSingle}>
Enable Single-User Mode
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,160 @@
import React, { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
export default function OnboardingWizard({ onComplete }) {
const [step, setStep] = useState(0);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleCreate = async (e) => {
e.preventDefault();
setError('');
let validationError = '';
if (password !== confirm) {
validationError = 'Passwords do not match.';
} else if (password.length < 8) {
validationError = 'Password must be at least 8 characters.';
}
if (validationError) {
setError(validationError);
toast.error(validationError);
return;
}
setLoading(true);
try {
await api.createUser({ username, password });
toast.success('User created successfully.');
onComplete();
} catch (err) {
const errorMessage = err.message || 'Failed to create user.';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)] px-4 py-12">
<div className="w-full max-w-md">
<div className="flex justify-center gap-2 mb-8">
{[0, 1].map(i => (
<span
key={i}
className={`h-2 rounded-full transition-all ${i === step ? 'w-6 bg-primary' : 'w-2 bg-border'}`}
/>
))}
</div>
{step === 0 && (
<Card>
<CardHeader>
<CardTitle className="text-xl">Welcome, Administrator</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Before creating your first user, please understand what your admin account can and cannot do.
</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2.5">
{[
{ can: true, text: 'Create and manage user accounts' },
{ can: true, text: 'Reset user passwords' },
{ can: true, text: 'Configure email notifications' },
{ can: true, text: 'Toggle single-user / multi-user mode' },
{ can: false, text: 'Cannot view bills or financial data' },
{ can: false, text: 'Cannot access user settings or history' },
].map(({ can, text }) => (
<div key={text} className="flex items-center gap-3 text-sm">
<span className={`shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${can ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}`}>
{can ? '✓' : '✗'}
</span>
<span className={can ? 'text-foreground' : 'text-muted-foreground'}>{text}</span>
</div>
))}
</div>
<div className="pt-4">
<Button className="w-full" onClick={() => setStep(1)}>
Got it, create my user account
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="text-xl">Create first user</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
This account will be used to access the bill tracker.
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ob-username">Username</Label>
<Input
id="ob-username"
placeholder="username"
value={username}
onChange={e => setUsername(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-password">Password</Label>
<Input
id="ob-password"
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-confirm">Confirm password</Label>
<Input
id="ob-confirm"
type="password"
placeholder="Confirm password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
/>
</div>
{error && (
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setStep(0)}>
<ChevronLeft className="h-4 w-4" />
Back
</Button>
<Button type="submit" className="flex-1" disabled={loading} aria-busy={loading}>
{loading ? 'Creating…' : 'Create User'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
export default function UsersTable({ users, onRefresh, currentUser }) {
const [resetForms, setResetForms] = useState({});
const [deleting, setDeleting] = useState(null);
const [resetting, setResetting] = useState(null);
const [roleUpdating, setRoleUpdating] = useState(null);
const [activeUpdating, setActiveUpdating] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const setReset = (id, v) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } }));
const getForm = (id) => resetForms[id] || { pw: '', open: false };
const handleReset = async (user) => {
const form = getForm(user.id);
if (!form.pw || form.pw.length < 8) { toast.error('Password must be at least 8 characters.'); return; }
setResetting(user.id);
try {
await api.resetPassword(user.id, { password: form.pw });
toast.success(`Password reset for ${user.username}.`);
setReset(user.id, { pw: '', open: false });
} catch (err) {
toast.error(err.message || 'Failed to reset password.');
} finally {
setResetting(null);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(deleteTarget.id);
try {
await api.deleteUser(deleteTarget.id);
toast.success(`User ${deleteTarget.username} deleted.`);
setDeleteTarget(null);
onRefresh();
} catch (err) {
toast.error(err.message || 'Failed to delete user.');
} finally {
setDeleting(null);
}
};
const handleRoleChange = async (user, role) => {
if (user.role === role) return;
setRoleUpdating(user.id);
try {
await api.updateUserRole(user.id, { role });
toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`);
onRefresh();
} catch (err) {
toast.error(err.message || 'Failed to update user role.');
} finally {
setRoleUpdating(null);
}
};
const handleActiveChange = async (user, active) => {
setActiveUpdating(user.id);
try {
await api.updateUserActive(user.id, { active });
toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`);
onRefresh();
} catch (err) {
toast.error(err.message || 'Failed to update user status.');
} finally {
setActiveUpdating(null);
}
};
return (
<>
<Card>
<CardHeader className="pb-4">
<CardTitle>Users</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Username</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Role</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Status</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Password</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Reset Password</th>
<th className="px-6 py-3" />
</tr>
</thead>
<tbody>
{(users || []).map(user => {
const form = getForm(user.id);
const isSelf = currentUser?.id === user.id;
return (
<tr key={user.id} className="group border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-6 py-3 font-medium">
<div className="flex items-center gap-2">
<span>{user.username}</span>
{user.is_default_admin && <Badge variant="secondary">default admin</Badge>}
</div>
</td>
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>{user.role}</Badge>
<select
value={user.role}
disabled={isSelf || roleUpdating === user.id}
onChange={e => handleRoleChange(user, e.target.value)}
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 change your own role' : 'Change user role'}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
</td>
<td className="px-6 py-3">
<select
value={user.active === false || user.active === 0 ? 'inactive' : 'active'}
disabled={isSelf || activeUpdating === user.id}
onChange={e => handleActiveChange(user, e.target.value === 'active')}
className="h-8 rounded-md border border-input bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
title={isSelf ? 'You cannot deactivate your own account' : 'Change user status'}
>
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
</td>
<td className="px-6 py-3">
{user.must_change_password
? <Badge variant="due_soon">Temporary</Badge>
: <span className="text-muted-foreground">Set</span>
}
</td>
<td className="px-6 py-3">
{form.open ? (
<div className="flex items-center gap-2">
<Input
type="password"
placeholder="New password"
value={form.pw || ''}
onChange={e => setReset(user.id, { pw: e.target.value })}
className="h-8 text-sm w-36"
/>
<Button size="sm" onClick={() => handleReset(user)} disabled={resetting === user.id}>
{resetting === user.id ? '…' : 'Save'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setReset(user.id, { open: false, pw: '' })}>
Cancel
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setReset(user.id, { open: true })}>Reset</Button>
)}
</td>
<td className="px-6 py-3 text-right">
{!isSelf && (
<Button size="sm" variant="destructive" onClick={() => setDeleteTarget(user)} disabled={deleting === user.id}>
{deleting === user.id ? '…' : 'Delete'}
</Button>
)}
</td>
</tr>
);
})}
{!users?.length && (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
<AlertDialogDescription>
This is permanent in 2026. The user account and all user-owned data will be deleted, including bills,
payments, categories, monthly state, monthly starting amounts, imports, import history, and sessions.
This cannot be undone from BillTracker.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={!!deleting}
>
{deleting ? 'Deleting…' : 'Delete User'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
export function SectionHeading({ children }) {
return <h2 className="text-base font-semibold text-foreground">{children}</h2>;
}
export function FieldRow({ label, children }) {
return (
<div className="grid gap-2 lg:grid-cols-[200px_1fr] lg:items-center lg:gap-4">
<Label className="text-muted-foreground lg:text-right">{label}</Label>
{children}
</div>
);
}
export function Toggle({ checked, onChange, label, disabled = false }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${checked ? 'bg-primary' : 'bg-input'}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform ${checked ? 'translate-x-4' : 'translate-x-0'}`} />
{label && <span className="sr-only">{label}</span>}
</button>
);
}
export function formatDateTime(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
export function BackupTypeBadge({ type }) {
const cls = {
manual: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
scheduled: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
imported: 'bg-violet-500/15 text-violet-400 border-violet-500/20',
'pre-restore': 'bg-amber-500/15 text-amber-400 border-amber-500/20',
}[type] || 'bg-muted text-muted-foreground border-border';
return <Badge className={cls}>{type || 'backup'}</Badge>;
}

View File

@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { toast } from 'sonner';
import { Database, Download, FileSpreadsheet, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { SectionCard } from './dataShared';
const USER_EXPORTS_AVAILABLE = true;
function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
const [loading, setLoading] = useState(false);
const handleDownload = async () => {
setLoading(true);
try {
const res = await fetch(endpoint, { credentials: 'include' });
if (!res.ok) {
let data = {};
try { data = await res.json(); } catch {}
throw new Error(data.error || `HTTP ${res.status}`);
}
const disposition = res.headers.get('Content-Disposition');
const match = disposition?.match(/filename="?([^"]+)"?/i);
const name = match ? match[1] : filename;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
toast.success(`${title} downloaded.`);
} catch (err) {
toast.error(err.message || 'Download failed.');
} finally {
setLoading(false);
}
};
const disabled = !USER_EXPORTS_AVAILABLE || loading;
return (
<div className="px-6 py-5 flex items-start justify-between gap-6">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="mt-0.5 h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<p className="text-sm font-medium">{title}</p>
{!USER_EXPORTS_AVAILABLE && (
<span className="inline-flex items-center rounded-full bg-muted border border-border px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Coming soon
</span>
)}
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="shrink-0 pt-0.5">
<Button size="sm" variant="outline" disabled={disabled}
onClick={USER_EXPORTS_AVAILABLE ? handleDownload : undefined}>
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Downloading</>
: <><Download className="h-3.5 w-3.5 mr-1.5" />{USER_EXPORTS_AVAILABLE ? 'Download' : 'Not Available Yet'}</>}
</Button>
</div>
</div>
);
}
export default function DownloadMyDataSection() {
return (
<SectionCard
title="Download My Data"
subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup."
>
<ExportCard icon={Database} title="SQLite Data Export"
description="Download a portable SQLite database containing your bill tracker data."
filename="bill-tracker-user-export.sqlite" endpoint="/api/export/user-db" />
<ExportCard icon={FileSpreadsheet} title="Excel Databook"
description="Download an Excel workbook with sheets for bills, payments, categories, monthly state, and summary data."
filename="bill-tracker-databook.xlsx" endpoint="/api/export/user-excel" />
<div className="px-6 py-3 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/40 flex items-start gap-2.5 mx-6 mt-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<p className="text-xs text-amber-700 dark:text-amber-300">Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.</p>
</div>
<div className="px-6 py-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's included</p>
<ul className="space-y-1.5">
{['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => (
<li key={i} className="flex items-center gap-2 text-xs text-foreground/80">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />{i}
</li>
))}
</ul>
</div>
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's not included</p>
<ul className="space-y-1.5">
{['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
<li key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<XCircle className="h-3.5 w-3.5 text-muted-foreground/40 shrink-0" />{i}
</li>
))}
</ul>
</div>
</div>
</SectionCard>
);
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import { RefreshCw, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { SectionCard, fmt } from './dataShared';
export default function ImportHistorySection({ history, loading, onRefresh }) {
if (loading) {
return (
<SectionCard title="Import History">
<div className="px-6 py-6 text-sm text-muted-foreground">Loading</div>
</SectionCard>
);
}
const rows = history ?? [];
return (
<SectionCard title="Import History">
<div className="px-6 py-4 flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`}
</p>
<Button size="sm" variant="ghost" onClick={onRefresh} className="h-7 text-xs gap-1.5">
<RefreshCw className="h-3 w-3" />Refresh
</Button>
</div>
{rows.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 text-muted-foreground">
{['Date','File','Sheet','Parsed','Created','Updated','Skipped','Errors'].map(h => (
<th key={h} className="px-4 py-2 text-left font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{rows.map(r => (
<tr key={r.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" />{fmt(r.imported_at)}</span>
</td>
<td className="px-4 py-2 text-muted-foreground">{r.source_filename || '—'}</td>
<td className="px-4 py-2 text-muted-foreground">{r.sheet_name || '—'}</td>
<td className="px-4 py-2 tabular-nums">{r.rows_parsed}</td>
<td className="px-4 py-2 tabular-nums text-emerald-600">{r.rows_created}</td>
<td className="px-4 py-2 tabular-nums text-blue-600">{r.rows_updated}</td>
<td className="px-4 py-2 tabular-nums text-muted-foreground">{r.rows_skipped}</td>
<td className="px-4 py-2 tabular-nums text-red-500">{r.rows_errored}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SectionCard>
);
}

View File

@ -0,0 +1,217 @@
import React, { useState, useRef } from 'react';
import { toast } from 'sonner';
import { Database, Upload, AlertTriangle, Loader2 } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { SectionCard, CountPill, fmt, importErrorState } from './dataShared';
export default function ImportMyDataSection({ onHistoryRefresh }) {
const fileRef = useRef(null);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null });
const [confirmOpen, setConfirmOpen] = useState(false);
const reset = () => {
setFile(null);
setPreview({ status: 'idle', data: null, error: null });
setApplyState({ status: 'idle', result: null, error: null });
if (fileRef.current) fileRef.current.value = '';
};
const handlePreview = async () => {
if (!file) {
toast.error('Choose a SQLite data export first.');
return;
}
setPreview({ status: 'loading', data: null, error: null });
setApplyState({ status: 'idle', result: null, error: null });
try {
const data = await api.previewUserDbImport(file);
setPreview({ status: 'ready', data, error: null });
toast.success('SQLite export preview ready.');
} catch (err) {
setPreview({ status: 'error', data: null, error: importErrorState(err, 'SQLite import preview failed.') });
toast.error(err.message || 'SQLite import preview failed.');
}
};
const handleApply = () => {
if (!preview.data?.import_session_id) return;
setConfirmOpen(true);
};
const handleConfirmImport = async () => {
setConfirmOpen(false);
setApplyState({ status: 'loading', result: null, error: null });
try {
const result = await api.applyUserDbImport({
import_session_id: preview.data.import_session_id,
options: { overwrite: false },
});
setApplyState({ status: 'done', result, error: null });
toast.success('SQLite data import applied.');
onHistoryRefresh?.();
} catch (err) {
setApplyState({ status: 'error', result: null, error: importErrorState(err, 'SQLite import apply failed.') });
toast.error(err.message || 'SQLite import apply failed.');
}
};
const counts = preview.data?.counts || {};
const summary = preview.data?.summary || {};
return (
<>
<SectionCard title="Import My Data Export"
subtitle="Restore data from a SQLite export created by this app for your account.">
<div className="px-6 py-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<div className="flex items-start gap-3">
<Database className="mt-0.5 h-5 w-5 text-primary shrink-0" />
<div>
<p className="text-sm font-medium">Import a SQLite data export created by this app.</p>
<p className="mt-1 text-xs text-muted-foreground">
This is not a full system restore. Existing records are skipped by default, and admin/system data is never imported.
</p>
</div>
</div>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="flex-1 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">SQLite export file</span>
<Input
ref={fileRef}
type="file"
accept=".sqlite,.db,application/octet-stream,application/x-sqlite3,application/vnd.sqlite3"
onChange={e => {
setFile(e.target.files?.[0] || null);
setPreview({ status: 'idle', data: null, error: null });
setApplyState({ status: 'idle', result: null, error: null });
}}
/>
</label>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={reset}>
Clear
</Button>
<Button size="sm" type="button" disabled={!file || preview.status === 'loading'} onClick={handlePreview}>
{preview.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Previewing</>
: <><Upload className="h-3.5 w-3.5 mr-1.5" />Preview</>}
</Button>
</div>
</div>
{preview.status === 'error' && (
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" />
{preview.error?.message || 'SQLite import preview failed.'}
{preview.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{preview.error.details.map((d, i) => (
<li key={i}>{d.message || d.table || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
{preview.status === 'ready' && preview.data && (
<div className="mt-4 space-y-4">
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">Preview ready</p>
<p className="mt-1 text-xs text-muted-foreground">
Exported {fmt(preview.data.metadata?.exported_at)} · {preview.data.source_filename || 'SQLite export'}
</p>
</div>
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
User data only
</span>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<CountPill label="Bills" value={counts.bills} />
<CountPill label="Categories" value={counts.categories} />
<CountPill label="Payments" value={counts.payments} />
<CountPill label="Monthly" value={counts.monthly_bill_state} />
<CountPill label="Notes" value={counts.notes} />
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{Object.entries(summary).filter(([, v]) => v && typeof v === 'object').map(([key, value]) => (
<div key={key} className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs">
<p className="font-semibold capitalize">{key.replace(/_/g, ' ')}</p>
<p className="mt-1 text-muted-foreground">
create {value.create || 0} · skip {value.skip || 0} · conflict {value.conflict || 0}
</p>
</div>
))}
</div>
{preview.data.warnings?.length > 0 && (
<div className="mt-4 space-y-1">
{preview.data.warnings.map((warning, i) => (
<p key={i} className="text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="mr-1 inline h-3.5 w-3.5" />{warning}
</p>
))}
</div>
)}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Review the preview before applying. Nothing is imported until you confirm.</p>
<Button size="sm" type="button" disabled={applyState.status === 'loading'} onClick={handleApply}>
{applyState.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Applying</>
: 'Apply Import'}
</Button>
</div>
</div>
)}
{applyState.status === 'done' && applyState.result && (
<div className="mt-4 rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
<p className="text-sm font-medium text-emerald-600">SQLite import applied</p>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
<CountPill label="Created" value={applyState.result.rows_created} />
<CountPill label="Skipped" value={applyState.result.rows_skipped} />
<CountPill label="Conflicts" value={applyState.result.rows_conflicted} />
<CountPill label="Errors" value={applyState.result.rows_errored} />
</div>
</div>
)}
{applyState.status === 'error' && (
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1.5" />{applyState.error?.message || 'SQLite import apply failed.'}
</div>
)}
</div>
</SectionCard>
{/* Import confirmation dialog */}
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Import SQLite data export?</AlertDialogTitle>
<AlertDialogDescription>
Import this SQLite data export into your account? Existing records will be skipped by default.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmImport}>
Confirm Import
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,568 @@
import React, { useState, useEffect, useRef } from 'react';
import { toast } from 'sonner';
import {
Upload, Loader2, CheckCircle2, CheckCheck, AlertTriangle, Plus, FileText,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SectionCard } from './dataShared';
const CSV_MAPPING_FIELDS = [
'posted_date',
'amount',
'debit_amount',
'credit_amount',
'description',
'payee',
'memo',
'category',
'account',
'transaction_id',
'transaction_type',
'currency',
'transacted_at',
];
function compactMapping(mapping) {
return Object.fromEntries(
Object.entries(mapping || {}).filter(([, value]) => value),
);
}
function canCommitCsvMapping(mapping) {
return !!mapping?.posted_date && !!(mapping.amount || mapping.debit_amount || mapping.credit_amount);
}
const CSV_IMPORT_STEPS = ['Upload', 'Preview', 'Map', 'Commit', 'Results'];
function csvImportStepIndex(preview, mapping, commitState) {
if (commitState.status === 'done') return 4;
if (commitState.status === 'loading') return 3;
if (preview.status === 'ready') return canCommitCsvMapping(mapping) ? 3 : 2;
if (preview.status === 'loading' || preview.status === 'error') return 1;
return 0;
}
function CsvImportStepper({ activeIndex }) {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{CSV_IMPORT_STEPS.map((step, index) => {
const complete = index < activeIndex;
const active = index === activeIndex;
return (
<div
key={step}
className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors',
complete && 'border-emerald-500/30 bg-emerald-500/5 text-emerald-600',
active && 'border-primary/40 bg-primary/5 text-foreground',
!complete && !active && 'border-border/60 bg-muted/20 text-muted-foreground',
)}
>
<span className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold tabular-nums',
complete && 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600',
active && 'border-primary/50 bg-primary/10 text-primary',
!complete && !active && 'border-border text-muted-foreground',
)}>
{complete ? <CheckCircle2 className="h-3 w-3" /> : index + 1}
</span>
<span className="truncate font-medium">{step}</span>
</div>
);
})}
</div>
);
}
function csvFieldRequirement(field, mapping) {
if (field === 'posted_date') return 'Required';
if (['amount', 'debit_amount', 'credit_amount'].includes(field)) {
return canCommitCsvMapping({ ...mapping, posted_date: mapping?.posted_date || '__date__' })
? 'Amount source'
: 'One required';
}
return 'Optional';
}
function csvFieldSamples(preview, header) {
if (!header) return [];
const values = [];
for (const row of preview?.sampleRows || []) {
const value = String(row?.[header] || '').trim();
if (value && !values.includes(value)) values.push(value);
if (values.length >= 3) break;
}
return values;
}
function CsvMappingRow({ field, label, preview, mapping, onChange, disabled = false }) {
const headers = preview?.headers || [];
const suggested = preview?.suggestedMapping?.[field] || '';
const current = mapping[field] || '';
const used = new Set(Object.entries(mapping)
.filter(([key, value]) => key !== field && value)
.map(([, value]) => value));
const requirement = csvFieldRequirement(field, mapping);
const missingRequired = (requirement === 'Required' || requirement === 'One required') && !current;
const samples = csvFieldSamples(preview, current);
const suggestedAvailable = suggested && suggested !== current && !used.has(suggested);
return (
<div className={cn(
'grid gap-3 border-b border-border/40 px-4 py-3 last:border-b-0 lg:grid-cols-[minmax(150px,0.8fr)_minmax(220px,1fr)_minmax(180px,1fr)]',
missingRequired && 'bg-destructive/5',
)}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{label}</p>
<span className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase',
requirement === 'Required' || requirement === 'One required'
? 'border-destructive/30 bg-destructive/10 text-destructive'
: requirement === 'Amount source'
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
{requirement}
</span>
</div>
<p className="mt-1 font-mono text-[11px] text-muted-foreground">{field}</p>
</div>
<div className="space-y-1.5">
<select
value={current}
onChange={e => onChange(field, e.target.value)}
disabled={disabled}
className={cn(
'h-9 w-full rounded-md border bg-background px-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-60',
missingRequired ? 'border-destructive/50' : 'border-input',
)}
>
<option value="">Not mapped</option>
{headers.map(header => (
<option key={header} value={header} disabled={used.has(header)}>
{header}{used.has(header) ? ' (assigned)' : ''}
</option>
))}
</select>
<div className="flex min-h-5 flex-wrap items-center gap-1.5">
{current && current === suggested && (
<span className="text-[11px] font-medium text-emerald-600">Suggested match</span>
)}
{suggestedAvailable && !disabled && (
<button
type="button"
onClick={() => onChange(field, suggested)}
className="rounded-full border border-border/70 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Use {suggested}
</button>
)}
{missingRequired && (
<span className="text-[11px] text-destructive">Needs a column</span>
)}
</div>
</div>
<div className="min-w-0">
{samples.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{samples.map(value => (
<span key={value} className="max-w-full truncate rounded border border-border/50 bg-muted/25 px-2 py-1 text-[11px] text-muted-foreground">
{value}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
{current ? 'No sample values' : 'Map a column to preview values'}
</p>
)}
</div>
</div>
);
}
function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSuggested, onClearMapping, disabled = false }) {
const mappingFields = CSV_MAPPING_FIELDS.filter(field => fields[field]);
const mappedCount = mappingFields.filter(field => mapping[field]).length;
const hasSuggestedMapping = Object.values(preview?.suggestedMapping || {}).some(Boolean);
const missingRequired = [
!mapping.posted_date ? 'Posted date' : null,
!(mapping.amount || mapping.debit_amount || mapping.credit_amount) ? 'Amount' : null,
].filter(Boolean);
return (
<div className="overflow-hidden rounded-lg border border-border/60">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 bg-muted/25 px-4 py-3">
<div>
<p className="text-sm font-medium">Column mapping</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{mappedCount} of {mappingFields.length} fields mapped
{missingRequired.length > 0 && ` · missing ${missingRequired.join(' and ')}`}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={onUseSuggested} disabled={disabled || !hasSuggestedMapping}>
Use Suggested
</Button>
<Button size="sm" variant="ghost" type="button" onClick={onClearMapping} disabled={disabled}>
Clear Mapping
</Button>
</div>
</div>
<div className="hidden border-b border-border/50 bg-muted/10 px-4 py-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground lg:grid lg:grid-cols-[minmax(150px,0.8fr)_minmax(220px,1fr)_minmax(180px,1fr)]">
<span>Field</span>
<span>CSV Column</span>
<span>Sample Values</span>
</div>
<div>
{mappingFields.map(field => (
<CsvMappingRow
key={field}
field={field}
label={fields[field]}
preview={preview}
mapping={mapping}
onChange={onMappingChange}
disabled={disabled}
/>
))}
</div>
</div>
);
}
function CsvSampleTable({ preview }) {
const headers = preview?.headers || [];
const sampleRows = preview?.sampleRows || [];
const visibleHeaders = headers.slice(0, 8);
const hiddenCount = Math.max(0, headers.length - visibleHeaders.length);
if (sampleRows.length === 0) {
return <p className="py-4 text-center text-sm text-muted-foreground">No sample rows found.</p>;
}
return (
<div className="overflow-x-auto rounded-lg border border-border/60">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 bg-muted/40 text-muted-foreground">
{visibleHeaders.map(header => (
<th key={header} className="px-3 py-2 text-left font-medium whitespace-nowrap">{header}</th>
))}
{hiddenCount > 0 && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap">+{hiddenCount}</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{sampleRows.map((row, index) => (
<tr key={index} className="hover:bg-muted/20">
{visibleHeaders.map(header => (
<td key={header} className="max-w-48 truncate px-3 py-2 text-muted-foreground">
{row[header] || '—'}
</td>
))}
{hiddenCount > 0 && (
<td className="px-3 py-2 text-muted-foreground">more columns</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
function formatCsvRowDetail(detail) {
if (!detail) return '';
const field = detail.field ? `${detail.field}: ` : '';
return `${field}${detail.message || detail.value || JSON.stringify(detail)}`;
}
export default function ImportTransactionCsvSection({ onHistoryRefresh }) {
const fileRef = useRef(null);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
const [mapping, setMapping] = useState({});
const [commitState, setCommitState] = useState({ status: 'idle', result: null, error: null });
const reset = () => {
setFile(null);
setPreview({ status: 'idle', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
if (fileRef.current) fileRef.current.value = '';
};
const handleMappingChange = (field, header) => {
if (commitState.status === 'done') return;
setMapping(prev => {
const next = { ...prev };
if (header) next[field] = header;
else delete next[field];
return next;
});
setCommitState({ status: 'idle', result: null, error: null });
};
const handlePreview = async () => {
if (!file) {
toast.error('Choose a CSV file first.');
return;
}
setPreview({ status: 'loading', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
try {
const data = await api.previewCsvTransactionImport(file);
setPreview({ status: 'ready', data, error: null });
setMapping(compactMapping(data.suggestedMapping || {}));
toast.success('CSV preview ready.');
} catch (err) {
const errorState = importErrorState(err, 'CSV preview failed.');
setPreview({ status: 'error', data: null, error: errorState });
toast.error(errorState.message || 'CSV preview failed.');
}
};
const handleCommit = async () => {
if (!preview.data?.import_session_id || !canCommitCsvMapping(mapping)) return;
setCommitState({ status: 'loading', result: null, error: null });
try {
const result = await api.commitCsvTransactionImport({
import_session_id: preview.data.import_session_id,
mapping: compactMapping(mapping),
});
setCommitState({ status: 'done', result, error: null });
toast.success(`CSV imported — ${result.imported} imported, ${result.skipped} skipped.`);
onHistoryRefresh?.();
} catch (err) {
const errorState = importErrorState(err, 'CSV import failed.');
setCommitState({ status: 'error', result: null, error: errorState });
toast.error(errorState.message || 'CSV import failed.');
}
};
const applySuggestedMapping = () => {
if (commitState.status === 'done') return;
setMapping(compactMapping(preview.data?.suggestedMapping || {}));
setCommitState({ status: 'idle', result: null, error: null });
};
const clearMapping = () => {
if (commitState.status === 'done') return;
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
};
const fields = preview.data?.fields || {};
const canCommit = preview.status === 'ready'
&& preview.data?.import_session_id
&& canCommitCsvMapping(mapping)
&& commitState.status !== 'loading'
&& commitState.status !== 'done';
const activeStep = csvImportStepIndex(preview, mapping, commitState);
const failedRows = (commitState.result?.details || []).filter(d => d.result === 'failed');
const skippedRows = (commitState.result?.details || []).filter(d => d.result === 'skipped_duplicate');
return (
<SectionCard
title="Import Transaction CSV"
subtitle="Upload a bank or credit-card CSV, map its columns, then save normalized transactions for matching later."
>
<div className="px-6 py-5 space-y-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-5 w-5 text-primary shrink-0" />
<div>
<p className="text-sm font-medium">Import transaction rows from CSV.</p>
<p className="mt-1 text-xs text-muted-foreground">
This importer creates shared transaction records only. It does not match transactions to bills yet.
</p>
</div>
</div>
</div>
<CsvImportStepper activeIndex={activeStep} />
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="flex-1 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">CSV file</span>
<Input
ref={fileRef}
type="file"
accept=".csv,text/csv"
onChange={e => {
setFile(e.target.files?.[0] || null);
setPreview({ status: 'idle', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
}}
/>
</label>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={reset}>
Clear
</Button>
<Button size="sm" type="button" disabled={!file || preview.status === 'loading'} onClick={handlePreview}>
{preview.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Previewing</>
: <><Upload className="h-3.5 w-3.5 mr-1.5" />Preview</>}
</Button>
</div>
</div>
</div>
{preview.status === 'error' && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mr-1.5 inline h-4 w-4" />
{preview.error?.message || 'CSV preview failed.'}
{preview.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{preview.error.details.map((d, i) => (
<li key={i}>{d.message || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
{preview.status === 'ready' && preview.data && (
<div className="space-y-5">
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">CSV Preview</p>
<p className="mt-1 text-sm font-medium">{file?.name || 'Transaction CSV'}</p>
</div>
<div className="flex flex-wrap gap-2">
<CountPill label="Rows" value={preview.data.rowCount} />
<CountPill label="Columns" value={preview.data.headers?.length || 0} />
<CountPill label="Issues" value={preview.data.errors?.length || 0} />
</div>
</div>
{preview.data.errors?.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">Review mapping</p>
<ul className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
{preview.data.errors.map((issue, i) => (
<li key={i} className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{issue.message || JSON.stringify(issue)}</span>
</li>
))}
</ul>
</div>
)}
<CsvSampleTable preview={preview.data} />
</div>
<CsvMappingReview
preview={preview.data}
fields={fields}
mapping={mapping}
onMappingChange={handleMappingChange}
onUseSuggested={applySuggestedMapping}
onClearMapping={clearMapping}
disabled={commitState.status === 'done'}
/>
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-medium">
{canCommitCsvMapping(mapping) ? 'Ready to commit' : 'Mapping incomplete'}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Duplicates are skipped using a CSV transaction ID when available, otherwise a stable row hash.
</p>
</div>
{commitState.status === 'done' ? (
<Button size="sm" variant="outline" type="button" onClick={reset}>
<Plus className="h-3.5 w-3.5 mr-1.5" />New CSV import
</Button>
) : (
<Button size="sm" type="button" disabled={!canCommit} onClick={handleCommit}>
{commitState.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Importing</>
: <><CheckCheck className="h-3.5 w-3.5 mr-1.5" />Commit Import</>}
</Button>
)}
</div>
</div>
)}
{commitState.status === 'done' && commitState.result && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
<p className="text-sm font-medium text-emerald-600">CSV transaction import complete</p>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs">
<CountPill label="Imported" value={commitState.result.imported} />
<CountPill label="Skipped" value={commitState.result.skipped} />
<CountPill label="Failed" value={commitState.result.failed} />
</div>
{skippedRows.length > 0 && (
<div className="mt-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Skipped duplicates ({skippedRows.length})</p>
<ul className="mt-1 max-h-44 space-y-1 overflow-y-auto rounded-md border border-border/50 bg-background/40 p-2">
{skippedRows.map(row => (
<li key={row.row} className="break-all">
Row {row.row}: {row.provider_transaction_id}
</li>
))}
</ul>
</div>
)}
{failedRows.length > 0 && (
<div className="mt-3 text-xs text-destructive">
<p className="font-medium">Failed rows ({failedRows.length})</p>
<ul className="mt-1 max-h-64 space-y-2 overflow-y-auto rounded-md border border-destructive/20 bg-background/40 p-2">
{failedRows.map((row, index) => (
<li key={`${row.row}-${index}`} className="rounded border border-destructive/10 bg-destructive/5 px-2 py-1.5">
<p>Row {row.row}: {row.message}</p>
{row.details?.length > 0 && (
<ul className="mt-1 space-y-0.5 pl-3 text-destructive/90">
{row.details.map((detail, detailIndex) => (
<li key={detailIndex}>{formatCsvRowDetail(detail)}</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
)}
</div>
)}
{commitState.status === 'error' && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mr-1.5 inline h-4 w-4" />
{commitState.error?.message || 'CSV import failed.'}
{commitState.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{commitState.error.details.map((d, i) => (
<li key={i}>{d.message || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
</div>
</SectionCard>
);
}
// Section 3: Import My Data Export

View File

@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { SectionCard } from './dataShared';
export default function SeedDemoDataSection({ onSeeded }) {
const [loading, setLoading] = useState(false);
const [seeded, setSeeded] = useState(false);
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
const [clearing, setClearing] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
useEffect(() => {
api.seededStatus()
.then(data => {
setSeeded(data.seeded);
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
})
.catch(err => console.error('Failed to check seeded status:', err))
.finally(() => setStatusLoading(false));
}, []);
const handleSeed = async () => {
setLoading(true);
try {
const data = await api.seedDemoData();
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
setSeeded(true);
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
setTimeout(() => onSeeded?.(), 100);
} catch (err) {
console.error('Seed error:', err);
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
} finally {
setLoading(false);
}
};
const handleClearDemoData = async () => {
setClearing(true);
try {
const data = await api.clearDemoData();
setSeeded(false);
setCounts({ bills: 0, categories: 0 });
setShowClearConfirm(false);
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
onSeeded?.();
} catch (err) {
toast.error(err.message || 'Failed to clear demo data.');
} finally {
setClearing(false);
}
};
return (
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
{statusLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : seeded ? (
<>
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Demo data seeded</p>
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
<div>
<p className="text-muted-foreground">Bills</p>
<p className="font-semibold">{counts.bills}</p>
</div>
<div>
<p className="text-muted-foreground">Categories</p>
<p className="font-semibold">{counts.categories}</p>
</div>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Create 20 realistic demo bills and 8 demo categories for testing purposes.
The data will be associated with your account.
</p>
)}
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border pt-4">
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded || statusLoading}>
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding</> : 'Seed Demo Data'}
</Button>
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={!seeded || clearing || statusLoading}>
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing</> : 'Clear Demo Data'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
<AlertDialogDescription>
This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearDemoData} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing</> : 'Clear Data'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</SectionCard>
);
}

View File

@ -0,0 +1,562 @@
import React, { useState, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import {
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off,
XCircle, Eye, EyeOff, Search,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { SectionCard } from './dataShared';
const TRANSACTION_FILTERS = [
{ id: 'open', label: 'Open', params: { ignored: 'false' } },
{ id: 'unmatched', label: 'Unmatched', params: { match_status: 'unmatched', ignored: 'false' } },
{ id: 'matched', label: 'Matched', params: { match_status: 'matched', ignored: 'false' } },
{ id: 'ignored', label: 'Ignored', params: { match_status: 'ignored', ignored: 'true' } },
{ id: 'all', label: 'All', params: { ignored: 'all' } },
];
function transactionStatus(tx) {
if (tx?.ignored) return 'ignored';
return tx?.match_status || 'unmatched';
}
function TransactionStatusBadge({ tx }) {
const status = transactionStatus(tx);
const styles = {
matched: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
ignored: 'border-muted-foreground/30 bg-muted/40 text-muted-foreground',
unmatched: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400',
};
return (
<span className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase',
styles[status] || styles.unmatched,
)}>
{status}
</span>
);
}
function formatTransactionAmount(amount, currency = 'USD') {
const value = Math.abs(Number(amount || 0)) / 100;
const sign = Number(amount || 0) < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
}
function transactionDate(tx) {
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
}
function transactionTitle(tx) {
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
}
function matchScoreTone(score) {
const value = Number(score) || 0;
if (value >= 80) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
if (value >= 55) return 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400';
return 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400';
}
function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onReject }) {
return (
<div className="rounded-lg border border-sky-500/20 bg-sky-500/[0.035]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sky-500/10 px-4 py-3">
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-sky-500/20 bg-sky-500/10 text-sky-600 dark:text-sky-400">
<Sparkles className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-semibold">Suggested matches</p>
<p className="text-xs text-muted-foreground">{loading ? 'Checking transactions' : `${suggestions.length} ready for review`}</p>
</div>
</div>
</div>
{loading ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" />
Finding likely bill matches...
</div>
) : suggestions.length === 0 ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
No suggested matches right now.
</div>
) : (
<div className="grid gap-2 p-3 xl:grid-cols-2">
{suggestions.map(suggestion => {
const tx = suggestion.transaction || {};
const bill = suggestion.bill || {};
const acceptBusy = actionId === `suggestion-match:${suggestion.id}`;
const rejectBusy = actionId === `suggestion-reject:${suggestion.id}`;
const busy = acceptBusy || rejectBusy;
return (
<div
key={suggestion.id}
className="rounded-lg border border-border/60 bg-background/80 p-3 shadow-sm transition-colors hover:border-sky-500/30"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className={cn('rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase', matchScoreTone(suggestion.score))}>
{suggestion.score}
</span>
<p className="truncate text-sm font-medium">{transactionTitle(tx)}</p>
</div>
<p className="truncate text-xs text-muted-foreground">
{transactionDate(tx)} · {tx.source_label || tx.source_type_label || 'Transaction'}
</p>
</div>
<p className={cn(
'shrink-0 text-sm font-semibold tabular-nums',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{formatTransactionAmount(tx.amount, tx.currency)}
</p>
</div>
<div className="mt-3 flex items-center gap-2 rounded-md border border-emerald-500/15 bg-emerald-500/[0.045] px-2.5 py-2">
<Link2 className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{bill.name || `Bill ${suggestion.billId}`}</p>
<p className="text-xs text-muted-foreground">
Expected ${Number(bill.expected_amount || 0).toFixed(2)}
</p>
</div>
</div>
{suggestion.reasons?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{suggestion.reasons.slice(0, 4).map(reason => (
<span key={reason} className="rounded-full border border-border/60 bg-muted/30 px-2 py-0.5 text-[10px] text-muted-foreground">
{reason}
</span>
))}
</div>
)}
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onReject(suggestion)}
className="h-8 text-xs text-muted-foreground hover:text-destructive"
>
{rejectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
<span className="ml-1.5">Reject</span>
</Button>
<Button
type="button"
size="sm"
disabled={busy}
onClick={() => onAccept(suggestion)}
className="h-8 gap-1.5 text-xs"
>
{acceptBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
Match
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading }) {
const [query, setQuery] = useState('');
const [selectedBillId, setSelectedBillId] = useState('');
useEffect(() => {
if (open) {
setQuery('');
setSelectedBillId(transaction?.matched_bill_id ? String(transaction.matched_bill_id) : '');
}
}, [open, transaction?.id, transaction?.matched_bill_id]);
const filteredBills = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return bills.slice(0, 40);
return bills
.filter(bill => String(bill.name || '').toLowerCase().includes(q))
.slice(0, 40);
}, [bills, query]);
const selectedBill = bills.find(bill => String(bill.id) === String(selectedBillId));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Match Transaction</DialogTitle>
<DialogDescription>
Choose the bill this transaction paid. Nothing changes until you confirm.
</DialogDescription>
</DialogHeader>
{transaction && (
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{transactionTitle(transaction)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{transactionDate(transaction)} · {transaction.source_label || transaction.source_type_label || 'Transaction'}
</p>
</div>
<p className={cn(
'text-sm font-semibold tabular-nums',
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{formatTransactionAmount(transaction.amount, transaction.currency)}
</p>
</div>
{transaction.description && transaction.description !== transactionTitle(transaction) && (
<p className="mt-2 text-xs text-muted-foreground">{transaction.description}</p>
)}
</div>
)}
<div className="space-y-3">
<label className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Find bill</span>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search bills"
className="pl-8"
/>
</div>
</label>
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/60">
{filteredBills.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">No bills found.</p>
) : (
<div className="divide-y divide-border/40">
{filteredBills.map(bill => (
<button
key={bill.id}
type="button"
onClick={() => setSelectedBillId(String(bill.id))}
className={cn(
'flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30',
String(selectedBillId) === String(bill.id) && 'bg-primary/5',
)}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{bill.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Due day {bill.due_day ?? '—'} · expected ${Number(bill.expected_amount || 0).toFixed(2)}
</p>
</div>
{String(selectedBillId) === String(bill.id) && (
<CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
))}
</div>
)}
</div>
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
disabled={!selectedBill || loading}
onClick={() => selectedBill && onConfirm(selectedBill.id)}
>
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Matching</> : <><Link2 className="h-3.5 w-3.5 mr-1.5" />Confirm Match</>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function TransactionMatchingSection({ refreshKey }) {
const [transactions, setTransactions] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [bills, setBills] = useState([]);
const [filter, setFilter] = useState('open');
const [loading, setLoading] = useState(true);
const [suggestionsLoading, setSuggestionsLoading] = useState(true);
const [billsLoading, setBillsLoading] = useState(true);
const [actionId, setActionId] = useState(null);
const [matchOpen, setMatchOpen] = useState(false);
const [matchTransaction, setMatchTransaction] = useState(null);
const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0];
const loadTransactions = async () => {
setLoading(true);
try {
const data = await api.transactions({ limit: 100, ...currentFilter.params });
setTransactions(data || []);
} catch (err) {
toast.error(err.message || 'Failed to load transactions.');
setTransactions([]);
} finally {
setLoading(false);
}
};
const loadSuggestions = async () => {
setSuggestionsLoading(true);
try {
const data = await api.matchSuggestions({ limit: 8 });
setSuggestions(data || []);
} catch (err) {
toast.error(err.message || 'Failed to load match suggestions.');
setSuggestions([]);
} finally {
setSuggestionsLoading(false);
}
};
const refreshTransactionWorkbench = async () => {
await Promise.all([loadTransactions(), loadSuggestions()]);
};
const loadBills = async () => {
setBillsLoading(true);
try {
const data = await api.bills();
setBills(data || []);
} catch {
setBills([]);
} finally {
setBillsLoading(false);
}
};
useEffect(() => { loadBills(); }, []);
useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
useEffect(() => { loadSuggestions(); }, [refreshKey]);
const openMatchDialog = (tx) => {
setMatchTransaction(tx);
setMatchOpen(true);
if (!bills.length && !billsLoading) loadBills();
};
const runTransactionAction = async (tx, action) => {
setActionId(`${action}:${tx.id}`);
try {
if (action === 'unmatch') {
await api.unmatchTransaction(tx.id);
toast.success('Transaction unmatched.');
} else if (action === 'ignore') {
await api.ignoreTransaction(tx.id);
toast.success('Transaction ignored.');
} else if (action === 'unignore') {
await api.unignoreTransaction(tx.id);
toast.success('Transaction restored.');
}
await refreshTransactionWorkbench();
} catch (err) {
toast.error(err.message || 'Transaction action failed.');
} finally {
setActionId(null);
}
};
const confirmMatch = async (billId) => {
if (!matchTransaction) return;
setActionId(`match:${matchTransaction.id}`);
try {
await api.matchTransaction(matchTransaction.id, billId);
toast.success('Transaction matched to bill.');
setMatchOpen(false);
setMatchTransaction(null);
await refreshTransactionWorkbench();
} catch (err) {
toast.error(err.message || 'Transaction match failed.');
} finally {
setActionId(null);
}
};
const acceptSuggestion = async (suggestion) => {
setActionId(`suggestion-match:${suggestion.id}`);
try {
await api.matchTransaction(suggestion.transactionId, suggestion.billId);
toast.success('Suggested match confirmed.');
await refreshTransactionWorkbench();
} catch (err) {
toast.error(err.message || 'Suggested match failed.');
} finally {
setActionId(null);
}
};
const rejectSuggestion = async (suggestion) => {
setActionId(`suggestion-reject:${suggestion.id}`);
try {
await api.rejectMatchSuggestion(suggestion.id);
toast.success('Suggestion rejected.');
await loadSuggestions();
} catch (err) {
toast.error(err.message || 'Suggestion could not be rejected.');
} finally {
setActionId(null);
}
};
return (
<SectionCard
title="Transactions"
subtitle="Review imported or manual transactions and confirm matches to bills."
>
<div className="px-6 py-5 space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
{TRANSACTION_FILTERS.map(item => (
<button
key={item.id}
type="button"
onClick={() => setFilter(item.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
filter === item.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border/60 bg-muted/20 text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{item.label}
</button>
))}
</div>
<Button size="sm" variant="outline" type="button" onClick={refreshTransactionWorkbench} disabled={loading || suggestionsLoading}>
{loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />}
Refresh
</Button>
</div>
<SuggestedMatchesPanel
suggestions={suggestions}
loading={suggestionsLoading}
actionId={actionId}
onAccept={acceptSuggestion}
onReject={rejectSuggestion}
/>
<div className="overflow-x-auto rounded-lg border border-border/60">
{loading ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">Loading transactions</div>
) : transactions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No transactions found for this filter.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 bg-muted/30 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<th className="px-4 py-2 text-left">Date</th>
<th className="px-4 py-2 text-left">Transaction</th>
<th className="px-4 py-2 text-left">Match</th>
<th className="px-4 py-2 text-right">Amount</th>
<th className="px-4 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{transactions.map(tx => {
const status = transactionStatus(tx);
const busy = actionId?.endsWith(`:${tx.id}`);
return (
<tr key={tx.id} className="hover:bg-muted/20">
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{transactionDate(tx)}
</td>
<td className="px-4 py-3 min-w-[240px]">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{transactionTitle(tx)}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{[tx.description, tx.account_name, tx.source_label].filter(Boolean).join(' · ') || '—'}
</p>
</div>
</td>
<td className="px-4 py-3 min-w-[180px]">
<div className="flex flex-col gap-1.5">
<TransactionStatusBadge tx={tx} />
{tx.matched_bill_name ? (
<span className="text-xs text-foreground">{tx.matched_bill_name}</span>
) : (
<span className="text-xs text-muted-foreground">No bill linked</span>
)}
</div>
</td>
<td className={cn(
'px-4 py-3 text-right font-semibold tabular-nums whitespace-nowrap',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{formatTransactionAmount(tx.amount, tx.currency)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1.5">
{status === 'ignored' ? (
<Button size="sm" variant="outline" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'unignore')}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Eye className="h-3.5 w-3.5" />}
<span className="ml-1.5 hidden xl:inline">Unignore</span>
</Button>
) : (
<>
{status === 'matched' ? (
<Button size="sm" variant="outline" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'unmatch')}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2Off className="h-3.5 w-3.5" />}
<span className="ml-1.5 hidden xl:inline">Unmatch</span>
</Button>
) : (
<Button size="sm" type="button" disabled={busy || billsLoading} onClick={() => openMatchDialog(tx)}>
<Link2 className="h-3.5 w-3.5" />
<span className="ml-1.5 hidden xl:inline">Match</span>
</Button>
)}
<Button size="sm" variant="ghost" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'ignore')}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
<span className="ml-1.5 hidden xl:inline">Ignore</span>
</Button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
<MatchBillDialog
open={matchOpen}
onOpenChange={setMatchOpen}
transaction={matchTransaction}
bills={bills}
loading={actionId === `match:${matchTransaction?.id}`}
onConfirm={confirmMatch}
/>
</SectionCard>
);
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
export function fmt(isoStr) {
if (!isoStr) return '—';
const d = new Date(isoStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function importErrorState(err, fallback) {
const data = err?.data || {};
return {
message: err?.message || data.message || data.error || fallback,
error: data.error || fallback,
code: data.code || err?.code || null,
details: Array.isArray(data.details) ? data.details : (Array.isArray(err?.details) ? err.details : []),
error_id: data.error_id || null,
};
}
export function SectionCard({ title, subtitle, children, className }) {
return (
<div className={cn('table-surface mb-6', className)}>
<div className="px-6 py-4 border-b border-border/50">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
{subtitle && <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>}
</div>
<div className="divide-y divide-border/50">{children}</div>
</div>
);
}
export function CountPill({ label, value }) {
return (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{value ?? 0}</p>
</div>
);
}

View File

@ -0,0 +1,136 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
const [actualAmount, setActualAmount] = useState('');
const [notes, setNotes] = useState('');
const [isSkipped, setIsSkipped] = useState(false);
const [saving, setSaving] = useState(false);
// Populate from current row state when dialog opens
useEffect(() => {
if (open) {
setActualAmount(row.actual_amount != null ? String(row.actual_amount) : '');
setNotes(row.monthly_notes || '');
setIsSkipped(!!row.is_skipped);
}
}, [open, row]);
async function handleSave(e) {
e.preventDefault();
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
if (amt !== null && (isNaN(amt) || amt < 0)) {
toast.error('Amount must be a positive number or empty');
return;
}
setSaving(true);
try {
await api.saveBillMonthlyState(row.id, {
year,
month,
actual_amount: amt,
notes: notes.trim() || null,
is_skipped: isSkipped,
});
toast.success(`${MONTHS[month - 1]} state saved`);
onSaved();
onOpenChange(false);
} catch (err) {
toast.error(err.message);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">
{row.name}
<span className="text-muted-foreground font-normal ml-2">
{MONTHS[month - 1]} {year}
</span>
</DialogTitle>
<p className="text-[11px] text-muted-foreground">
Monthly overrides changes only affect {MONTHS[month - 1]}
</p>
</DialogHeader>
<form id="mbs-form" onSubmit={handleSave} className="space-y-4 py-1">
{/* Actual amount this month */}
<div className="space-y-1.5">
<Label htmlFor="mbs-amount" className="text-xs uppercase tracking-wider text-muted-foreground">
Actual Amount ($)
</Label>
<Input
id="mbs-amount"
type="number" min="0" step="0.01"
placeholder={String(row.expected_amount)}
value={actualAmount}
onChange={e => setActualAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
<p className="text-[11px] text-muted-foreground">
Leave blank to use the template default ({fmt(row.expected_amount)}).
</p>
</div>
{/* Monthly notes */}
<div className="space-y-1.5">
<Label htmlFor="mbs-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
Notes (this month only)
</Label>
<Input
id="mbs-notes"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="e.g. higher than usual, double-billed…"
className="bg-background/50 border-border/60"
/>
</div>
{/* Skip this month */}
<label className="flex items-start gap-3 cursor-pointer select-none">
<input
type="checkbox"
checked={isSkipped}
onChange={e => setIsSkipped(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border accent-primary"
/>
<div>
<p className="text-sm font-medium leading-tight">Skip this month</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
Excludes this bill from {MONTHS[month - 1]} totals. Other months are unchanged.
</p>
</div>
</label>
</form>
<DialogFooter className="mt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={() => onOpenChange(false)} className="text-xs">
Cancel
</Button>
<Button type="submit" form="mbs-form" disabled={saving} className="text-xs">
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default MonthlyStateDialog;

View File

@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
const METHOD_NONE = 'none';
function PaymentModal({ payment, onClose, onSave }) {
const [amount, setAmount] = useState(String(payment.amount));
const [date, setDate] = useState(payment.paid_date);
// Use METHOD_NONE sentinel empty string value crashes Radix Select
const [method, setMethod] = useState(payment.method || METHOD_NONE);
const [notes, setNotes] = useState(payment.notes || '');
const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
async function handleSave(e) {
e.preventDefault();
setBusy(true);
try {
await api.updatePayment(payment.id, {
amount: parseFloat(amount),
paid_date: date,
method: method === METHOD_NONE ? null : method,
notes: notes || null,
});
toast.success('Payment saved');
onSave(); onClose();
} catch (err) {
toast.error(err.message);
} finally { setBusy(false); }
}
async function handleDelete() {
setBusy(true);
try {
await api.deletePayment(payment.id);
toast.success('Payment moved to recovery. Bill is now marked as unpaid.', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(payment.id);
toast.success('Payment restored');
onSave();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
onSave(); onClose();
} catch (err) {
toast.error(err.message);
} finally { setBusy(false); }
}
return (
<>
<Dialog open onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">Edit Payment</DialogTitle>
</DialogHeader>
<form id="payment-modal-form" onSubmit={handleSave} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="pm-amount" className="text-xs uppercase tracking-wider text-muted-foreground">Amount ($) *</Label>
<Input id="pm-amount" type="number" min="0" step="0.01" required
value={amount} onChange={e => setAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-date" className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date *</Label>
<Input id="pm-date" type="date" required
value={date} onChange={e => setDate(e.target.value)}
className="font-mono bg-background/50 border-border/60" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-method" className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger id="pm-method" className="bg-background/50 border-border/60">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value={METHOD_NONE}></SelectItem>
<SelectItem value="bank">Bank Transfer</SelectItem>
<SelectItem value="card">Card</SelectItem>
<SelectItem value="autopay">Autopay</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-notes" className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input id="pm-notes" value={notes} onChange={e => setNotes(e.target.value)}
className="bg-background/50 border-border/60" />
</div>
</form>
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
<Button
type="button" variant="destructive" disabled={busy} onClick={() => setConfirmDelete(true)}
className="text-xs"
title="Removes this payment record. The bill itself is NOT deleted."
>
Remove Payment
</Button>
<div className="flex gap-2">
<Button type="button" variant="ghost" disabled={busy} onClick={onClose} className="text-xs">Cancel</Button>
<Button type="submit" form="payment-modal-form" disabled={busy} className="text-xs">Save</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove this payment?</AlertDialogTitle>
<AlertDialogDescription>
This marks the payment as removed and reverses any debt balance update. You can undo it from the toast.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={busy}
onClick={handleDelete}
>
{busy ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export default PaymentModal;

View File

@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
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>
);
}
export default StartingAmountsEditDialog;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,9 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import PaymentModal from '@/components/tracker/PaymentModal';
const MONTHS = [ const MONTHS = [
'January','February','March','April','May','June', 'January','February','March','April','May','June',
'July','August','September','October','November','December', 'July','August','September','October','November','December',
@ -780,443 +783,6 @@ function NotesCell({ row, refresh }) {
); );
} }
// Monthly state dialog
// Edits actual_amount, monthly notes, and is_skipped for a specific bill+month.
// Changes are isolated to the selected month other months are not affected.
function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
const [actualAmount, setActualAmount] = useState('');
const [notes, setNotes] = useState('');
const [isSkipped, setIsSkipped] = useState(false);
const [saving, setSaving] = useState(false);
// Populate from current row state when dialog opens
useEffect(() => {
if (open) {
setActualAmount(row.actual_amount != null ? String(row.actual_amount) : '');
setNotes(row.monthly_notes || '');
setIsSkipped(!!row.is_skipped);
}
}, [open, row]);
async function handleSave(e) {
e.preventDefault();
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
if (amt !== null && (isNaN(amt) || amt < 0)) {
toast.error('Amount must be a positive number or empty');
return;
}
setSaving(true);
try {
await api.saveBillMonthlyState(row.id, {
year,
month,
actual_amount: amt,
notes: notes.trim() || null,
is_skipped: isSkipped,
});
toast.success(`${MONTHS[month - 1]} state saved`);
onSaved();
onOpenChange(false);
} catch (err) {
toast.error(err.message);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">
{row.name}
<span className="text-muted-foreground font-normal ml-2">
{MONTHS[month - 1]} {year}
</span>
</DialogTitle>
<p className="text-[11px] text-muted-foreground">
Monthly overrides changes only affect {MONTHS[month - 1]}
</p>
</DialogHeader>
<form id="mbs-form" onSubmit={handleSave} className="space-y-4 py-1">
{/* Actual amount this month */}
<div className="space-y-1.5">
<Label htmlFor="mbs-amount" className="text-xs uppercase tracking-wider text-muted-foreground">
Actual Amount ($)
</Label>
<Input
id="mbs-amount"
type="number" min="0" step="0.01"
placeholder={String(row.expected_amount)}
value={actualAmount}
onChange={e => setActualAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
<p className="text-[11px] text-muted-foreground">
Leave blank to use the template default ({fmt(row.expected_amount)}).
</p>
</div>
{/* Monthly notes */}
<div className="space-y-1.5">
<Label htmlFor="mbs-notes" className="text-xs uppercase tracking-wider text-muted-foreground">
Notes (this month only)
</Label>
<Input
id="mbs-notes"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="e.g. higher than usual, double-billed…"
className="bg-background/50 border-border/60"
/>
</div>
{/* Skip this month */}
<label className="flex items-start gap-3 cursor-pointer select-none">
<input
type="checkbox"
checked={isSkipped}
onChange={e => setIsSkipped(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border accent-primary"
/>
<div>
<p className="text-sm font-medium leading-tight">Skip this month</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
Excludes this bill from {MONTHS[month - 1]} totals. Other months are unchanged.
</p>
</div>
</label>
</form>
<DialogFooter className="mt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={() => onOpenChange(false)} className="text-xs">
Cancel
</Button>
<Button type="submit" form="mbs-form" disabled={saving} className="text-xs">
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [firstAmount, setFirstAmount] = useState('0');
const [fifteenthAmount, setFifteenthAmount] = useState('0');
const [otherAmount, setOtherAmount] = useState('0');
const [preview, setPreview] = useState(null);
const monthName = `${MONTHS[month - 1]} ${year}`;
const localFirst = Number(firstAmount) || 0;
const localFifteenth = Number(fifteenthAmount) || 0;
const localOther = Number(otherAmount) || 0;
const totalStarting = localFirst + localFifteenth + localOther;
const paidSoFar = Number(preview?.paid_total || 0);
const firstRemaining = localFirst - Number(preview?.paid_from_first || 0);
const fifteenthRemaining = localFifteenth - Number(preview?.paid_from_fifteenth || 0);
const totalRemaining = totalStarting - paidSoFar;
useEffect(() => {
let alive = true;
async function loadStartingAmounts() {
if (!open) return;
setLoading(true);
setError('');
try {
const result = await api.getMonthlyStartingAmounts(year, month);
if (!alive) return;
setPreview(result);
setFirstAmount(String(result.first_amount ?? 0));
setFifteenthAmount(String(result.fifteenth_amount ?? 0));
setOtherAmount(String(result.other_amount ?? 0));
} catch (err) {
if (!alive) return;
setError(err.message || 'Monthly starting amounts could not be loaded.');
} finally {
if (alive) setLoading(false);
}
}
loadStartingAmounts();
return () => { alive = false; };
}, [open, year, month]);
async function handleSave(e) {
e.preventDefault();
const first = Number(firstAmount);
const fifteenth = Number(fifteenthAmount);
const other = Number(otherAmount);
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
setError('Starting amounts must be non-negative numbers.');
return;
}
setSaving(true);
setError('');
try {
await api.updateMonthlyStartingAmounts({
year,
month,
first_amount: first,
fifteenth_amount: fifteenth,
other_amount: other,
});
toast.success('Monthly starting amounts saved.');
onSave();
} catch (err) {
setError(err.message || 'Monthly starting amounts could not be saved.');
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[92vh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-semibold tracking-tight">Monthly Starting Amounts</DialogTitle>
<p className="text-sm text-muted-foreground">{monthName}</p>
</DialogHeader>
<form id="starting-amounts-form" onSubmit={handleSave} className="space-y-5">
{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1.5">
<Label htmlFor="starting-first" className="text-xs uppercase tracking-wider text-muted-foreground">
1st
</Label>
<Input
id="starting-first"
type="number"
min="0"
step="0.01"
value={firstAmount}
disabled={loading || saving}
onChange={e => setFirstAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
<label className="space-y-1.5">
<Label htmlFor="starting-fifteenth" className="text-xs uppercase tracking-wider text-muted-foreground">
15th
</Label>
<Input
id="starting-fifteenth"
type="number"
min="0"
step="0.01"
value={fifteenthAmount}
disabled={loading || saving}
onChange={e => setFifteenthAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
<label className="space-y-1.5">
<Label htmlFor="starting-other" className="text-xs uppercase tracking-wider text-muted-foreground">
Other
</Label>
<Input
id="starting-other"
type="number"
min="0"
step="0.01"
value={otherAmount}
disabled={loading || saving}
onChange={e => setOtherAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
</div>
<div className="rounded-xl border border-border/60 bg-muted/35 p-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalStarting)}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(paidSoFar)}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalRemaining)}</p>
</div>
</div>
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">1st remaining</span>
<span className="font-mono font-semibold sm:block">{fmt(firstRemaining)}</span>
</div>
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">15th remaining</span>
<span className="font-mono font-semibold sm:block">{fmt(fifteenthRemaining)}</span>
</div>
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">Other</span>
<span className="font-mono font-semibold sm:block">{fmt(localOther)}</span>
</div>
</div>
</div>
</form>
<DialogFooter className="mt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={onClose} className="text-xs">
Cancel
</Button>
<Button type="submit" form="starting-amounts-form" disabled={loading || saving} className="text-xs">
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Payment modal
function PaymentModal({ payment, onClose, onSave }) {
const [amount, setAmount] = useState(String(payment.amount));
const [date, setDate] = useState(payment.paid_date);
// Use METHOD_NONE sentinel empty string value crashes Radix Select
const [method, setMethod] = useState(payment.method || METHOD_NONE);
const [notes, setNotes] = useState(payment.notes || '');
const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
async function handleSave(e) {
e.preventDefault();
setBusy(true);
try {
await api.updatePayment(payment.id, {
amount: parseFloat(amount),
paid_date: date,
method: method === METHOD_NONE ? null : method,
notes: notes || null,
});
toast.success('Payment saved');
onSave(); onClose();
} catch (err) {
toast.error(err.message);
} finally { setBusy(false); }
}
async function handleDelete() {
setBusy(true);
try {
await api.deletePayment(payment.id);
toast.success('Payment moved to recovery. Bill is now marked as unpaid.', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(payment.id);
toast.success('Payment restored');
onSave();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
onSave(); onClose();
} catch (err) {
toast.error(err.message);
} finally { setBusy(false); }
}
return (
<>
<Dialog open onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">Edit Payment</DialogTitle>
</DialogHeader>
<form id="payment-modal-form" onSubmit={handleSave} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="pm-amount" className="text-xs uppercase tracking-wider text-muted-foreground">Amount ($) *</Label>
<Input id="pm-amount" type="number" min="0" step="0.01" required
value={amount} onChange={e => setAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-date" className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date *</Label>
<Input id="pm-date" type="date" required
value={date} onChange={e => setDate(e.target.value)}
className="font-mono bg-background/50 border-border/60" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-method" className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger id="pm-method" className="bg-background/50 border-border/60">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value={METHOD_NONE}></SelectItem>
<SelectItem value="bank">Bank Transfer</SelectItem>
<SelectItem value="card">Card</SelectItem>
<SelectItem value="autopay">Autopay</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="pm-notes" className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input id="pm-notes" value={notes} onChange={e => setNotes(e.target.value)}
className="bg-background/50 border-border/60" />
</div>
</form>
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
<Button
type="button" variant="destructive" disabled={busy} onClick={() => setConfirmDelete(true)}
className="text-xs"
title="Removes this payment record. The bill itself is NOT deleted."
>
Remove Payment
</Button>
<div className="flex gap-2">
<Button type="button" variant="ghost" disabled={busy} onClick={onClose} className="text-xs">Cancel</Button>
<Button type="submit" form="payment-modal-form" disabled={busy} className="text-xs">Save</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove this payment?</AlertDialogTitle>
<AlertDialogDescription>
This marks the payment as removed and reverses any debt balance update. You can undo it from the toast.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={busy}
onClick={handleDelete}
>
{busy ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// Table row // Table row
function Row({ row, year, month, refresh, index, onEditBill }) { function Row({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null); const amountRef = useRef(null);

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@ -4,6 +4,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/img/logo.png"> <link rel="icon" type="image/png" href="/img/logo.png">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
<meta name="theme-color" content="#18181b">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="BillTracker">
<title>Bill Tracker</title> <title>Bill Tracker</title>
</head> </head>
<body> <body>

4200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.28.4.4", "version": "0.29.0",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@ -57,7 +57,8 @@
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"vite": "^5.4.10" "vite": "^5.4.10",
"vite-plugin-pwa": "^1.3.0"
}, },
"directories": { "directories": {
"doc": "docs" "doc": "docs"

View File

@ -1,5 +1,6 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createRequire } from 'module'; import { createRequire } from 'module';
@ -9,7 +10,40 @@ const require = createRequire(import.meta.url);
const pkg = require('./package.json'); const pkg = require('./package.json');
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['img/logo.png', 'img/pwa-192.png', 'img/pwa-512.png'],
manifest: {
name: 'BillTracker',
short_name: 'BillTracker',
description: 'Personal bill planning and tracking',
theme_color: '#18181b',
background_color: '#18181b',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/img/pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/img/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^\/api\/(tracker|bills|calendar|summary|analytics|snowball|categories)/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
expiration: { maxEntries: 30, maxAgeSeconds: 300 },
},
},
],
},
}),
],
publicDir: 'client/public', publicDir: 'client/public',
define: { define: {
// Injected at build time — frontend reads this instead of maintaining a // Injected at build time — frontend reads this instead of maintaining a