592 lines
23 KiB
JavaScript
592 lines
23 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { toast } from 'sonner';
|
|
import { AlertCircle, Building2, Link2Off, Loader2, Moon, RefreshCw, Sun, Users } 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 {
|
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import {
|
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
} from '@/components/ui/select';
|
|
import { useTheme } from '@/contexts/ThemeContext';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
|
|
// ─── Card wrapper ─────────────────────────────────────────────────────────────
|
|
|
|
function SectionCard({ title, children }) {
|
|
return (
|
|
<div className="table-surface mb-4">
|
|
<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>
|
|
</div>
|
|
<div className="divide-y divide-border/50">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Setting Row ──────────────────────────────────────────────────────────────
|
|
|
|
function SettingRow({ label, description, children }) {
|
|
return (
|
|
<div className="px-4 py-4 flex flex-col gap-3 sm:px-6 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex-1 min-w-0 sm:mr-8">
|
|
<p className="text-sm font-medium">{label}</p>
|
|
{description && (
|
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
|
)}
|
|
</div>
|
|
<div className="shrink-0">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Theme Card ───────────────────────────────────────────────────────────────
|
|
|
|
function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }) {
|
|
const selected = currentTheme === value;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect(value)}
|
|
className={cn(
|
|
'flex flex-col items-center gap-1.5 rounded-lg border px-3 py-2.5 text-xs font-medium transition-all',
|
|
'hover:border-muted-foreground/50',
|
|
selected
|
|
? 'border-primary bg-primary/5 text-primary'
|
|
: 'border-border bg-card text-muted-foreground',
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ─── Appearance Section ───────────────────────────────────────────────────────
|
|
|
|
function AppearanceSection() {
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
return (
|
|
<SectionCard title="Appearance">
|
|
<SettingRow label="Theme" description="Choose your preferred color scheme.">
|
|
<div className="flex gap-2">
|
|
<ThemeCard value="light" label="Light" icon={Sun} currentTheme={theme} onSelect={setTheme} />
|
|
<ThemeCard value="dark" label="Dark" icon={Moon} currentTheme={theme} onSelect={setTheme} />
|
|
</div>
|
|
</SettingRow>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
function LoginModeRecoverySection() {
|
|
const navigate = useNavigate();
|
|
const { singleUserMode, refresh } = useAuth();
|
|
const [restoring, setRestoring] = useState(false);
|
|
|
|
if (!singleUserMode) return null;
|
|
|
|
const handleRestore = async () => {
|
|
setRestoring(true);
|
|
try {
|
|
await api.restoreMultiUserMode();
|
|
toast.success('Multi-user login restored.');
|
|
refresh();
|
|
navigate('/login', { replace: true });
|
|
} catch (err) {
|
|
toast.error(err.message || 'Failed to restore multi-user mode.');
|
|
} finally {
|
|
setRestoring(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SectionCard title="Login Mode">
|
|
<SettingRow
|
|
label="Single-user mode is active"
|
|
description="Restore the normal login screen so each user signs in with their own account."
|
|
>
|
|
<Button size="sm" variant="outline" onClick={handleRestore} disabled={restoring}>
|
|
<Users className="h-3.5 w-3.5 mr-1.5" />
|
|
{restoring ? 'Restoring…' : 'Restore Multi-User Mode'}
|
|
</Button>
|
|
</SettingRow>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
// ─── Settings Skeleton ────────────────────────────────────────────────────────
|
|
|
|
function SettingsSkeleton() {
|
|
return (
|
|
<div>
|
|
{/* Page header */}
|
|
<div className="mb-8">
|
|
<h1 className="h-8 w-48 rounded-md bg-muted/50"></h1>
|
|
<p className="h-4 w-64 mt-2 rounded-md bg-muted/50"></p>
|
|
</div>
|
|
|
|
{/* Appearance */}
|
|
<div className="table-surface mb-4">
|
|
<div className="px-6 py-4 border-b border-border/50">
|
|
<h2 className="h-3 w-24 rounded bg-muted/50"></h2>
|
|
</div>
|
|
<div className="divide-y divide-border/50">
|
|
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex-1 min-w-0 sm:mr-8">
|
|
<p className="h-4 w-32 rounded-md bg-muted/50"></p>
|
|
<p className="h-3 w-48 mt-2 rounded-md bg-muted/50"></p>
|
|
</div>
|
|
<div className="shrink-0 flex gap-2">
|
|
<div className="h-12 w-20 rounded-lg bg-muted/50"></div>
|
|
<div className="h-12 w-20 rounded-lg bg-muted/50"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Login mode */}
|
|
<div className="table-surface mb-4">
|
|
<div className="px-6 py-4 border-b border-border/50">
|
|
<h2 className="h-3 w-24 rounded bg-muted/50"></h2>
|
|
</div>
|
|
<div className="divide-y divide-border/50">
|
|
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex-1 min-w-0 sm:mr-8">
|
|
<p className="h-4 w-48 rounded-md bg-muted/50"></p>
|
|
<p className="h-3 w-64 mt-2 rounded-md bg-muted/50"></p>
|
|
</div>
|
|
<div className="shrink-0 h-9 w-48 rounded-md bg-muted/50"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* General */}
|
|
<div className="table-surface mb-4">
|
|
<div className="px-6 py-4 border-b border-border/50">
|
|
<h2 className="h-3 w-24 rounded bg-muted/50"></h2>
|
|
</div>
|
|
<div className="divide-y divide-border/50">
|
|
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex-1 min-w-0 sm:mr-8">
|
|
<p className="h-4 w-32 rounded-md bg-muted/50"></p>
|
|
<p className="h-3 w-48 mt-2 rounded-md bg-muted/50"></p>
|
|
</div>
|
|
<div className="shrink-0 h-9 w-32 rounded-md bg-muted/50"></div>
|
|
</div>
|
|
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex-1 min-w-0 sm:mr-8">
|
|
<p className="h-4 w-32 rounded-md bg-muted/50"></p>
|
|
<p className="h-3 w-48 mt-2 rounded-md bg-muted/50"></p>
|
|
</div>
|
|
<div className="shrink-0 h-9 w-32 rounded-md bg-muted/50"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Billing */}
|
|
<div className="table-surface mb-4">
|
|
<div className="px-6 py-4 border-b border-border/50">
|
|
<h2 className="h-3 w-24 rounded bg-muted/50"></h2>
|
|
</div>
|
|
<div className="divide-y divide-border/50">
|
|
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex-1 min-w-0 sm:mr-8">
|
|
<p className="h-4 w-32 rounded-md bg-muted/50"></p>
|
|
<p className="h-3 w-48 mt-2 rounded-md bg-muted/50"></p>
|
|
</div>
|
|
<div className="shrink-0 h-9 w-32 rounded-md bg-muted/50"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Bank Sync Section ────────────────────────────────────────────────────────
|
|
|
|
function BankSyncSection() {
|
|
const [enabled, setEnabled] = useState(null); // null = loading
|
|
const [connections, setConnections] = useState([]);
|
|
const [loadError, setLoadError] = useState('');
|
|
const [setupToken, setSetupToken] = useState('');
|
|
const [connecting, setConnecting] = useState(false);
|
|
const [syncing, setSyncing] = useState(null); // id being synced
|
|
const [disconnectTarget, setDisconnectTarget] = useState(null);
|
|
const [disconnecting, setDisconnecting] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoadError('');
|
|
try {
|
|
const [status, sources] = await Promise.all([
|
|
api.simplefinStatus(),
|
|
api.dataSources({ type: 'provider_sync' }),
|
|
]);
|
|
setEnabled(status.enabled);
|
|
setConnections(Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []);
|
|
} catch (err) {
|
|
setEnabled(false);
|
|
setLoadError(err.message || 'Failed to load bank sync status');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const handleConnect = async () => {
|
|
const token = setupToken.trim();
|
|
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
|
|
setConnecting(true);
|
|
try {
|
|
const result = await api.connectSimplefin(token);
|
|
toast.success(`Connected — ${result.accountsUpserted} account(s), ${result.transactionsNew} new transaction(s).`);
|
|
setSetupToken('');
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(err.message || 'Failed to connect SimpleFIN');
|
|
} finally {
|
|
setConnecting(false);
|
|
}
|
|
};
|
|
|
|
const handleSync = async (id) => {
|
|
setSyncing(id);
|
|
try {
|
|
const result = await api.syncDataSource(id);
|
|
toast.success(`Synced — ${result.transactionsNew} new transaction(s).`);
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(err.message || 'Sync failed');
|
|
await load();
|
|
} finally {
|
|
setSyncing(null);
|
|
}
|
|
};
|
|
|
|
const handleDisconnect = async () => {
|
|
if (!disconnectTarget) return;
|
|
setDisconnecting(true);
|
|
try {
|
|
await api.deleteDataSource(disconnectTarget.id);
|
|
toast.success('SimpleFIN disconnected.');
|
|
setDisconnectTarget(null);
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(err.message || 'Failed to disconnect');
|
|
} finally {
|
|
setDisconnecting(false);
|
|
}
|
|
};
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
if (enabled === null) {
|
|
return (
|
|
<SectionCard title="Bank Sync">
|
|
<div className="px-6 py-6 flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading…
|
|
</div>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<SectionCard title="Bank Sync">
|
|
{!enabled ? (
|
|
<div className="px-6 py-5 text-sm text-muted-foreground">
|
|
Bank sync is not enabled on this server.
|
|
Set <code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">BANK_SYNC_ENABLED=true</code> and
|
|
a <code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">TOKEN_ENCRYPTION_KEY</code> in your
|
|
environment to enable SimpleFIN.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{loadError && (
|
|
<div className="px-6 py-3 text-sm text-destructive">{loadError}</div>
|
|
)}
|
|
|
|
{/* Connected accounts */}
|
|
{connections.length > 0 && connections.map(conn => (
|
|
<div key={conn.id} className="px-6 py-4 space-y-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Building2 className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{conn.name}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
|
|
{conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Button
|
|
size="sm" variant="outline"
|
|
onClick={() => handleSync(conn.id)}
|
|
disabled={syncing === conn.id}
|
|
className="h-8 text-xs gap-1.5"
|
|
>
|
|
{syncing === conn.id
|
|
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing…</>
|
|
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
|
|
</Button>
|
|
<Button
|
|
size="sm" variant="ghost"
|
|
onClick={() => setDisconnectTarget(conn)}
|
|
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Link2Off className="h-3.5 w-3.5" />
|
|
Disconnect
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 text-xs">
|
|
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
|
<p className="text-muted-foreground">Last sync</p>
|
|
<p className="font-medium mt-0.5">{fmtDate(conn.last_sync_at)}</p>
|
|
</div>
|
|
<div className={cn(
|
|
'rounded-lg border px-3 py-2',
|
|
conn.last_error ? 'border-destructive/30 bg-destructive/5' : 'border-border/60 bg-muted/20',
|
|
)}>
|
|
<p className="text-muted-foreground">Status</p>
|
|
<p className={cn('font-medium mt-0.5', conn.last_error ? 'text-destructive' : '')}>
|
|
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Connect form */}
|
|
{connections.length === 0 && (
|
|
<div className="px-6 py-5 space-y-4">
|
|
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground">
|
|
<p className="font-medium text-foreground mb-1">Connect a SimpleFIN Bridge account</p>
|
|
<p>Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL — no bank credentials are saved.</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={setupToken}
|
|
onChange={e => setSetupToken(e.target.value)}
|
|
placeholder="Paste SimpleFIN setup token…"
|
|
className="flex-1 font-mono text-xs"
|
|
/>
|
|
<Button
|
|
onClick={handleConnect}
|
|
disabled={connecting || !setupToken.trim()}
|
|
className="shrink-0"
|
|
>
|
|
{connecting ? <><Loader2 className="h-4 w-4 animate-spin mr-1.5" />Connecting…</> : 'Connect'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add another connection — only show if already connected */}
|
|
{connections.length > 0 && (
|
|
<div className="px-6 py-4 border-t border-border/50 space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Add another connection</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={setupToken}
|
|
onChange={e => setSetupToken(e.target.value)}
|
|
placeholder="Paste SimpleFIN setup token…"
|
|
className="flex-1 font-mono text-xs"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleConnect}
|
|
disabled={connecting || !setupToken.trim()}
|
|
className="shrink-0"
|
|
>
|
|
{connecting ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Connecting…</> : 'Connect'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</SectionCard>
|
|
|
|
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Disconnect SimpleFIN?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This removes the connection and deletes synced accounts. Previously synced transactions
|
|
are kept but will no longer be associated with a data source.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={disconnecting}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleDisconnect} disabled={disconnecting}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
{disconnecting ? 'Disconnecting…' : 'Disconnect'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ─── SettingsPage ─────────────────────────────────────────────────────────────
|
|
|
|
export default function SettingsPage() {
|
|
const DEFAULTS = {
|
|
currency: 'USD',
|
|
date_format: 'MM/DD/YYYY',
|
|
grace_period_days: 3,
|
|
};
|
|
|
|
const [settings, setSettings] = useState(DEFAULTS);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const loadSettings = useCallback(() => {
|
|
setLoading(true);
|
|
setLoadError(null);
|
|
api.settings()
|
|
.then((d) => setSettings({ ...DEFAULTS, ...d }))
|
|
.catch((err) => setLoadError(err.message || 'Failed to load settings'))
|
|
.finally(() => setLoading(false));
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => { loadSettings(); }, [loadSettings]);
|
|
|
|
const set = (k, v) => setSettings((p) => ({ ...p, [k]: v }));
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await api.saveSettings({
|
|
currency: settings.currency,
|
|
date_format: settings.date_format,
|
|
grace_period_days: settings.grace_period_days,
|
|
});
|
|
toast.success('Settings saved.');
|
|
} catch (err) {
|
|
toast.error(err.message || 'Failed to save settings.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<SettingsSkeleton />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loadError) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-24 text-center rounded-xl border border-destructive/20 bg-destructive/5">
|
|
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
|
|
<p className="text-sm font-medium text-foreground">Failed to load settings</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
|
|
<Button size="sm" variant="outline" onClick={loadSettings}
|
|
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50">
|
|
<RefreshCw className="h-3 w-3" />
|
|
Try again
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
|
|
{/* Page header — flat on background */}
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">Manage your display and billing preferences</p>
|
|
</div>
|
|
|
|
{/* Appearance */}
|
|
<AppearanceSection />
|
|
|
|
{/* Login mode recovery */}
|
|
<LoginModeRecoverySection />
|
|
|
|
{/* General */}
|
|
<SectionCard title="General">
|
|
<SettingRow label="Currency" description="Default currency for bill amounts.">
|
|
<Select value={settings.currency} onValueChange={(v) => set('currency', v)}>
|
|
<SelectTrigger className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="USD">USD — US Dollar</SelectItem>
|
|
<SelectItem value="EUR">EUR — Euro</SelectItem>
|
|
<SelectItem value="GBP">GBP — British Pound</SelectItem>
|
|
<SelectItem value="CAD">CAD — Canadian Dollar</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingRow>
|
|
|
|
<SettingRow label="Date format" description="How dates are displayed throughout the app.">
|
|
<Select value={settings.date_format} onValueChange={(v) => set('date_format', v)}>
|
|
<SelectTrigger className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="MM/DD/YYYY">MM/DD/YYYY</SelectItem>
|
|
<SelectItem value="DD/MM/YYYY">DD/MM/YYYY</SelectItem>
|
|
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingRow>
|
|
</SectionCard>
|
|
|
|
{/* Billing Behavior */}
|
|
<SectionCard title="Billing Behavior">
|
|
<SettingRow
|
|
label="Grace period"
|
|
description="Days after the due date before a bill is marked overdue."
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
max={30}
|
|
value={settings.grace_period_days}
|
|
onChange={(e) => set('grace_period_days', parseInt(e.target.value, 10) || 0)}
|
|
className="w-20"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">days</span>
|
|
</div>
|
|
</SettingRow>
|
|
</SectionCard>
|
|
|
|
{/* Bank Sync */}
|
|
<BankSyncSection />
|
|
|
|
{/* Save button — right-aligned below all cards */}
|
|
<div className="flex justify-end mt-6">
|
|
<Button size="sm" onClick={handleSave} disabled={saving}>
|
|
{saving ? 'Saving…' : 'Save Settings'}
|
|
</Button>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|