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:
parent
92cc667947
commit
71dfbe36cc
|
|
@ -1,6 +1,9 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 { api } from '@/api';
|
||||
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 { 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) {
|
||||
return values
|
||||
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
|
||||
|
|
@ -42,6 +87,76 @@ function shortcutLabel() {
|
|||
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() {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef(null);
|
||||
|
|
@ -50,6 +165,7 @@ export default function CommandPalette() {
|
|||
const [bills, setBills] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const monthCommands = useMemo(() => buildMonthCommands(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const openPalette = () => setOpen(true);
|
||||
|
|
@ -85,18 +201,6 @@ export default function CommandPalette() {
|
|||
.finally(() => setLoading(false));
|
||||
}, [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 = () => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
|
|
@ -115,6 +219,46 @@ export default function CommandPalette() {
|
|||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||
|
|
@ -129,9 +273,12 @@ export default function CommandPalette() {
|
|||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
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"
|
||||
/>
|
||||
{query && (
|
||||
|
|
@ -148,66 +295,56 @@ export default function CommandPalette() {
|
|||
)}
|
||||
</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 ? (
|
||||
<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" />
|
||||
Loading bills...
|
||||
Loading…
|
||||
</div>
|
||||
) : results.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{results.map(bill => (
|
||||
<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>
|
||||
))}
|
||||
) : !hasResults ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
No results.
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
No bills found.
|
||||
<>
|
||||
{commandResults.length > 0 && (
|
||||
<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 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>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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, 1–72)">
|
||||
<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, 30–3650)">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 ────────────────────────────────────────
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -25,6 +25,9 @@ import {
|
|||
} from '@/components/ui/select';
|
||||
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 = [
|
||||
'January','February','March','April','May','June',
|
||||
'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 ──────────────────────────────────────────────────────────────
|
||||
function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||
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 |
|
|
@ -4,6 +4,12 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.28.4.4",
|
||||
"version": "0.29.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -57,7 +57,8 @@
|
|||
"concurrently": "^9.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^5.4.10"
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
|
|
@ -9,7 +10,40 @@ const require = createRequire(import.meta.url);
|
|||
const pkg = require('./package.json');
|
||||
|
||||
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',
|
||||
define: {
|
||||
// Injected at build time — frontend reads this instead of maintaining a
|
||||
|
|
|
|||
Loading…
Reference in New Issue