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 (
);
}
// ─── Setting Row ──────────────────────────────────────────────────────────────
function SettingRow({ label, description, children }) {
return (
{label}
{description && (
{description}
)}
{children}
);
}
// ─── Theme Card ───────────────────────────────────────────────────────────────
function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }) {
const selected = currentTheme === value;
return (
);
}
// ─── Appearance Section ───────────────────────────────────────────────────────
function AppearanceSection() {
const { theme, setTheme } = useTheme();
return (
);
}
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 (
);
}
// ─── Settings Skeleton ────────────────────────────────────────────────────────
function SettingsSkeleton() {
return (
{/* Page header */}
{/* Appearance */}
{/* Login mode */}
{/* General */}
{/* Billing */}
);
}
// ─── 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 (
Loading…
);
}
return (
<>
{!enabled ? (
Bank sync is not enabled on this server.
Set BANK_SYNC_ENABLED=true and
a TOKEN_ENCRYPTION_KEY in your
environment to enable SimpleFIN.
) : (
<>
{loadError && (
{loadError}
)}
{/* Connected accounts */}
{connections.length > 0 && connections.map(conn => (
{conn.name}
{conn.account_count} account{conn.account_count !== 1 ? 's' : ''} ·{' '}
{conn.transaction_count} transaction{conn.transaction_count !== 1 ? 's' : ''}
Last sync
{fmtDate(conn.last_sync_at)}
Status
{conn.last_error ? conn.last_error : (conn.status === 'active' ? 'Active' : conn.status)}
))}
{/* Connect form */}
{connections.length === 0 && (
Connect a SimpleFIN Bridge account
Paste your SimpleFIN setup token below. BillTracker only stores an encrypted access URL — no bank credentials are saved.
setSetupToken(e.target.value)}
placeholder="Paste SimpleFIN setup token…"
className="flex-1 font-mono text-xs"
/>
)}
{/* Add another connection — only show if already connected */}
{connections.length > 0 && (
)}
>
)}
{ if (!open) setDisconnectTarget(null); }}>
Disconnect SimpleFIN?
This removes the connection and deletes synced accounts. Previously synced transactions
are kept but will no longer be associated with a data source.
Cancel
{disconnecting ? 'Disconnecting…' : 'Disconnect'}
>
);
}
// ─── 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 (
);
}
if (loadError) {
return (
Failed to load settings
{loadError}
);
}
return (
{/* Page header — flat on background */}
Settings
Manage your display and billing preferences
{/* Appearance */}
{/* Login mode recovery */}
{/* General */}
{/* Billing Behavior */}
set('grace_period_days', parseInt(e.target.value, 10) || 0)}
className="w-20"
/>
days
{/* Bank Sync */}
{/* Save button — right-aligned below all cards */}
);
}