diff --git a/client/components/CommandPalette.jsx b/client/components/CommandPalette.jsx index e53d73d..29a63c9 100644 --- a/client/components/CommandPalette.jsx +++ b/client/components/CommandPalette.jsx @@ -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 ( +
+ +
+ + +
+
+ ); +} + +function CommandResult({ cmd, onRun }) { + const Icon = cmd.icon || Navigation; + return ( + + ); +} + +// ─── 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 ( @@ -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() { )} -
+
{loading ? (
- Loading bills... + Loading…
- ) : results.length > 0 ? ( -
- {results.map(bill => ( -
- -
- - -
-
- ))} + ) : !hasResults ? ( +
+ No results.
) : ( -
- No bills found. -
+ <> + {commandResults.length > 0 && ( +
+

+ Commands +

+
+ {commandResults.map(cmd => ( + + ))} +
+
+ )} + + {billResults.length > 0 && ( +
+ {commandResults.length > 0 && ( +

+ Bills +

+ )} +
+ {billResults.map(bill => ( + + ))} +
+
+ )} + )}
- Enter opens Bills + Enter to open · Tab to focus {shortcutLabel()}
diff --git a/client/components/admin/AddUserCard.jsx b/client/components/admin/AddUserCard.jsx new file mode 100644 index 0000000..ccb2c92 --- /dev/null +++ b/client/components/admin/AddUserCard.jsx @@ -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 ( + + + Add User + + +
+
+ + setUsername(e.target.value)} placeholder="username" required /> +
+
+ + setPassword(e.target.value)} placeholder="Password" required /> +
+
+ {error && ( +
+ {error} +
+ )} + + + + + ); +} diff --git a/client/components/admin/AuthMethodsCard.jsx b/client/components/admin/AuthMethodsCard.jsx new file mode 100644 index 0000000..aac86fc --- /dev/null +++ b/client/components/admin/AuthMethodsCard.jsx @@ -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 ( + + + Loading auth settings… + + + ); + } + + 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 ( + + +
+
+ Authentication Methods +

+ Control local login and authentik/OIDC. Settings are saved in the database; + environment variables only fill blank fields as bootstrap defaults. +

+
+
+
+ + + + {(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && ( +
+ {wouldLockOut && ( +

+ Cannot disable all login methods; at least one must remain enabled. +

+ )} + {cantDisableLocal && !wouldLockOut && ( +

+ Cannot disable local login without authentik/OIDC configured, enabled, and mapped to an admin group. +

+ )} + {oidcEnabledButIncomplete && ( +

+ authentik/OIDC needs {missingFields.join(', ')} before it can be enabled. +

+ )} + {data?.warnings?.map((w, i) => ( +

{w}

+ ))} +
+ )} + + +
+ set('local_login_enabled', v)} label="Enable local login" /> + {form.local_login_enabled ? 'Enabled' : 'Disabled'} +
+
+ + +
+ set('oidc_login_enabled', v)} label="Enable OIDC login" /> + + {!oidcConfigured ? 'Not fully configured' : form.oidc_login_enabled ? 'Enabled' : 'Disabled'} + +
+
+ +
+
+ + authentik / OIDC configuration +
+ + + set('oidc_provider_name', e.target.value)} placeholder="authentik" className="max-w-xs h-8 text-sm" /> + + + +
+ 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" + /> +

+ Use the authentik provider issuer URL or full discovery URL, for example https://yourURL.com/application/o/bills/.well-known/openid-configuration. +

+ {issuerEndpointWarning && ( +

+ This looks like an authorization endpoint. In authentik, copy the provider issuer or OpenID Configuration URL. +

+ )} +
+
+ + + set('oidc_client_id', e.target.value)} placeholder="authentik client ID" className="max-w-xl h-8 text-sm" /> + + + +
+
+ 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" + /> + + {data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'Secret is set' : 'No secret saved'} + +
+ +
+
+ + +
+ +

+ Advanced. Keep client_secret_basic unless your authentik provider explicitly requires client_secret_post. +

+
+
+ + +
+
+ set('oidc_redirect_uri', e.target.value)} placeholder={defaultOidcRedirectUri()} className="h-8 text-sm" /> + +
+

Add this exact URL to the Redirect URIs allowed by authentik.

+
+
+ + + set('oidc_scopes', e.target.value)} placeholder="openid email profile groups" className="max-w-xl h-8 text-sm" /> + + + +
+ set('oidc_admin_group', e.target.value)} placeholder="e.g. bill-tracker-admins" className="max-w-sm h-8 text-sm" /> +

Only users in this authentik group become app admins. Admin is never granted by default.

+
+
+ + +
+
+ set('oidc_auto_provision', v)} label="Auto-provision users" /> + {form.oidc_auto_provision ? 'Enabled' : 'Disabled'} +
+

When enabled, valid authentik users are created in this app on first login.

+
+
+ + +
+ + Admin role only via admin group. +
+
+ + {data?.oidc_env_fallback_used && ( +
+ One or more blank database fields are currently using environment fallback values. Saving values here takes precedence. +
+ )} + + {oidcTest && ( +
+ {oidcTest.ok + ? `Configuration test passed for ${oidcTest.issuer || form.oidc_issuer_url}.` + : oidcTest.error || 'Configuration test failed.'} +
+ )} +
+ +
+ + + +
+ +
+
+ ); +} diff --git a/client/components/admin/BackupManagementCard.jsx b/client/components/admin/BackupManagementCard.jsx new file mode 100644 index 0000000..fa31a6d --- /dev/null +++ b/client/components/admin/BackupManagementCard.jsx @@ -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 Loading backups…; + } + + return ( + <> + + +
+
+ Database Backups +

+ Admin-only SQLite backup, import, download, restore, and schedule controls. +

+
+ + {settings.last_error ? 'Attention' : 'Ready'} + +
+
+ +
+ {[ + ['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]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {settings.last_error && ( +
+ {settings.last_error} +
+ )} + +
+ + + +
+ +
+ + + + + + + + + + + + {backups.map(backup => ( + + + + + + + + + ))} + {!backups.length && ( + + + + )} + +
BackupTypeModifiedSizeChecksum +
{backup.id}{formatDateTime(backup.modified_at)}{fmtBytes(backup.size_bytes)} + {backup.checksum || '—'} + +
+ + + +
+
No managed backups yet.
+
+ +
+
+
+ Scheduled Backups +

+ Scheduled runs create managed backups and only apply retention to scheduled backups. +

+
+ setSchedule('enabled', v)} label="Enable scheduled backups" /> +
+ +
+
+ + +
+
+ + setSchedule('time', e.target.value)} /> +
+
+ + setSchedule('retention_count', e.target.value)} /> +
+
+ +
+ {formatDateTime(settings.next_run_at)} +
+
+
+ +
+
Last run: {formatDateTime(settings.last_run_at)}
+
Last error: {settings.last_error || '—'}
+
+ +
+ + +
+
+
+
+ + { if (!open) setRestoreTarget(null); }}> + + + Restore this database backup? + + This replaces the live database with {restoreTarget?.id}. + A pre-restore backup will be created first. Run this during a quiet maintenance window. + + + + Cancel + + {busy.startsWith('restore:') ? 'Restoring…' : 'Restore Database'} + + + + + + { if (!open) setDeleteTarget(null); }}> + + + Delete this backup? + + This permanently deletes {deleteTarget?.id}. + The live database is not affected. + + + + Cancel + + {busy.startsWith('delete:') ? 'Deleting…' : 'Delete Backup'} + + + + + + ); +} diff --git a/client/components/admin/CleanupPanel.jsx b/client/components/admin/CleanupPanel.jsx new file mode 100644 index 0000000..68ba7dc --- /dev/null +++ b/client/components/admin/CleanupPanel.jsx @@ -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 ( + + + Loading cleanup settings… + + + ); + } + + return ( + + +
+
+ +
+ Cleanup / Maintenance +

+ Automatic daily cleanup: expired import sessions, stale export temp files, orphaned backup partials. Runs at 6:00 AM. +

+
+
+ Auto +
+
+ + +
+
+

Last Run

+

{status?.last_run_at ? formatDateTime(status.last_run_at) : '—'}

+
+
+

Last Result

+ {resultLines.length > 0 ? ( +
    + {resultLines.map(line => ( +
  • {line}
  • + ))} +
+ ) : ( +

No runs recorded yet

+ )} +
+
+ +
+

Task Settings

+ {[ + ['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]) => ( + + set(key, v)} label={label} /> + + ))} + + + 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" + /> + + + + set('import_history_enabled', v)} label="Trim import history rows" /> + + + {form.import_history_enabled && ( + <> + + set('import_history_max_age_days', parseInt(e.target.value, 10) || 365)} + className="max-w-[120px] h-8 text-sm" + /> + +
+ Warning: Import history trimming permanently deletes audit records older than {form.import_history_max_age_days} days. This cannot be undone. +
+ + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/client/components/admin/EmailNotifCard.jsx b/client/components/admin/EmailNotifCard.jsx new file mode 100644 index 0000000..81c626e --- /dev/null +++ b/client/components/admin/EmailNotifCard.jsx @@ -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 Loading…; + + return ( + + + Email Notifications + + +
+
+

Enable email notifications

+

Configure SMTP to send bill reminders

+
+ set('enabled', v)} label="Enable email notifications" /> +
+ +
+ +
+ Sender + + set('sender_name', e.target.value)} placeholder="BillTracker" /> + + + set('sender_address', e.target.value)} placeholder="no-reply@example.com" type="email" /> + +
+ +
+ +
+ SMTP Server + + set('smtp_host', e.target.value)} placeholder="smtp.example.com" /> + + + set('smtp_port', e.target.value)} placeholder="587" type="number" className="w-28" /> + + + + + +
+ set('smtp_self_signed', e.target.checked)} + className="h-4 w-4 rounded border-input bg-input accent-primary" + /> + +
+
+ + set('smtp_username', e.target.value)} placeholder="user@example.com" /> + + +
+ set('smtp_password', e.target.value)} + placeholder="••••••••" + className="pr-9" + /> + +
+
+
+ +
+ +
+ User Access + +
+ set('allow_user_config', e.target.checked)} + className="h-4 w-4 rounded border-input bg-input accent-primary" + /> + +
+
+ + set('global_recipient', e.target.value)} + placeholder="recipient@example.com" + type="email" + /> + +
+ +
+ +
+ Test Email + +
+ setTestEmail(e.target.value)} + placeholder="you@example.com" + type="email" + /> + +
+
+
+ +
+ +
+ + + ); +} diff --git a/client/components/admin/LoginModeCard.jsx b/client/components/admin/LoginModeCard.jsx new file mode 100644 index 0000000..6392937 --- /dev/null +++ b/client/components/admin/LoginModeCard.jsx @@ -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 Loading…; + + 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 ( + <> + + +
+ Login Mode + + {isMulti ? 'Multi-user' : 'Single-user'} + +
+
+ + {isMulti ? ( + <> +

+ Single-user mode bypasses the login screen and automatically signs in as the selected user. +

+
+ + +
+ + + ) : ( + <> +

+ Currently auto-signing in as{' '} + {activeUser?.username ?? '—'}. + Restoring login requirement will require all users to sign in manually. +

+ + + )} +
+
+ + + + + Enable Single-User Mode? + + Anyone who opens the app will be automatically signed in as{' '} + {selectedUsername}. + The admin login still requires a password. + + + + Cancel + + Enable Single-User Mode + + + + + + ); +} diff --git a/client/components/admin/OnboardingWizard.jsx b/client/components/admin/OnboardingWizard.jsx new file mode 100644 index 0000000..7944df2 --- /dev/null +++ b/client/components/admin/OnboardingWizard.jsx @@ -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 ( +
+
+
+ {[0, 1].map(i => ( + + ))} +
+ + {step === 0 && ( + + + Welcome, Administrator +

+ Before creating your first user, please understand what your admin account can and cannot do. +

+
+ +
+ {[ + { 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 }) => ( +
+ + {can ? '✓' : '✗'} + + {text} +
+ ))} +
+
+ +
+
+
+ )} + + {step === 1 && ( + + + Create first user +

+ This account will be used to access the bill tracker. +

+
+ +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + setConfirm(e.target.value)} + required + /> +
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+
+
+ )} +
+
+ ); +} diff --git a/client/components/admin/UsersTable.jsx b/client/components/admin/UsersTable.jsx new file mode 100644 index 0000000..ab692fd --- /dev/null +++ b/client/components/admin/UsersTable.jsx @@ -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 ( + <> + + + Users + + +
+ + + + + + + + + + + + {(users || []).map(user => { + const form = getForm(user.id); + const isSelf = currentUser?.id === user.id; + return ( + + + + + + + + + ); + })} + {!users?.length && ( + + + + )} + +
UsernameRoleStatusPasswordReset Password +
+
+ {user.username} + {user.is_default_admin && default admin} +
+
+
+ {user.role} + +
+
+ + + {user.must_change_password + ? Temporary + : Set + } + + {form.open ? ( +
+ setReset(user.id, { pw: e.target.value })} + className="h-8 text-sm w-36" + /> + + +
+ ) : ( + + )} +
+ {!isSelf && ( + + )} +
No users found.
+
+
+
+ + { if (!open) setDeleteTarget(null); }}> + + + Delete {deleteTarget?.username}? + + 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. + + + + Cancel + + {deleting ? 'Deleting…' : 'Delete User'} + + + + + + ); +} diff --git a/client/components/admin/adminShared.jsx b/client/components/admin/adminShared.jsx new file mode 100644 index 0000000..0c99fb2 --- /dev/null +++ b/client/components/admin/adminShared.jsx @@ -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

{children}

; +} + +export function FieldRow({ label, children }) { + return ( +
+ + {children} +
+ ); +} + +export function Toggle({ checked, onChange, label, disabled = false }) { + return ( + + ); +} + +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 {type || 'backup'}; +} diff --git a/client/components/data/DownloadMyDataSection.jsx b/client/components/data/DownloadMyDataSection.jsx new file mode 100644 index 0000000..7a44904 --- /dev/null +++ b/client/components/data/DownloadMyDataSection.jsx @@ -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 ( +
+
+
+ +
+
+
+

{title}

+ {!USER_EXPORTS_AVAILABLE && ( + + Coming soon + + )} +
+

{description}

+
+
+
+ +
+
+ ); +} + +export default function DownloadMyDataSection() { + return ( + + + +
+ +

Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.

+
+
+
+

What's included

+
    + {['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => ( +
  • + {i} +
  • + ))} +
+
+
+

What's not included

+
    + {['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => ( +
  • + {i} +
  • + ))} +
+
+
+
+ ); +} diff --git a/client/components/data/ImportHistorySection.jsx b/client/components/data/ImportHistorySection.jsx new file mode 100644 index 0000000..32746b8 --- /dev/null +++ b/client/components/data/ImportHistorySection.jsx @@ -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 ( + +
Loading…
+
+ ); + } + + const rows = history ?? []; + + return ( + +
+

+ {rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`} +

+ +
+ {rows.length > 0 && ( +
+ + + + {['Date','File','Sheet','Parsed','Created','Updated','Skipped','Errors'].map(h => ( + + ))} + + + + {rows.map(r => ( + + + + + + + + + + + ))} + +
{h}
+ {fmt(r.imported_at)} + {r.source_filename || '—'}{r.sheet_name || '—'}{r.rows_parsed}{r.rows_created}{r.rows_updated}{r.rows_skipped}{r.rows_errored}
+
+ )} +
+ ); +} diff --git a/client/components/data/ImportMyDataSection.jsx b/client/components/data/ImportMyDataSection.jsx new file mode 100644 index 0000000..6a94d31 --- /dev/null +++ b/client/components/data/ImportMyDataSection.jsx @@ -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 ( + <> + +
+
+
+ +
+

Import a SQLite data export created by this app.

+

+ This is not a full system restore. Existing records are skipped by default, and admin/system data is never imported. +

+
+
+
+ +
+ +
+ + +
+
+ + {preview.status === 'error' && ( +
+ + {preview.error?.message || 'SQLite import preview failed.'} + {preview.error?.details?.length > 0 && ( +
    + {preview.error.details.map((d, i) => ( +
  • {d.message || d.table || JSON.stringify(d)}
  • + ))} +
+ )} +
+ )} + + {preview.status === 'ready' && preview.data && ( +
+
+
+
+

Preview ready

+

+ Exported {fmt(preview.data.metadata?.exported_at)} · {preview.data.source_filename || 'SQLite export'} +

+
+ + User data only + +
+
+ + + + + +
+
+ {Object.entries(summary).filter(([, v]) => v && typeof v === 'object').map(([key, value]) => ( +
+

{key.replace(/_/g, ' ')}

+

+ create {value.create || 0} · skip {value.skip || 0} · conflict {value.conflict || 0} +

+
+ ))} +
+ {preview.data.warnings?.length > 0 && ( +
+ {preview.data.warnings.map((warning, i) => ( +

+ {warning} +

+ ))} +
+ )} +
+ +
+

Review the preview before applying. Nothing is imported until you confirm.

+ +
+
+ )} + + {applyState.status === 'done' && applyState.result && ( +
+

SQLite import applied

+
+ + + + +
+
+ )} + + {applyState.status === 'error' && ( +
+ {applyState.error?.message || 'SQLite import apply failed.'} +
+ )} +
+
+ {/* Import confirmation dialog */} + + + + Import SQLite data export? + + Import this SQLite data export into your account? Existing records will be skipped by default. + + + + Cancel + + Confirm Import + + + + + + ); +} diff --git a/client/components/data/ImportSpreadsheetSection.jsx b/client/components/data/ImportSpreadsheetSection.jsx new file mode 100644 index 0000000..3e4038c --- /dev/null +++ b/client/components/data/ImportSpreadsheetSection.jsx @@ -0,0 +1,1435 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { toast } from 'sonner'; +import { + Upload, FileSpreadsheet, AlertTriangle, CheckCircle2, CheckCheck, + Loader2, RefreshCw, ChevronDown, ChevronUp, SkipForward, Plus, + List, Building2, ChevronLeft, FileText, XCircle, Sparkles, +} 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 { Switch } from '@/components/ui/switch'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { SectionCard, CountPill, fmt, importErrorState } from './dataShared'; + +function groupRowsBySheet(rows) { + const map = new Map(); + for (const row of rows) { + const key = row.sheet_name || '(unknown sheet)'; + if (!map.has(key)) map.set(key, []); + map.get(key).push(row); + } + return Array.from(map.entries()).map(([name, rows]) => ({ name, rows })); +} + +function initialDecisionFromRecommendation(row) { + const rec = row.recommendation || {}; + const action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null); + + if (!action || row.requires_user_decision) return { action: null }; + if (action === 'skip_row') return { action: 'skip_row' }; + if (action === 'match_existing_bill') { + return { + action, + bill_id: rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null, + bill_name: null, + due_day: rec.due_day ?? null, + actual_amount: rec.actual_amount ?? row.detected_amount ?? null, + payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null, + payment_date: rec.payment_date ?? row.detected_paid_date ?? null, + notes: row.detected_notes ?? null, + }; + } + if (action === 'create_new_bill') { + return { + action, + bill_id: null, + bill_name: rec.bill_name || row.detected_bill_name || '', + category_id: rec.category_id ?? null, + due_day: rec.due_day ?? null, + expected_amount: rec.expected_amount ?? row.detected_amount ?? null, + actual_amount: rec.actual_amount ?? row.detected_amount ?? null, + payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null, + payment_date: rec.payment_date ?? row.detected_paid_date ?? null, + notes: row.detected_notes ?? null, + }; + } + return { action }; +} + +function safeRawBillName(row) { + const raw = row.raw_values?.find((v) => { + const text = String(v || '').trim(); + if (!text || text.length > 80) return false; + if (/^(?:total|subtotal|sum|grand\s*total)$/i.test(text)) return false; + if (/^\$?\(?\d[\d,]*(?:\.\d{1,2})?\)?$/.test(text)) return false; + if (/^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(text)) return false; + if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return false; + return true; + }); + return raw ? String(raw).trim() : ''; +} + +function buildCreateNewDecision(row, currentDecision = {}) { + const rec = row.recommendation || {}; + const billName = currentDecision.bill_name + || row.detected_bill_name + || rec.bill_name + || safeRawBillName(row); + + return { + ...currentDecision, + action: 'create_new_bill', + previous_match_bill_id: currentDecision.bill_id ?? currentDecision.previous_match_bill_id ?? rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null, + bill_id: null, + bill_name: billName, + category_id: currentDecision.category_id ?? rec.category_id ?? null, + due_day: currentDecision.due_day ?? rec.due_day ?? null, + expected_amount: currentDecision.expected_amount ?? rec.expected_amount ?? row.detected_amount ?? null, + actual_amount: currentDecision.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null, + payment_amount: currentDecision.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null, + payment_date: currentDecision.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null, + notes: currentDecision.notes ?? row.detected_notes ?? null, + }; +} + +function buildInitialDecisions(rows) { + const d = {}; + for (const row of rows) { + const hasError = row.errors?.length > 0; + if (hasError || row.proposed_action === 'skip_row') { + d[row.row_id] = { action: 'skip_row' }; + } else { + d[row.row_id] = initialDecisionFromRecommendation(row); + } + } + return d; +} + +function isDecisionComplete(action, decision) { + if (!action) return false; + if (action === 'skip_row') return true; + if (action === 'create_new_bill') return !!(decision?.bill_name?.trim()); + if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note', 'create_payment'].includes(action)) { + return !!decision?.bill_id; + } + return true; +} + +// ─── Badges ─────────────────────────────────────────────────────────────────── + +function SourceBadge({ source }) { + const MAP = { + row_date: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', + sheet_name: 'bg-blue-500/15 text-blue-600 dark:text-blue-400', + default: 'bg-amber-500/15 text-amber-600 dark:text-amber-500', + ambiguous: 'bg-red-500/15 text-red-600 dark:text-red-400', + }; + const LABELS = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' }; + return ( + + {LABELS[source] ?? source} + + ); +} + +function ConfidenceBadge({ confidence }) { + const MAP = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' }; + return {confidence}; +} + +function actionLabel(action) { + const MAP = { + match_existing_bill: 'Match existing bill', + create_new_bill: 'Create new bill', + skip_row: 'Skip row', + ambiguous: 'Needs decision', + update_monthly_state: 'Update monthly record', + add_monthly_note: 'Add monthly note', + create_payment: 'Record as payment', + }; + return MAP[action] || (action ? action.replace(/_/g, ' ') : 'Needs decision'); +} + + +function SheetStatusBadge({ status }) { + const MAP = { + parsed: 'bg-emerald-500/15 text-emerald-600', + parsed_month_only: 'bg-amber-500/15 text-amber-600', + ambiguous: 'bg-orange-500/15 text-orange-600', + skipped: 'bg-muted text-muted-foreground', + }; + const LABELS = { + parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped', + }; + return ( + + {LABELS[status] ?? status} + + ); +} +function WorkbookSummaryCard({ workbook }) { + const isMulti = workbook.parse_mode === 'all_sheets'; + + return ( +
+
+

Workbook Summary

+ + {isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names.length} tabs` : `${workbook.row_count} rows · ${workbook.selected_sheet}`} + +
+ {isMulti && workbook.sheets?.length > 0 && ( +
+ {workbook.sheets.map(s => ( +
+ {s.name} +
+ {s.detected_year && s.detected_month && ( + + {String(s.detected_month).padStart(2,'0')}/{s.detected_year} + + )} + + {s.status !== 'skipped' && {s.row_count} rows} +
+
+ ))} +
+ )} +
+ ); +} + +// ─── XLSX Import: Row Decision Controls ────────────────────────────────────── + +const ACTIONS_NEEDING_BILL = new Set(['match_existing_bill','update_monthly_state','add_monthly_note','create_payment']); + +function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, selected, onSelectedChange }) { + const [expanded, setExpanded] = useState(row.requires_user_decision || !decision?.action); + + const action = decision?.action ?? null; + const isSkip = action === 'skip_row'; + const hasError = row.errors?.length > 0; + const complete = isDecisionComplete(action, decision); + const rec = row.recommendation || {}; + + const suggestedBills = row.possible_bill_matches ?? []; + const suggestedIds = new Set(suggestedBills.map(b => b.bill_id)); + const otherBills = allBills.filter(b => !suggestedIds.has(b.id)); + + const handleAction = (val) => { + const next = { ...decision, action: val }; + if (val === 'create_new_bill') { + Object.assign(next, buildCreateNewDecision(row, decision)); + } else if (ACTIONS_NEEDING_BILL.has(val)) { + next.bill_name = null; + next.bill_id = decision?.bill_id ?? decision?.previous_match_bill_id ?? rec.bill_id ?? suggestedBills[0]?.bill_id ?? null; + next.actual_amount = decision?.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null; + next.payment_amount = decision?.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null; + next.payment_date = decision?.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null; + } else { + next.bill_id = null; + next.bill_name = null; + } + onDecisionChange(row.row_id, next); + if (val === 'skip_row') setExpanded(false); + }; + + const handleBill = (e) => { + onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null }); + }; + + const handleBillName = (e) => { + onDecisionChange(row.row_id, { ...decision, bill_name: e.target.value }); + }; + + const handleDecisionField = (field, value) => { + onDecisionChange(row.row_id, { ...decision, [field]: value }); + }; + + return ( +
+ {/* Main row */} +
setExpanded(e => !e)} + > + {/* Selection */} +
e.stopPropagation()}> + onSelectedChange(row.row_id, e.target.checked)} + aria-label={`Select row ${row.source_row_number}`} + className="h-4 w-4 rounded border-border accent-primary" + /> +
+ + {/* Status icon */} +
+ {hasError ? : + isSkip ? : + complete ? : + action !== null ? : + } +
+ + {/* Content */} +
+
+ #{row.source_row_number} + {row.sheet_name && {row.sheet_name}} + {row.detected_year && row.detected_month && ( + + {String(row.detected_month).padStart(2,'0')}/{row.detected_year} + + )} + {row.year_month_source && } +
+
+ + {row.detected_bill_name || '(no bill name)'} + + {row.detected_amount != null && ( + + ${row.detected_amount.toFixed(2)} + + )} + {row.detected_paid_date && ( + + paid {row.detected_paid_date} + + )} + {row.detected_labels?.length > 0 && ( + {row.detected_labels.join(', ')} + )} + {row.detected_notes && ( + {row.detected_notes} + )} +
+
+ + {/* Right: action status + expand */} +
+ {action === null ? ( + Needs decision + ) : isSkip ? ( + Skipped + ) : ( + {action.replace(/_/g,' ')} + )} + {action !== 'skip_row' && ( + expanded ? : + )} +
+
+ + {/* Expanded decision controls */} + {expanded && !hasError && ( +
+ {/* Recommendation */} + {rec.action && ( +
+
+ Recommended: {actionLabel(rec.action)} + {rec.bill_name && rec.action === 'match_existing_bill' && ( + → {rec.bill_name} + )} + {rec.category_name && ( + Category: {rec.category_name} + )} + {rec.due_day && Due day: {rec.due_day}} + {rec.actual_amount != null && ${Number(rec.actual_amount).toFixed(2)}} + +
+ {rec.reason &&

Reason: {rec.reason}

} +
+ )} + + {/* Warnings */} + {(rec.warnings?.length > 0 || row.warnings?.length > 0) && ( +
+ {Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => ( +

+ {w} +

+ ))} +
+ )} + + {/* Possible matches hint */} + {suggestedBills.length > 0 && ( +
+ Suggested: + {suggestedBills.slice(0, 3).map(b => ( + + ))} +
+ )} + + {/* Action selector */} +
+ + +
+ + {/* Bill selector (for actions that need a bill) */} + {ACTIONS_NEEDING_BILL.has(action) && ( +
+ + +
+ )} + + {/* Bill name input for create_new_bill */} + {action === 'create_new_bill' && ( +
+
+ + +
+
+ + + {rec.category_name && Suggested: {rec.category_name}} +
+
+ + handleDecisionField('due_day', e.target.value ? parseInt(e.target.value, 10) : null)} + placeholder="Due day" + className="h-8 text-sm w-24" + /> + handleDecisionField('expected_amount', e.target.value === '' ? null : parseFloat(e.target.value))} + placeholder="Expected amount" + className="h-8 text-sm w-40" + /> +
+
+ )} + + {action && action !== 'skip_row' && ( +
+ + handleDecisionField('payment_date', e.target.value || null)} + className="h-8 text-sm w-40" + /> + handleDecisionField('payment_amount', e.target.value === '' ? null : parseFloat(e.target.value))} + placeholder="Paid amount" + className="h-8 text-sm w-36" + /> +
+ )} + + {/* Quick skip */} + {action !== 'skip_row' && ( + + )} +
+ )} +
+ ); +} + +// ─── XLSX Import: Preview Table ─────────────────────────────────────────────── + +function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }) { + const groups = groupRowsBySheet(rows); + const multiTab = groups.length > 1; + + return ( +
+ {groups.map(({ name, rows: groupRows }) => ( +
+ {multiTab && ( +
+ + {name} + · {groupRows.length} rows +
+ )} + {groupRows.map(row => ( + + ))} +
+ ))} +
+ ); +} + +function BulkActionBar({ + rows, + selectedRows, + onSelectAll, + onClearSelection, + onBulkSkip, + onBulkCreateNew, + onBulkReset, +}) { + const allSelected = rows.length > 0 && rows.every(r => selectedRows.has(r.row_id)); + const selectedCount = selectedRows.size; + + return ( +
+
+ + +
+ {selectedCount > 0 && ( + {selectedCount} row{selectedCount === 1 ? '' : 's'} selected + )} + {selectedCount > 0 && ( + <> + + + + + + )} +
+
+
+ ); +} + +// ─── Section 1: Import Spreadsheet History ──────────────────────────────────── + +const INITIAL_OPTIONS = { + parseAllSheets: true, + defaultYear: new Date().getFullYear(), + defaultMonth: '', +}; + +// ─── Bill History Import helpers ────────────────────────────────────────────── + +function ConfidenceDot({ level }) { + const cls = level === 'high' ? 'bg-emerald-500' + : level === 'medium' ? 'bg-amber-500' + : 'bg-muted-foreground/30'; + return ; +} + +function useBillGroups(previewRows, allBills) { + return useMemo(() => { + const billMap = new Map(allBills.map(b => [b.id, b])); + const groups = new Map(); + + for (const row of previewRows) { + for (const match of (row.possible_bill_matches ?? [])) { + if (!billMap.has(match.bill_id)) continue; + if (!groups.has(match.bill_id)) { + groups.set(match.bill_id, { + bill: billMap.get(match.bill_id), + rows: [], + counts: { high: 0, medium: 0, low: 0 }, + }); + } + const g = groups.get(match.bill_id); + if (!g.rows.find(r => r.row_id === row.row_id)) { + g.rows.push({ ...row, _match: match }); + g.counts[match.match_confidence] = (g.counts[match.match_confidence] || 0) + 1; + } + } + } + return [...groups.values()].sort((a, b) => + b.rows.length !== a.rows.length ? b.rows.length - a.rows.length : b.counts.high - a.counts.high + ); + }, [previewRows, allBills]); +} + +function rowDateLabel(row) { + if (row.detected_year && row.detected_month) + return `${row.detected_year}-${String(row.detected_month).padStart(2, '0')}`; + return row.detected_paid_date ?? '—'; +} + +function billImportProgress(rows, importResult) { + const completedRowIds = importResult?.completedRowIds ?? new Set(); + const remainingRows = rows.filter(row => !completedRowIds.has(row.row_id)); + return { + completedCount: rows.length - remainingRows.length, + remainingRows, + remainingCount: remainingRows.length, + }; +} + +function detailImportedAnything(detail) { + return ['created', 'updated', 'overwritten', 'imported'].includes(detail?.result) + || detail?.payment === 'created'; +} + +function detailCompletesImport(detail) { + if (!detail?.row_id) return false; + if (['error', 'ambiguous', 'skipped_conflict'].includes(detail.result)) return false; + if (detail.result === 'skipped') return false; + return detailImportedAnything(detail) + || detail.result === 'skipped_duplicate' + || detail.payment === 'skipped_duplicate'; +} + +function BillDetailView({ group, onBack, onImport, isImporting, importResult }) { + const { bill, rows } = group; + const { completedCount, remainingCount } = billImportProgress(rows, importResult); + const sorted = [...rows].sort((a, b) => { + const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0); + const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0); + return da - db; + }); + + return ( +
+
+ + {bill.name} +
+ {importResult && ( +
+ + {completedCount === rows.length + ? 'All imported' + : `${completedCount} imported · ${remainingCount} remaining`} + {importResult.duplicates > 0 + && ` · ${importResult.duplicates} dupes`} + + {importResult.duplicates > 0 && importResult.earliestDup && ( +

+ {importResult.duplicates} dup{importResult.duplicates > 1 ? 's' : ''} · recorded{' '} + {importResult.earliestDup.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} +

+ )} +
+ )} + +
+
+
+ {sorted.map(row => ( +
+ + {rowDateLabel(row)} + + {row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'} + + {row.detected_name ?? '—'} + + {row._match.match_confidence} + +
+ ))} +
+
+ ); +} + +function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }) { + const [selectedBillId, setSelectedBillId] = useState(null); + const billGroups = useBillGroups(previewRows, allBills); + + if (billGroups.length === 0) { + return ( +
+ No existing bills matched rows in this file. +
+ ); + } + + if (selectedBillId) { + const group = billGroups.find(g => g.bill.id === selectedBillId); + return group + ? setSelectedBillId(null)} + onImport={() => onImportBill(group)} /> + : null; + } + + return ( +
+ {billGroups.map(g => { + const { bill, rows, counts } = g; + const isImporting = importingBillId === bill.id; + const importResult = billImportResults.get(bill.id) ?? null; + const { completedCount, remainingCount } = billImportProgress(rows, importResult); + + const sorted3 = [...rows] + .sort((a, b) => { + const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0); + const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0); + return da - db; + }) + .slice(0, 3); + + return ( +
+
+
+ {bill.name} + + {rows.length} row{rows.length !== 1 ? 's' : ''} + + {counts.high > 0 && {counts.high} high} + {counts.medium > 0 && {counts.medium} med} + {counts.low > 0 && {counts.low} low} + {importResult && (() => { + const allImported = completedCount === rows.length; + const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : ''; + return ( +
+ + {allImported ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`} + {importResult.duplicates > 0 && ` · ${importResult.duplicates} dupes`} + {importResult.errored > 0 && ` · ${importResult.errored} errors`} + + {importResult.duplicates > 0 && ( +

+ {importResult.duplicates} duplicate{importResult.duplicates > 1 ? 's' : ''}{dupDate} +

+ )} +
+ ); + })()} +
+
+ {sorted3.map(row => ( +
+ + {rowDateLabel(row)} + {row.detected_amount != null && ( + ${Number(row.detected_amount).toFixed(2)} + )} + {row.detected_name && + row.detected_name.toLowerCase() !== bill.name.toLowerCase() && ( + "{row.detected_name}" + )} +
+ ))} + {rows.length > 3 && ( + + )} +
+
+
+ {importResult ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── + +export default function ImportSpreadsheetSection({ onHistoryRefresh }) { + const fileRef = useRef(null); + const [file, setFile] = useState(null); + const [options, setOptions] = useState(INITIAL_OPTIONS); + const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); + const [decisions, setDecisions] = useState({}); + const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null }); + const [allBills, setAllBills] = useState([]); + const [categories, setCategories] = useState([]); + const [selectedRows, setSelectedRows] = useState(new Set()); + const [viewMode, setViewMode] = useState('rows'); // 'rows' | 'bills' + const [importingBillId, setImportingBillId] = useState(null); + const [billImportResults, setBillImportResults] = useState(new Map()); // bill_id → { created, updated, errored } + + // Load bills/categories for the decision controls + useEffect(() => { + api.bills().then(setAllBills).catch(() => {}); + api.categories().then(setCategories).catch(() => {}); + }, []); + + const opt = (k, v) => setOptions(prev => ({ ...prev, [k]: v })); + + // ── Preview ────────────────────────────────────────────────────────────────── + const handlePreview = async () => { + if (!file) return; + setPreview({ status: 'loading', data: null, error: null }); + setDecisions({}); + setSelectedRows(new Set()); + setApplyState({ status: 'idle', result: null, error: null }); + setViewMode('rows'); + setImportingBillId(null); + setBillImportResults(new Map()); + try { + const data = await api.previewSpreadsheetImport(file, { + parseAllSheets: options.parseAllSheets, + defaultYear: options.defaultYear ? parseInt(options.defaultYear, 10) : null, + defaultMonth: options.defaultMonth ? parseInt(options.defaultMonth, 10) : null, + }); + setPreview({ status: 'ready', data, error: null }); + setDecisions(buildInitialDecisions(data.rows)); + } catch (err) { + setPreview({ status: 'error', data: null, error: importErrorState(err, 'Preview failed.') }); + } + }; + + // ── Decision update ────────────────────────────────────────────────────────── + const handleDecisionChange = (rowId, decision) => { + setDecisions(prev => ({ ...prev, [rowId]: decision })); + }; + + const handleSelectedChange = (rowId, selected) => { + setSelectedRows(prev => { + const next = new Set(prev); + if (selected) next.add(rowId); + else next.delete(rowId); + return next; + }); + }; + + const clearSelection = () => setSelectedRows(new Set()); + + // ── Bill-history direct import ──────────────────────────────────────────── + // Applies all matching rows for a bill immediately — no queue, no review step. + const handleDirectImportBill = async (group) => { + const sessionId = preview.data?.import_session_id; + if (!sessionId || importingBillId) return; + + const previousResult = billImportResults.get(group.bill.id) ?? null; + const rowsToImport = billImportProgress(group.rows, previousResult).remainingRows; + if (rowsToImport.length === 0) { + toast.info(`All rows for "${group.bill.name}" have already been imported.`); + return; + } + + setImportingBillId(group.bill.id); + try { + const decisionsList = rowsToImport.map(row => ({ + row_id: row.row_id, + action: 'match_existing_bill', + bill_id: group.bill.id, + actual_amount: row.detected_amount ?? null, + payment_amount: row.detected_payment_amount ?? row.detected_amount ?? null, + payment_date: row.detected_paid_date ?? null, + })); + + const result = await api.applySpreadsheetImport({ + import_session_id: sessionId, + decisions: decisionsList, + options: {}, + }); + + const created = result.rows_created ?? 0; + const updated = result.rows_updated ?? 0; + const errored = result.rows_errored ?? 0; + const details = result.details ?? []; + const duplicateRowIds = new Set( + details + .filter(d => d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') + .map(d => d.row_id) + .filter(Boolean), + ); + const duplicates = duplicateRowIds.size || (result.rows_duplicates ?? 0); + + // Collect created_at dates from duplicate detail entries so we can show + // when the existing payments were originally recorded. + const dupDates = details + .filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at) + .map(d => new Date(d.existing_created_at)) + .filter(d => !isNaN(d.getTime())) + .sort((a, b) => a - b); + + const earliestDup = dupDates[0] ?? null; + const latestDup = dupDates.at(-1) ?? null; + const completedRowIds = new Set(previousResult?.completedRowIds ?? []); + const erroredRowIds = new Set(previousResult?.erroredRowIds ?? []); + + for (const detail of details) { + if (detailCompletesImport(detail)) { + completedRowIds.add(detail.row_id); + erroredRowIds.delete(detail.row_id); + } else if (['error', 'ambiguous', 'skipped_conflict'].includes(detail?.result)) { + erroredRowIds.add(detail.row_id); + } + } + + const mergedResult = { + created: (previousResult?.created ?? 0) + created, + updated: (previousResult?.updated ?? 0) + updated, + errored: erroredRowIds.size, + duplicates: (previousResult?.duplicates ?? 0) + duplicates, + earliestDup: previousResult?.earliestDup && earliestDup + ? (previousResult.earliestDup < earliestDup ? previousResult.earliestDup : earliestDup) + : (previousResult?.earliestDup ?? earliestDup), + latestDup: previousResult?.latestDup && latestDup + ? (previousResult.latestDup > latestDup ? previousResult.latestDup : latestDup) + : (previousResult?.latestDup ?? latestDup), + completedRowIds, + erroredRowIds, + }; + + setBillImportResults(prev => new Map(prev).set(group.bill.id, mergedResult)); + + const imported = created + updated; + const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount; + + if (imported === 0 && duplicates > 0) { + const dateHint = earliestDup + ? ` (first recorded ${fmtDate(earliestDup)})` + : ''; + toast.warning( + remainingCount === 0 + ? `All rows for "${group.bill.name}" are now imported${dateHint}.` + : `${duplicates} row${duplicates > 1 ? 's' : ''} for "${group.bill.name}" already exist${dateHint}. ${remainingCount} remaining.`, + ); + } else { + const parts = [`${imported} entr${imported === 1 ? 'y' : 'ies'} imported`]; + if (duplicates > 0) { + const dateHint = earliestDup ? ` (recorded ${fmtDate(earliestDup)})` : ''; + parts.push(`${duplicates} already existed${dateHint}`); + } + if (errored > 0) parts.push(`${errored} error${errored > 1 ? 's' : ''}`); + if (remainingCount > 0) parts.push(`${remainingCount} remaining`); + toast.success(`${group.bill.name} — ${parts.join(' · ')}`); + } + onHistoryRefresh?.(); + } catch (err) { + toast.error(err.message || `Import failed for "${group.bill.name}"`); + } finally { + setImportingBillId(null); + } + }; + + const selectAllVisibleRows = () => { + setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id))); + }; + + const selectedPreviewRows = () => { + const selected = selectedRows; + return (preview.data?.rows || []).filter(r => selected.has(r.row_id)); + }; + + const handleBulkSkip = () => { + const rows = selectedPreviewRows(); + setDecisions(prev => { + const next = { ...prev }; + rows.forEach(row => { + next[row.row_id] = { ...(next[row.row_id] || {}), action: 'skip_row', bill_id: null, bill_name: null }; + }); + return next; + }); + toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} marked skipped.`); + }; + + const handleBulkCreateNew = () => { + const rows = selectedPreviewRows(); + let missingNames = 0; + setDecisions(prev => { + const next = { ...prev }; + rows.forEach(row => { + const decision = buildCreateNewDecision(row, next[row.row_id] || {}); + if (!decision.bill_name?.trim()) missingNames++; + next[row.row_id] = decision; + }); + return next; + }); + if (missingNames > 0) { + toast.warning(`${missingNames} selected row${missingNames === 1 ? '' : 's'} still need a bill name.`); + } else { + toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} set to create new bills.`); + } + }; + + const handleBulkReset = () => { + const rows = selectedPreviewRows(); + setDecisions(prev => { + const next = { ...prev }; + rows.forEach(row => { + next[row.row_id] = initialDecisionFromRecommendation(row); + }); + return next; + }); + toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} reset to recommendation.`); + }; + + const buildApplyDecision = (row, d) => { + if (!d?.action) return null; + + const base = { + row_id: row.row_id, + action: d.action, + actual_amount: d.actual_amount ?? row.detected_amount ?? undefined, + year: row.detected_year ?? undefined, + month: row.detected_month ?? undefined, + notes: d.notes ?? row.detected_notes ?? undefined, + payment_amount: d.payment_amount ?? row.detected_payment_amount ?? undefined, + payment_date: d.payment_date ?? row.detected_paid_date ?? undefined, + }; + + if (d.action === 'create_new_bill') { + return { + ...base, + bill_name: d.bill_name?.trim() || undefined, + category_id: d.category_id ?? undefined, + due_day: d.due_day ?? undefined, + expected_amount: d.expected_amount ?? undefined, + }; + } + + if (ACTIONS_NEEDING_BILL.has(d.action)) { + return { + ...base, + bill_id: d.bill_id ?? undefined, + }; + } + + return base; + }; + + // ── Apply ──────────────────────────────────────────────────────────────────── + const handleApply = async () => { + if (!preview.data) return; + setApplyState({ status: 'loading', result: null, error: null }); + try { + const decisionsList = preview.data.rows + .map(row => { + const d = decisions[row.row_id]; + if (d?.action === 'skip_row') return null; + return buildApplyDecision(row, d); + }) + .filter(Boolean); + + if (decisionsList.length === 0) { + throw new Error('No rows are selected to import. Choose at least one row to match or create, or keep the preview for review.'); + } + + const result = await api.applySpreadsheetImport({ + import_session_id: preview.data.import_session_id, + decisions: decisionsList, + options: { reviewed_skipped_count: skipRows.length }, + }); + setApplyState({ status: 'done', result, error: null }); + setSelectedRows(new Set()); + toast.success(`Import applied — ${result.rows_created} created, ${result.rows_updated} updated.`); + onHistoryRefresh(); + } catch (err) { + const errorState = importErrorState(err, 'Apply failed.'); + setApplyState({ status: 'error', result: null, error: errorState }); + toast.error(errorState.message || 'Apply failed.'); + } + }; + + // ── Reset ──────────────────────────────────────────────────────────────────── + const handleReset = () => { + setFile(null); + setOptions(INITIAL_OPTIONS); + setPreview({ status: 'idle', data: null, error: null }); + setDecisions({}); + setSelectedRows(new Set()); + setApplyState({ status: 'idle', result: null, error: null }); + setViewMode('rows'); + setImportingBillId(null); + setBillImportResults(new Map()); + if (fileRef.current) fileRef.current.value = ''; + }; + + // ── Derived state ──────────────────────────────────────────────────────────── + const previewRows = preview.data?.rows ?? []; + const unresolvedRows = previewRows.filter(r => { + const d = decisions[r.row_id]; + return !d?.action || !isDecisionComplete(d.action, d); + }); + const pendingRows = previewRows.filter(r => decisions[r.row_id]?.action && decisions[r.row_id]?.action !== 'skip_row'); + const skipRows = previewRows.filter(r => decisions[r.row_id]?.action === 'skip_row'); + const canApply = unresolvedRows.length === 0 && pendingRows.length > 0 && applyState.status !== 'loading'; + + // ── Render ──────────────────────────────────────────────────────────────────── + return ( + + + {/* ── Upload panel ──────────────────────────────────────────────────────── */} +
+ + {/* File picker */} +
+ +
+ setFile(e.target.files?.[0] ?? null)} /> + + {file && ( + + {file.name} + + )} +
+
+ + {/* Options */} +
+
+ opt('parseAllSheets', v)} + id="parse-all" /> + +
+
+ + opt('defaultYear', e.target.value)} + className="w-24 h-8 text-sm" /> +
+ {!options.parseAllSheets && ( +
+ + opt('defaultMonth', e.target.value)} + className="w-20 h-8 text-sm" /> +
+ )} +
+ + {/* Preview button */} +
+ + {(preview.status === 'ready' || preview.status === 'error' || applyState.status !== 'idle') && ( + + )} +
+ + {/* Error from preview */} + {preview.status === 'error' && ( +
+ {preview.error?.message || preview.error || 'Preview failed.'} + {preview.error?.details?.length > 0 && ( +
    + {preview.error.details.map((d, i) => ( +
  • {d.row_id ? `${d.row_id}: ` : ''}{d.message}
  • + ))} +
+ )} +
+ )} +
+ + {/* ── Preview results ────────────────────────────────────────────────────── */} + {preview.status === 'ready' && preview.data && !['loading', 'done'].includes(applyState.status) && ( +
+ + {/* Workbook summary */} + + + {/* Row decision table */} + {previewRows.length > 0 ? ( +
+ {/* Tab header */} +
+
+ + +
+ + {viewMode === 'rows' + ? 'Select rows, apply bulk decisions, then import.' + : 'Click a bill to queue its entire history from this file.'} + +
+ + {/* Rows view */} + {viewMode === 'rows' && ( + <> + + + + )} + + {/* Bills view */} + {viewMode === 'bills' && ( + + )} +
+ ) : ( +

No data rows found in this file.

+ )} + + {/* Apply bar */} + {previewRows.length > 0 && ( +
+
+ {previewRows.length} rows reviewed + {pendingRows.length} to apply + {skipRows.length} skipped + {unresolvedRows.length > 0 && ( + {unresolvedRows.length} need a decision + )} +
+ +
+ )} +
+ )} + + {/* ── Applying ──────────────────────────────────────────────────────────── */} + {applyState.status === 'loading' && ( +
+ + Applying import… +
+ )} + + {/* ── Apply result ──────────────────────────────────────────────────────── */} + {applyState.status === 'done' && applyState.result && ( +
+
+
+ +

Import applied successfully

+
+
+ {[ + { label: 'Created', value: applyState.result.rows_created, color: 'text-emerald-600' }, + { label: 'Updated', value: applyState.result.rows_updated, color: 'text-blue-600' }, + { label: 'Skipped', value: applyState.result.rows_skipped, color: 'text-muted-foreground' }, + { label: 'Errors', value: applyState.result.rows_errored, color: 'text-red-500' }, + ].map(({ label, value, color }) => ( +
+

{value}

+

{label}

+
+ ))} +
+
+ +
+ )} + + {/* ── Apply error ───────────────────────────────────────────────────────── */} + {applyState.status === 'error' && ( +
+
+ {applyState.error?.message || applyState.error || 'Apply failed.'} + {applyState.error?.details?.length > 0 && ( +
    + {applyState.error.details.map((d, i) => ( +
  • + {d.row_id ? `${d.row_id}: ` : ''} + {d.field ? `${d.field} - ` : ''} + {d.message} +
  • + ))} +
+ )} + {applyState.error?.error_id && ( +

Error ID: {applyState.error.error_id}

+ )} +
+
+ )} +
+ ); +} + +// ─── DataPage ───────────────────────────────────────────────────────────────── diff --git a/client/components/data/ImportTransactionCsvSection.jsx b/client/components/data/ImportTransactionCsvSection.jsx new file mode 100644 index 0000000..9996ad0 --- /dev/null +++ b/client/components/data/ImportTransactionCsvSection.jsx @@ -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 ( +
+ {CSV_IMPORT_STEPS.map((step, index) => { + const complete = index < activeIndex; + const active = index === activeIndex; + return ( +
+ + {complete ? : index + 1} + + {step} +
+ ); + })} +
+ ); +} + +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 ( +
+
+
+

{label}

+ + {requirement} + +
+

{field}

+
+ +
+ +
+ {current && current === suggested && ( + Suggested match + )} + {suggestedAvailable && !disabled && ( + + )} + {missingRequired && ( + Needs a column + )} +
+
+ +
+ {samples.length > 0 ? ( +
+ {samples.map(value => ( + + {value} + + ))} +
+ ) : ( +

+ {current ? 'No sample values' : 'Map a column to preview values'} +

+ )} +
+
+ ); +} + +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 ( +
+
+
+

Column mapping

+

+ {mappedCount} of {mappingFields.length} fields mapped + {missingRequired.length > 0 && ` · missing ${missingRequired.join(' and ')}`} +

+
+
+ + +
+
+ +
+ Field + CSV Column + Sample Values +
+ +
+ {mappingFields.map(field => ( + + ))} +
+
+ ); +} + +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

No sample rows found.

; + } + + return ( +
+ + + + {visibleHeaders.map(header => ( + + ))} + {hiddenCount > 0 && ( + + )} + + + + {sampleRows.map((row, index) => ( + + {visibleHeaders.map(header => ( + + ))} + {hiddenCount > 0 && ( + + )} + + ))} + +
{header}+{hiddenCount}
+ {row[header] || '—'} + more columns
+
+ ); +} + +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 ( + +
+
+
+ +
+

Import transaction rows from CSV.

+

+ This importer creates shared transaction records only. It does not match transactions to bills yet. +

+
+
+
+ + + +
+
+ +
+ + +
+
+
+ + {preview.status === 'error' && ( +
+ + {preview.error?.message || 'CSV preview failed.'} + {preview.error?.details?.length > 0 && ( +
    + {preview.error.details.map((d, i) => ( +
  • {d.message || JSON.stringify(d)}
  • + ))} +
+ )} +
+ )} + + {preview.status === 'ready' && preview.data && ( +
+
+
+
+

CSV Preview

+

{file?.name || 'Transaction CSV'}

+
+
+ + + +
+
+ + {preview.data.errors?.length > 0 && ( +
+

Review mapping

+
    + {preview.data.errors.map((issue, i) => ( +
  • + + {issue.message || JSON.stringify(issue)} +
  • + ))} +
+
+ )} + + +
+ + + +
+
+

+ {canCommitCsvMapping(mapping) ? 'Ready to commit' : 'Mapping incomplete'} +

+

+ Duplicates are skipped using a CSV transaction ID when available, otherwise a stable row hash. +

+
+ {commitState.status === 'done' ? ( + + ) : ( + + )} +
+
+ )} + + {commitState.status === 'done' && commitState.result && ( +
+
+ +

CSV transaction import complete

+
+
+ + + +
+ {skippedRows.length > 0 && ( +
+

Skipped duplicates ({skippedRows.length})

+
    + {skippedRows.map(row => ( +
  • + Row {row.row}: {row.provider_transaction_id} +
  • + ))} +
+
+ )} + {failedRows.length > 0 && ( +
+

Failed rows ({failedRows.length})

+
    + {failedRows.map((row, index) => ( +
  • +

    Row {row.row}: {row.message}

    + {row.details?.length > 0 && ( +
      + {row.details.map((detail, detailIndex) => ( +
    • {formatCsvRowDetail(detail)}
    • + ))} +
    + )} +
  • + ))} +
+
+ )} +
+ )} + + {commitState.status === 'error' && ( +
+ + {commitState.error?.message || 'CSV import failed.'} + {commitState.error?.details?.length > 0 && ( +
    + {commitState.error.details.map((d, i) => ( +
  • {d.message || JSON.stringify(d)}
  • + ))} +
+ )} +
+ )} +
+
+ ); +} + +// ─── Section 3: Import My Data Export ──────────────────────────────────────── diff --git a/client/components/data/SeedDemoDataSection.jsx b/client/components/data/SeedDemoDataSection.jsx new file mode 100644 index 0000000..5de52de --- /dev/null +++ b/client/components/data/SeedDemoDataSection.jsx @@ -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 ( + +
+ {statusLoading ? ( +

Loading…

+ ) : seeded ? ( + <> +

Demo data seeded

+
+
+

Bills

+

{counts.bills}

+
+
+

Categories

+

{counts.categories}

+
+
+ + ) : ( +

+ Create 20 realistic demo bills and 8 demo categories for testing purposes. + The data will be associated with your account. +

+ )} + +
+ + + + + + + + + Clear Demo Data + + This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone. + + + + Cancel + + {clearing ? <>Clearing… : 'Clear Data'} + + + + +
+
+
+ ); +} diff --git a/client/components/data/TransactionMatchingSection.jsx b/client/components/data/TransactionMatchingSection.jsx new file mode 100644 index 0000000..4098211 --- /dev/null +++ b/client/components/data/TransactionMatchingSection.jsx @@ -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 ( + + {status} + + ); +} + +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 ( +
+
+
+ + + +
+

Suggested matches

+

{loading ? 'Checking transactions' : `${suggestions.length} ready for review`}

+
+
+
+ + {loading ? ( +
+ + Finding likely bill matches... +
+ ) : suggestions.length === 0 ? ( +
+ No suggested matches right now. +
+ ) : ( +
+ {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 ( +
+
+
+
+ + {suggestion.score} + +

{transactionTitle(tx)}

+
+

+ {transactionDate(tx)} · {tx.source_label || tx.source_type_label || 'Transaction'} +

+
+

+ {formatTransactionAmount(tx.amount, tx.currency)} +

+
+ +
+ +
+

{bill.name || `Bill ${suggestion.billId}`}

+

+ Expected ${Number(bill.expected_amount || 0).toFixed(2)} +

+
+
+ + {suggestion.reasons?.length > 0 && ( +
+ {suggestion.reasons.slice(0, 4).map(reason => ( + + {reason} + + ))} +
+ )} + +
+ + +
+
+ ); + })} +
+ )} +
+ ); +} + +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 ( + + + + Match Transaction + + Choose the bill this transaction paid. Nothing changes until you confirm. + + + + {transaction && ( +
+
+
+

{transactionTitle(transaction)}

+

+ {transactionDate(transaction)} · {transaction.source_label || transaction.source_type_label || 'Transaction'} +

+
+

+ {formatTransactionAmount(transaction.amount, transaction.currency)} +

+
+ {transaction.description && transaction.description !== transactionTitle(transaction) && ( +

{transaction.description}

+ )} +
+ )} + +
+ + +
+ {filteredBills.length === 0 ? ( +

No bills found.

+ ) : ( +
+ {filteredBills.map(bill => ( + + ))} +
+ )} +
+
+ + + + + +
+
+ ); +} + +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 ( + +
+
+
+ {TRANSACTION_FILTERS.map(item => ( + + ))} +
+ +
+ + + +
+ {loading ? ( +
Loading transactions…
+ ) : transactions.length === 0 ? ( +
+ No transactions found for this filter. +
+ ) : ( + + + + + + + + + + + + {transactions.map(tx => { + const status = transactionStatus(tx); + const busy = actionId?.endsWith(`:${tx.id}`); + return ( + + + + + + + + ); + })} + +
DateTransactionMatchAmountActions
+ {transactionDate(tx)} + +
+

{transactionTitle(tx)}

+

+ {[tx.description, tx.account_name, tx.source_label].filter(Boolean).join(' · ') || '—'} +

+
+
+
+ + {tx.matched_bill_name ? ( + {tx.matched_bill_name} + ) : ( + No bill linked + )} +
+
+ {formatTransactionAmount(tx.amount, tx.currency)} + +
+ {status === 'ignored' ? ( + + ) : ( + <> + {status === 'matched' ? ( + + ) : ( + + )} + + + )} +
+
+ )} +
+
+ + +
+ ); +} diff --git a/client/components/data/dataShared.jsx b/client/components/data/dataShared.jsx new file mode 100644 index 0000000..d5c7932 --- /dev/null +++ b/client/components/data/dataShared.jsx @@ -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 ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+ ); +} + +export function CountPill({ label, value }) { + return ( +
+

{label}

+

{value ?? 0}

+
+ ); +} diff --git a/client/components/tracker/MonthlyStateDialog.jsx b/client/components/tracker/MonthlyStateDialog.jsx new file mode 100644 index 0000000..4d8d9c4 --- /dev/null +++ b/client/components/tracker/MonthlyStateDialog.jsx @@ -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 ( + + + + + {row.name} + + {MONTHS[month - 1]} {year} + + +

+ Monthly overrides — changes only affect {MONTHS[month - 1]} +

+
+ +
+ {/* Actual amount this month */} +
+ + setActualAmount(e.target.value)} + className="font-mono bg-background/50 border-border/60" + /> +

+ Leave blank to use the template default ({fmt(row.expected_amount)}). +

+
+ + {/* Monthly notes */} +
+ + setNotes(e.target.value)} + placeholder="e.g. higher than usual, double-billed…" + className="bg-background/50 border-border/60" + /> +
+ + {/* Skip this month */} + +
+ + + + + +
+
+ ); +} + +export default MonthlyStateDialog; diff --git a/client/components/tracker/PaymentModal.jsx b/client/components/tracker/PaymentModal.jsx new file mode 100644 index 0000000..7dc2619 --- /dev/null +++ b/client/components/tracker/PaymentModal.jsx @@ -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 ( + <> + { if (!v) onClose(); }}> + + + Edit Payment + + +
+
+ + setAmount(e.target.value)} + className="font-mono bg-background/50 border-border/60" /> +
+
+ + setDate(e.target.value)} + className="font-mono bg-background/50 border-border/60" /> +
+
+ + +
+
+ + setNotes(e.target.value)} + className="bg-background/50 border-border/60" /> +
+
+ + + +
+ + +
+
+
+
+ + + + + Remove this payment? + + This marks the payment as removed and reverses any debt balance update. You can undo it from the toast. + + + + Cancel + + {busy ? 'Removing...' : 'Remove Payment'} + + + + + + ); +} + +export default PaymentModal; diff --git a/client/components/tracker/StartingAmountsEditDialog.jsx b/client/components/tracker/StartingAmountsEditDialog.jsx new file mode 100644 index 0000000..e56c352 --- /dev/null +++ b/client/components/tracker/StartingAmountsEditDialog.jsx @@ -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 ( + { if (!value) onClose(); }}> + + + Monthly Starting Amounts +

{monthName}

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + + +
+ +
+
+
+

Total starting

+

{fmt(totalStarting)}

+
+
+

Paid so far

+

{fmt(paidSoFar)}

+
+
+

Total remaining

+

{fmt(totalRemaining)}

+
+
+
+
+ 1st remaining + {fmt(firstRemaining)} +
+
+ 15th remaining + {fmt(fifteenthRemaining)} +
+
+ Other + {fmt(localOther)} +
+
+
+
+ + + + + +
+
+ ); +} + +export default StartingAmountsEditDialog; diff --git a/client/pages/AdminPage.jsx b/client/pages/AdminPage.jsx index 42e0536..74ec73e 100644 --- a/client/pages/AdminPage.jsx +++ b/client/pages/AdminPage.jsx @@ -1,1843 +1,21 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { toast } from 'sonner'; -import { - ChevronLeft, ChevronRight, Database, Download, Eye, EyeOff, - Play, RefreshCw, RotateCcw, Trash2, Upload, Wrench, -} from 'lucide-react'; import { api } from '@/api'; -import { cn, fmtBytes } from '@/lib/utils'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -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 AppNavigation from '@/components/layout/Sidebar'; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -const AUTHENTIK_ICON_URL = '/img/auth.png'; - -function SectionHeading({ children }) { - return

{children}

; -} - -function FieldRow({ label, children }) { - return ( -
- - {children} -
- ); -} - -function Toggle({ checked, onChange, label, disabled = false }) { - return ( - - ); -} - -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); -} - -function formatDateTime(value) { - if (!value) return '—'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); -} - -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 {type || 'backup'}; -} - -// ─── Onboarding Wizard ──────────────────────────────────────────────────────── - -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 ( -
-
- {/* Step dots */} -
- {[0, 1].map(i => ( - - ))} -
- - {step === 0 && ( - - - Welcome, Administrator -

- Before creating your first user, please understand what your admin account can and cannot do. -

-
- -
- {[ - { 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 }) => ( -
- - {can ? '✓' : '✗'} - - {text} -
- ))} -
-
- -
-
-
- )} - - {step === 1 && ( - - - Create first user -

- This account will be used to access the bill tracker. -

-
- -
-
- - setUsername(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
-
- - setConfirm(e.target.value)} - required - /> -
- {error && ( -
- {error} -
- )} -
- - -
-
-
-
- )} -
-
- ); -} - -// ─── Email Notifications Card ───────────────────────────────────────────────── - -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 Loading…; - - return ( - - - Email Notifications - - - {/* Enable toggle */} -
-
-

Enable email notifications

-

Configure SMTP to send bill reminders

-
- set('enabled', v)} label="Enable email notifications" /> -
- -
- -
- Sender - - set('sender_name', e.target.value)} placeholder="BillTracker" /> - - - set('sender_address', e.target.value)} placeholder="no-reply@example.com" type="email" /> - -
- -
- -
- SMTP Server - - set('smtp_host', e.target.value)} placeholder="smtp.example.com" /> - - - set('smtp_port', e.target.value)} placeholder="587" type="number" className="w-28" /> - - - - - -
- set('smtp_self_signed', e.target.checked)} - className="h-4 w-4 rounded border-input bg-input accent-primary" - /> - -
-
- - set('smtp_username', e.target.value)} placeholder="user@example.com" /> - - -
- set('smtp_password', e.target.value)} - placeholder="••••••••" - className="pr-9" - /> - -
-
-
- -
- -
- User Access - -
- set('allow_user_config', e.target.checked)} - className="h-4 w-4 rounded border-input bg-input accent-primary" - /> - -
-
- - set('global_recipient', e.target.value)} - placeholder="recipient@example.com" - type="email" - /> - -
- -
- -
- Test Email - -
- setTestEmail(e.target.value)} - placeholder="you@example.com" - type="email" - /> - -
-
-
- -
- -
- - - ); -} - -// ─── Login Mode Card ────────────────────────────────────────────────────────── - -function LoginModeCard({ users }) { - const [modeData, setModeData] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [selectedUser, setSelectedUser] = useState(''); - - // Single-user mode confirmation dialog - 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 Loading…; - - 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 ( - <> - - -
- Login Mode - - {isMulti ? 'Multi-user' : 'Single-user'} - -
-
- - {isMulti ? ( - <> -

- Single-user mode bypasses the login screen and automatically signs in as the selected user. -

-
- - -
- - - ) : ( - <> -

- Currently auto-signing in as{' '} - {activeUser?.username ?? '—'}. - Restoring login requirement will require all users to sign in manually. -

- - - )} -
-
- - {/* Single-user mode confirmation */} - - - - Enable Single-User Mode? - - Anyone who opens the app will be automatically signed in as{' '} - {selectedUsername}. - The admin login still requires a password. - - - - Cancel - - Enable Single-User Mode - - - - - - ); -} - -// ─── AuthMethodsCard ───────────────────────────────────────────────────────── -// Controls login methods and DB-backed authentik/OIDC provider settings. -// Client secret is write-only; the API returns only a "set" marker. - -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 ( - - - Loading auth settings… - - - ); - } - - 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 ( - - -
-
- Authentication Methods -

- Control local login and authentik/OIDC. Settings are saved in the database; - environment variables only fill blank fields as bootstrap defaults. -

-
-
-
- - - - {/* Warnings */} - {(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && ( -
- {wouldLockOut && ( -

- Cannot disable all login methods; at least one must remain enabled. -

- )} - {cantDisableLocal && !wouldLockOut && ( -

- Cannot disable local login without authentik/OIDC configured, enabled, and mapped to an admin group. -

- )} - {oidcEnabledButIncomplete && ( -

- authentik/OIDC needs {missingFields.join(', ')} before it can be enabled. -

- )} - {data?.warnings?.map((w, i) => ( -

{w}

- ))} -
- )} - - {/* Local login toggle */} - -
- set('local_login_enabled', v)} - label="Enable local login" - /> - - {form.local_login_enabled ? 'Enabled' : 'Disabled'} - -
-
- - {/* OIDC / authentik login toggle */} - -
- set('oidc_login_enabled', v)} - label="Enable OIDC login" - /> - - {!oidcConfigured - ? 'Not fully configured' - : form.oidc_login_enabled ? 'Enabled' : 'Disabled'} - -
-
- -
-
- - authentik / OIDC configuration -
- - - set('oidc_provider_name', e.target.value)} - placeholder="authentik" - className="max-w-xs h-8 text-sm" - /> - - - -
- 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" - /> -

- Use the authentik provider issuer URL or full discovery URL, for example https://yourURL.com/application/o/bills/.well-known/openid-configuration. -

- {issuerEndpointWarning && ( -

- This looks like an authorization endpoint. In authentik, copy the provider issuer or OpenID Configuration URL. -

- )} -
-
- - - set('oidc_client_id', e.target.value)} - placeholder="authentik client ID" - className="max-w-xl h-8 text-sm" - /> - - - -
-
- 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" - /> - - {data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'Secret is set' : 'No secret saved'} - -
- -
-
- - -
- -

- Advanced. Keep client_secret_basic unless your authentik provider explicitly requires client_secret_post. -

-
-
- - -
-
- set('oidc_redirect_uri', e.target.value)} - placeholder={defaultOidcRedirectUri()} - className="h-8 text-sm" - /> - -
-

- Add this exact URL to the Redirect URIs allowed by authentik. -

-
-
- - - set('oidc_scopes', e.target.value)} - placeholder="openid email profile groups" - className="max-w-xl h-8 text-sm" - /> - - - -
- set('oidc_admin_group', e.target.value)} - placeholder="e.g. bill-tracker-admins" - className="max-w-sm h-8 text-sm" - /> -

- Only users in this authentik group become app admins. Admin is never granted by default. -

-
-
- - -
-
- set('oidc_auto_provision', v)} - label="Auto-provision users" - /> - - {form.oidc_auto_provision ? 'Enabled' : 'Disabled'} - -
-

- When enabled, valid authentik users are created in this app on first login. -

-
-
- - -
- - - Admin role only via admin group. - -
-
- - {data?.oidc_env_fallback_used && ( -
- One or more blank database fields are currently using environment fallback values. Saving values here takes precedence. -
- )} - - {oidcTest && ( -
- {oidcTest.ok - ? `Configuration test passed for ${oidcTest.issuer || form.oidc_issuer_url}.` - : oidcTest.error || 'Configuration test failed.'} -
- )} -
- -
- - - -
- -
-
- ); -} - -// ─── Users Table ────────────────────────────────────────────────────────────── - -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); - - // Delete confirmation dialog - const [deleteTarget, setDeleteTarget] = useState(null); // user object - - 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 ( - <> - - - Users - - -
- - - - - - - - - - - - {(users || []).map(user => { - const form = getForm(user.id); - const isSelf = currentUser?.id === user.id; - return ( - - - - - - - - - ); - })} - {!users?.length && ( - - - - )} - -
UsernameRoleStatusPasswordReset Password -
-
- {user.username} - {user.is_default_admin && default admin} -
-
-
- - {user.role} - - -
-
- - - {user.must_change_password - ? Temporary - : Set - } - - {form.open ? ( -
- setReset(user.id, { pw: e.target.value })} - className="h-8 text-sm w-36" - /> - - -
- ) : ( - - )} -
- {!isSelf && ( - - )} -
No users found.
-
-
-
- - {/* Delete user confirmation */} - { if (!open) setDeleteTarget(null); }}> - - - Delete {deleteTarget?.username}? - - 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. - - - - Cancel - - {deleting ? 'Deleting…' : 'Delete User'} - - - - - - ); -} - -// ─── Add User Card ──────────────────────────────────────────────────────────── - -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 ( - - - Add User - - -
-
- - setUsername(e.target.value)} - placeholder="username" - required - /> -
-
- - setPassword(e.target.value)} - placeholder="Password" - required - /> -
-
- {error && ( -
- {error} -
- )} - - - - - ); -} - -// ─── Backup Management Card ────────────────────────────────────────────────── - -function BackupManagementCard() { - const DEFAULT_SETTINGS = { - enabled: false, - frequency: 'daily', - time: '02:00', - retention_count: 14, - last_run_at: null, - next_run_at: null, - last_error: null, - }; - - 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); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - 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 Loading backups…; - } - - return ( - <> - - -
-
- Database Backups -

- Admin-only SQLite backup, import, download, restore, and schedule controls. -

-
- - {settings.last_error ? 'Attention' : 'Ready'} - -
-
- -
- {[ - ['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]) => ( -
-

{label}

-

{value}

-
- ))} -
- - {settings.last_error && ( -
- {settings.last_error} -
- )} - -
- - - -
- -
- - - - - - - - - - - - {backups.map(backup => ( - - - - - - - - - ))} - {!backups.length && ( - - - - )} - -
BackupTypeModifiedSizeChecksum -
{backup.id}{formatDateTime(backup.modified_at)}{fmtBytes(backup.size_bytes)} - {backup.checksum || '—'} - -
- - - -
-
- No managed backups yet. -
-
- -
-
-
- Scheduled Backups -

- Scheduled runs create managed backups and only apply retention to scheduled backups. -

-
- setSchedule('enabled', v)} label="Enable scheduled backups" /> -
- -
-
- - -
-
- - setSchedule('time', e.target.value)} /> -
-
- - setSchedule('retention_count', e.target.value)} - /> -
-
- -
- {formatDateTime(settings.next_run_at)} -
-
-
- -
-
- Last run: {formatDateTime(settings.last_run_at)} -
-
- Last error: {settings.last_error || '—'} -
-
- -
- - -
-
-
-
- - { if (!open) setRestoreTarget(null); }}> - - - Restore this database backup? - - This replaces the live database with {restoreTarget?.id}. - A pre-restore backup will be created first. Run this during a quiet maintenance window. - - - - Cancel - - {busy.startsWith('restore:') ? 'Restoring…' : 'Restore Database'} - - - - - - { if (!open) setDeleteTarget(null); }}> - - - Delete this backup? - - This permanently deletes {deleteTarget?.id}. - The live database is not affected. - - - - Cancel - - {busy.startsWith('delete:') ? 'Deleting…' : 'Delete Backup'} - - - - - - ); -} - -// ─── CleanupPanel ───────────────────────────────────────────────────────────── - -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 ( - - - Loading cleanup settings… - - - ); - } - - return ( - - -
-
- -
- Cleanup / Maintenance -

- Automatic daily cleanup: expired import sessions, stale export temp files, orphaned backup partials. Runs at 6:00 AM. -

-
-
- Auto -
-
- - - - {/* Last run summary */} -
-
-

Last Run

-

{status?.last_run_at ? formatDateTime(status.last_run_at) : '—'}

-
-
-

Last Result

- {resultLines.length > 0 ? ( -
    - {resultLines.map(line => ( -
  • {line}
  • - ))} -
- ) : ( -

No runs recorded yet

- )} -
-
- - {/* Settings */} -
-

Task Settings

- - {[ - ['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]) => ( - - set(key, v)} label={label} /> - - ))} - - - 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" - /> - - - - set('import_history_enabled', v)} label="Trim import history rows" /> - - - {form.import_history_enabled && ( - <> - - set('import_history_max_age_days', parseInt(e.target.value, 10) || 365)} - className="max-w-[120px] h-8 text-sm" - /> - -
- Warning: Import history trimming permanently deletes audit records older than {form.import_history_max_age_days} days. This cannot be undone. -
- - )} -
- - {/* Action buttons */} -
- - -
- -
-
- ); -} - -// ─── AdminPage ──────────────────────────────────────────────────────────────── +import OnboardingWizard from '@/components/admin/OnboardingWizard'; +import EmailNotifCard from '@/components/admin/EmailNotifCard'; +import LoginModeCard from '@/components/admin/LoginModeCard'; +import AuthMethodsCard from '@/components/admin/AuthMethodsCard'; +import UsersTable from '@/components/admin/UsersTable'; +import AddUserCard from '@/components/admin/AddUserCard'; +import BackupManagementCard from '@/components/admin/BackupManagementCard'; +import CleanupPanel from '@/components/admin/CleanupPanel'; export default function AdminPage() { const navigate = useNavigate(); const [me, setMe] = useState(null); - const [hasUsers, setHasUsers] = useState(null); // null = loading + const [hasUsers, setHasUsers] = useState(null); const [users, setUsers] = useState([]); const loadMe = useCallback(async () => { @@ -1849,14 +27,6 @@ export default function AdminPage() { } }, [navigate]); - const loadHasUsers = useCallback(async () => { - try { - const d = await api.hasUsers(); - setHasUsers(d.has_users); - if (d.has_users) loadUsers(); - } catch {} - }, []); // eslint-disable-line react-hooks/exhaustive-deps - const loadUsers = useCallback(async () => { try { const d = await api.adminUsers(); @@ -1864,6 +34,14 @@ export default function AdminPage() { } catch {} }, []); + const loadHasUsers = useCallback(async () => { + try { + const d = await api.hasUsers(); + setHasUsers(d.has_users); + if (d.has_users) loadUsers(); + } catch {} + }, [loadUsers]); + useEffect(() => { loadMe(); loadHasUsers(); @@ -1874,7 +52,6 @@ export default function AdminPage() { loadUsers(); }; - // Loading state if (hasUsers === null) { return (
@@ -1887,7 +64,6 @@ export default function AdminPage() {
- {/* Content */} {!hasUsers ? ( ) : ( diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx index 7e43ff8..4b96676 100644 --- a/client/pages/DataPage.jsx +++ b/client/pages/DataPage.jsx @@ -1,3084 +1,12 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { toast } from 'sonner'; -import { - Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle, - AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown, - ChevronUp, SkipForward, Plus, CheckCheck, Sparkles, - List, Building2, ChevronLeft, FileText, Link2, Link2Off, - EyeOff, Eye, Search, -} from 'lucide-react'; +import React, { useState, useEffect } from 'react'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Switch } from '@/components/ui/switch'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; - -// ─── User export availability flag ─────────────────────────────────────────── -// Flip to true when GET /api/export/user-db and GET /api/export/user-excel exist. -const USER_EXPORTS_AVAILABLE = true; - -// ─── Utilities ──────────────────────────────────────────────────────────────── - -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' }); -} - -function groupRowsBySheet(rows) { - const map = new Map(); - for (const row of rows) { - const key = row.sheet_name || '(unknown sheet)'; - if (!map.has(key)) map.set(key, []); - map.get(key).push(row); - } - return Array.from(map.entries()).map(([name, rows]) => ({ name, rows })); -} - -function initialDecisionFromRecommendation(row) { - const rec = row.recommendation || {}; - const action = rec.action === 'ambiguous' ? null : (rec.action || row.proposed_action || null); - - if (!action || row.requires_user_decision) return { action: null }; - if (action === 'skip_row') return { action: 'skip_row' }; - if (action === 'match_existing_bill') { - return { - action, - bill_id: rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null, - bill_name: null, - due_day: rec.due_day ?? null, - actual_amount: rec.actual_amount ?? row.detected_amount ?? null, - payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null, - payment_date: rec.payment_date ?? row.detected_paid_date ?? null, - notes: row.detected_notes ?? null, - }; - } - if (action === 'create_new_bill') { - return { - action, - bill_id: null, - bill_name: rec.bill_name || row.detected_bill_name || '', - category_id: rec.category_id ?? null, - due_day: rec.due_day ?? null, - expected_amount: rec.expected_amount ?? row.detected_amount ?? null, - actual_amount: rec.actual_amount ?? row.detected_amount ?? null, - payment_amount: rec.payment_amount ?? row.detected_payment_amount ?? null, - payment_date: rec.payment_date ?? row.detected_paid_date ?? null, - notes: row.detected_notes ?? null, - }; - } - return { action }; -} - -function safeRawBillName(row) { - const raw = row.raw_values?.find((v) => { - const text = String(v || '').trim(); - if (!text || text.length > 80) return false; - if (/^(?:total|subtotal|sum|grand\s*total)$/i.test(text)) return false; - if (/^\$?\(?\d[\d,]*(?:\.\d{1,2})?\)?$/.test(text)) return false; - if (/^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(text)) return false; - if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return false; - return true; - }); - return raw ? String(raw).trim() : ''; -} - -function buildCreateNewDecision(row, currentDecision = {}) { - const rec = row.recommendation || {}; - const billName = currentDecision.bill_name - || row.detected_bill_name - || rec.bill_name - || safeRawBillName(row); - - return { - ...currentDecision, - action: 'create_new_bill', - previous_match_bill_id: currentDecision.bill_id ?? currentDecision.previous_match_bill_id ?? rec.bill_id ?? row.possible_bill_matches?.[0]?.bill_id ?? null, - bill_id: null, - bill_name: billName, - category_id: currentDecision.category_id ?? rec.category_id ?? null, - due_day: currentDecision.due_day ?? rec.due_day ?? null, - expected_amount: currentDecision.expected_amount ?? rec.expected_amount ?? row.detected_amount ?? null, - actual_amount: currentDecision.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null, - payment_amount: currentDecision.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null, - payment_date: currentDecision.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null, - notes: currentDecision.notes ?? row.detected_notes ?? null, - }; -} - -function buildInitialDecisions(rows) { - const d = {}; - for (const row of rows) { - const hasError = row.errors?.length > 0; - if (hasError || row.proposed_action === 'skip_row') { - d[row.row_id] = { action: 'skip_row' }; - } else { - d[row.row_id] = initialDecisionFromRecommendation(row); - } - } - return d; -} - -function isDecisionComplete(action, decision) { - if (!action) return false; - if (action === 'skip_row') return true; - if (action === 'create_new_bill') return !!(decision?.bill_name?.trim()); - if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note', 'create_payment'].includes(action)) { - return !!decision?.bill_id; - } - return true; -} - -// ─── Badges ─────────────────────────────────────────────────────────────────── - -function SourceBadge({ source }) { - const MAP = { - row_date: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', - sheet_name: 'bg-blue-500/15 text-blue-600 dark:text-blue-400', - default: 'bg-amber-500/15 text-amber-600 dark:text-amber-500', - ambiguous: 'bg-red-500/15 text-red-600 dark:text-red-400', - }; - const LABELS = { row_date: 'date cell', sheet_name: 'tab name', default: 'default', ambiguous: 'ambiguous' }; - return ( - - {LABELS[source] ?? source} - - ); -} - -function ConfidenceBadge({ confidence }) { - const MAP = { high: 'text-emerald-600 dark:text-emerald-400', medium: 'text-amber-600 dark:text-amber-500', low: 'text-red-500' }; - return {confidence}; -} - -function actionLabel(action) { - const MAP = { - match_existing_bill: 'Match existing bill', - create_new_bill: 'Create new bill', - skip_row: 'Skip row', - ambiguous: 'Needs decision', - update_monthly_state: 'Update monthly record', - add_monthly_note: 'Add monthly note', - create_payment: 'Record as payment', - }; - return MAP[action] || (action ? action.replace(/_/g, ' ') : 'Needs decision'); -} - -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, - }; -} - -function SheetStatusBadge({ status }) { - const MAP = { - parsed: 'bg-emerald-500/15 text-emerald-600', - parsed_month_only: 'bg-amber-500/15 text-amber-600', - ambiguous: 'bg-orange-500/15 text-orange-600', - skipped: 'bg-muted text-muted-foreground', - }; - const LABELS = { - parsed: 'parsed', parsed_month_only: 'month only', ambiguous: 'ambiguous', skipped: 'skipped', - }; - return ( - - {LABELS[status] ?? status} - - ); -} - -// ─── Shared SectionCard ─────────────────────────────────────────────────────── - -function SectionCard({ title, subtitle, children, className }) { - return ( -
-
-

{title}

- {subtitle &&

{subtitle}

} -
-
{children}
-
- ); -} - -// ─── Section 2: Download My Data ───────────────────────────────────────────── - -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 ( -
-
-
- -
-
-
-

{title}

- {!USER_EXPORTS_AVAILABLE && ( - - Coming soon - - )} -
-

{description}

-
-
-
- -
-
- ); -} - -export function DownloadMyDataSection() { - return ( - - - -
- -

Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.

-
-
-
-

What's included

-
    - {['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => ( -
  • - {i} -
  • - ))} -
-
-
-

What's not included

-
    - {['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => ( -
  • - {i} -
  • - ))} -
-
-
-
- ); -} - -function CountPill({ label, value }) { - return ( -
-

{label}

-

{value ?? 0}

-
- ); -} - -// ─── Section 2: Transaction Matching ───────────────────────────────────────── - -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 ( - - {status} - - ); -} - -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 ( -
-
-
- - - -
-

Suggested matches

-

{loading ? 'Checking transactions' : `${suggestions.length} ready for review`}

-
-
-
- - {loading ? ( -
- - Finding likely bill matches... -
- ) : suggestions.length === 0 ? ( -
- No suggested matches right now. -
- ) : ( -
- {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 ( -
-
-
-
- - {suggestion.score} - -

{transactionTitle(tx)}

-
-

- {transactionDate(tx)} · {tx.source_label || tx.source_type_label || 'Transaction'} -

-
-

- {formatTransactionAmount(tx.amount, tx.currency)} -

-
- -
- -
-

{bill.name || `Bill ${suggestion.billId}`}

-

- Expected ${Number(bill.expected_amount || 0).toFixed(2)} -

-
-
- - {suggestion.reasons?.length > 0 && ( -
- {suggestion.reasons.slice(0, 4).map(reason => ( - - {reason} - - ))} -
- )} - -
- - -
-
- ); - })} -
- )} -
- ); -} - -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 ( - - - - Match Transaction - - Choose the bill this transaction paid. Nothing changes until you confirm. - - - - {transaction && ( -
-
-
-

{transactionTitle(transaction)}

-

- {transactionDate(transaction)} · {transaction.source_label || transaction.source_type_label || 'Transaction'} -

-
-

- {formatTransactionAmount(transaction.amount, transaction.currency)} -

-
- {transaction.description && transaction.description !== transactionTitle(transaction) && ( -

{transaction.description}

- )} -
- )} - -
- - -
- {filteredBills.length === 0 ? ( -

No bills found.

- ) : ( -
- {filteredBills.map(bill => ( - - ))} -
- )} -
-
- - - - - -
-
- ); -} - -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 ( - -
-
-
- {TRANSACTION_FILTERS.map(item => ( - - ))} -
- -
- - - -
- {loading ? ( -
Loading transactions…
- ) : transactions.length === 0 ? ( -
- No transactions found for this filter. -
- ) : ( - - - - - - - - - - - - {transactions.map(tx => { - const status = transactionStatus(tx); - const busy = actionId?.endsWith(`:${tx.id}`); - return ( - - - - - - - - ); - })} - -
DateTransactionMatchAmountActions
- {transactionDate(tx)} - -
-

{transactionTitle(tx)}

-

- {[tx.description, tx.account_name, tx.source_label].filter(Boolean).join(' · ') || '—'} -

-
-
-
- - {tx.matched_bill_name ? ( - {tx.matched_bill_name} - ) : ( - No bill linked - )} -
-
- {formatTransactionAmount(tx.amount, tx.currency)} - -
- {status === 'ignored' ? ( - - ) : ( - <> - {status === 'matched' ? ( - - ) : ( - - )} - - - )} -
-
- )} -
-
- - -
- ); -} - -// ─── Section 1: Import Transaction CSV ─────────────────────────────────────── - -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 ( -
- {CSV_IMPORT_STEPS.map((step, index) => { - const complete = index < activeIndex; - const active = index === activeIndex; - return ( -
- - {complete ? : index + 1} - - {step} -
- ); - })} -
- ); -} - -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 ( -
-
-
-

{label}

- - {requirement} - -
-

{field}

-
- -
- -
- {current && current === suggested && ( - Suggested match - )} - {suggestedAvailable && !disabled && ( - - )} - {missingRequired && ( - Needs a column - )} -
-
- -
- {samples.length > 0 ? ( -
- {samples.map(value => ( - - {value} - - ))} -
- ) : ( -

- {current ? 'No sample values' : 'Map a column to preview values'} -

- )} -
-
- ); -} - -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 ( -
-
-
-

Column mapping

-

- {mappedCount} of {mappingFields.length} fields mapped - {missingRequired.length > 0 && ` · missing ${missingRequired.join(' and ')}`} -

-
-
- - -
-
- -
- Field - CSV Column - Sample Values -
- -
- {mappingFields.map(field => ( - - ))} -
-
- ); -} - -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

No sample rows found.

; - } - - return ( -
- - - - {visibleHeaders.map(header => ( - - ))} - {hiddenCount > 0 && ( - - )} - - - - {sampleRows.map((row, index) => ( - - {visibleHeaders.map(header => ( - - ))} - {hiddenCount > 0 && ( - - )} - - ))} - -
{header}+{hiddenCount}
- {row[header] || '—'} - more columns
-
- ); -} - -function formatCsvRowDetail(detail) { - if (!detail) return ''; - const field = detail.field ? `${detail.field}: ` : ''; - return `${field}${detail.message || detail.value || JSON.stringify(detail)}`; -} - -export 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 ( - -
-
-
- -
-

Import transaction rows from CSV.

-

- This importer creates shared transaction records only. It does not match transactions to bills yet. -

-
-
-
- - - -
-
- -
- - -
-
-
- - {preview.status === 'error' && ( -
- - {preview.error?.message || 'CSV preview failed.'} - {preview.error?.details?.length > 0 && ( -
    - {preview.error.details.map((d, i) => ( -
  • {d.message || JSON.stringify(d)}
  • - ))} -
- )} -
- )} - - {preview.status === 'ready' && preview.data && ( -
-
-
-
-

CSV Preview

-

{file?.name || 'Transaction CSV'}

-
-
- - - -
-
- - {preview.data.errors?.length > 0 && ( -
-

Review mapping

-
    - {preview.data.errors.map((issue, i) => ( -
  • - - {issue.message || JSON.stringify(issue)} -
  • - ))} -
-
- )} - - -
- - - -
-
-

- {canCommitCsvMapping(mapping) ? 'Ready to commit' : 'Mapping incomplete'} -

-

- Duplicates are skipped using a CSV transaction ID when available, otherwise a stable row hash. -

-
- {commitState.status === 'done' ? ( - - ) : ( - - )} -
-
- )} - - {commitState.status === 'done' && commitState.result && ( -
-
- -

CSV transaction import complete

-
-
- - - -
- {skippedRows.length > 0 && ( -
-

Skipped duplicates ({skippedRows.length})

-
    - {skippedRows.map(row => ( -
  • - Row {row.row}: {row.provider_transaction_id} -
  • - ))} -
-
- )} - {failedRows.length > 0 && ( -
-

Failed rows ({failedRows.length})

-
    - {failedRows.map((row, index) => ( -
  • -

    Row {row.row}: {row.message}

    - {row.details?.length > 0 && ( -
      - {row.details.map((detail, detailIndex) => ( -
    • {formatCsvRowDetail(detail)}
    • - ))} -
    - )} -
  • - ))} -
-
- )} -
- )} - - {commitState.status === 'error' && ( -
- - {commitState.error?.message || 'CSV import failed.'} - {commitState.error?.details?.length > 0 && ( -
    - {commitState.error.details.map((d, i) => ( -
  • {d.message || JSON.stringify(d)}
  • - ))} -
- )} -
- )} -
-
- ); -} - -// ─── Section 3: Import My Data Export ──────────────────────────────────────── - -export 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 ( - <> - -
-
-
- -
-

Import a SQLite data export created by this app.

-

- This is not a full system restore. Existing records are skipped by default, and admin/system data is never imported. -

-
-
-
- -
- -
- - -
-
- - {preview.status === 'error' && ( -
- - {preview.error?.message || 'SQLite import preview failed.'} - {preview.error?.details?.length > 0 && ( -
    - {preview.error.details.map((d, i) => ( -
  • {d.message || d.table || JSON.stringify(d)}
  • - ))} -
- )} -
- )} - - {preview.status === 'ready' && preview.data && ( -
-
-
-
-

Preview ready

-

- Exported {fmt(preview.data.metadata?.exported_at)} · {preview.data.source_filename || 'SQLite export'} -

-
- - User data only - -
-
- - - - - -
-
- {Object.entries(summary).filter(([, v]) => v && typeof v === 'object').map(([key, value]) => ( -
-

{key.replace(/_/g, ' ')}

-

- create {value.create || 0} · skip {value.skip || 0} · conflict {value.conflict || 0} -

-
- ))} -
- {preview.data.warnings?.length > 0 && ( -
- {preview.data.warnings.map((warning, i) => ( -

- {warning} -

- ))} -
- )} -
- -
-

Review the preview before applying. Nothing is imported until you confirm.

- -
-
- )} - - {applyState.status === 'done' && applyState.result && ( -
-

SQLite import applied

-
- - - - -
-
- )} - - {applyState.status === 'error' && ( -
- {applyState.error?.message || 'SQLite import apply failed.'} -
- )} -
-
- {/* Import confirmation dialog */} - - - - Import SQLite data export? - - Import this SQLite data export into your account? Existing records will be skipped by default. - - - - Cancel - - Confirm Import - - - - - - ); -} - -// ─── Section 4: Import History ──────────────────────────────────────────────── - -export function ImportHistorySection({ history, loading, onRefresh }) { - if (loading) { - return ( - -
Loading…
-
- ); - } - - const rows = history ?? []; - - return ( - -
-

- {rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`} -

- -
- {rows.length > 0 && ( -
- - - - {['Date','File','Sheet','Parsed','Created','Updated','Skipped','Errors'].map(h => ( - - ))} - - - - {rows.map(r => ( - - - - - - - - - - - ))} - -
{h}
- {fmt(r.imported_at)} - {r.source_filename || '—'}{r.sheet_name || '—'}{r.rows_parsed}{r.rows_created}{r.rows_updated}{r.rows_skipped}{r.rows_errored}
-
- )} -
- ); -} - -// ─── XLSX Import: Workbook Summary ──────────────────────────────────────────── - -function WorkbookSummaryCard({ workbook }) { - const isMulti = workbook.parse_mode === 'all_sheets'; - - return ( -
-
-

Workbook Summary

- - {isMulti ? `${workbook.total_row_count} rows across ${workbook.sheet_names.length} tabs` : `${workbook.row_count} rows · ${workbook.selected_sheet}`} - -
- {isMulti && workbook.sheets?.length > 0 && ( -
- {workbook.sheets.map(s => ( -
- {s.name} -
- {s.detected_year && s.detected_month && ( - - {String(s.detected_month).padStart(2,'0')}/{s.detected_year} - - )} - - {s.status !== 'skipped' && {s.row_count} rows} -
-
- ))} -
- )} -
- ); -} - -// ─── XLSX Import: Row Decision Controls ────────────────────────────────────── - -const ACTIONS_NEEDING_BILL = new Set(['match_existing_bill','update_monthly_state','add_monthly_note','create_payment']); - -function RowDecisionRow({ row, decision, onDecisionChange, allBills, categories, selected, onSelectedChange }) { - const [expanded, setExpanded] = useState(row.requires_user_decision || !decision?.action); - - const action = decision?.action ?? null; - const isSkip = action === 'skip_row'; - const hasError = row.errors?.length > 0; - const complete = isDecisionComplete(action, decision); - const rec = row.recommendation || {}; - - const suggestedBills = row.possible_bill_matches ?? []; - const suggestedIds = new Set(suggestedBills.map(b => b.bill_id)); - const otherBills = allBills.filter(b => !suggestedIds.has(b.id)); - - const handleAction = (val) => { - const next = { ...decision, action: val }; - if (val === 'create_new_bill') { - Object.assign(next, buildCreateNewDecision(row, decision)); - } else if (ACTIONS_NEEDING_BILL.has(val)) { - next.bill_name = null; - next.bill_id = decision?.bill_id ?? decision?.previous_match_bill_id ?? rec.bill_id ?? suggestedBills[0]?.bill_id ?? null; - next.actual_amount = decision?.actual_amount ?? rec.actual_amount ?? row.detected_amount ?? null; - next.payment_amount = decision?.payment_amount ?? rec.payment_amount ?? row.detected_payment_amount ?? null; - next.payment_date = decision?.payment_date ?? rec.payment_date ?? row.detected_paid_date ?? null; - } else { - next.bill_id = null; - next.bill_name = null; - } - onDecisionChange(row.row_id, next); - if (val === 'skip_row') setExpanded(false); - }; - - const handleBill = (e) => { - onDecisionChange(row.row_id, { ...decision, bill_id: parseInt(e.target.value, 10) || null }); - }; - - const handleBillName = (e) => { - onDecisionChange(row.row_id, { ...decision, bill_name: e.target.value }); - }; - - const handleDecisionField = (field, value) => { - onDecisionChange(row.row_id, { ...decision, [field]: value }); - }; - - return ( -
- {/* Main row */} -
setExpanded(e => !e)} - > - {/* Selection */} -
e.stopPropagation()}> - onSelectedChange(row.row_id, e.target.checked)} - aria-label={`Select row ${row.source_row_number}`} - className="h-4 w-4 rounded border-border accent-primary" - /> -
- - {/* Status icon */} -
- {hasError ? : - isSkip ? : - complete ? : - action !== null ? : - } -
- - {/* Content */} -
-
- #{row.source_row_number} - {row.sheet_name && {row.sheet_name}} - {row.detected_year && row.detected_month && ( - - {String(row.detected_month).padStart(2,'0')}/{row.detected_year} - - )} - {row.year_month_source && } -
-
- - {row.detected_bill_name || '(no bill name)'} - - {row.detected_amount != null && ( - - ${row.detected_amount.toFixed(2)} - - )} - {row.detected_paid_date && ( - - paid {row.detected_paid_date} - - )} - {row.detected_labels?.length > 0 && ( - {row.detected_labels.join(', ')} - )} - {row.detected_notes && ( - {row.detected_notes} - )} -
-
- - {/* Right: action status + expand */} -
- {action === null ? ( - Needs decision - ) : isSkip ? ( - Skipped - ) : ( - {action.replace(/_/g,' ')} - )} - {action !== 'skip_row' && ( - expanded ? : - )} -
-
- - {/* Expanded decision controls */} - {expanded && !hasError && ( -
- {/* Recommendation */} - {rec.action && ( -
-
- Recommended: {actionLabel(rec.action)} - {rec.bill_name && rec.action === 'match_existing_bill' && ( - → {rec.bill_name} - )} - {rec.category_name && ( - Category: {rec.category_name} - )} - {rec.due_day && Due day: {rec.due_day}} - {rec.actual_amount != null && ${Number(rec.actual_amount).toFixed(2)}} - -
- {rec.reason &&

Reason: {rec.reason}

} -
- )} - - {/* Warnings */} - {(rec.warnings?.length > 0 || row.warnings?.length > 0) && ( -
- {Array.from(new Set([...(rec.warnings || []), ...(row.warnings || [])])).map((w, i) => ( -

- {w} -

- ))} -
- )} - - {/* Possible matches hint */} - {suggestedBills.length > 0 && ( -
- Suggested: - {suggestedBills.slice(0, 3).map(b => ( - - ))} -
- )} - - {/* Action selector */} -
- - -
- - {/* Bill selector (for actions that need a bill) */} - {ACTIONS_NEEDING_BILL.has(action) && ( -
- - -
- )} - - {/* Bill name input for create_new_bill */} - {action === 'create_new_bill' && ( -
-
- - -
-
- - - {rec.category_name && Suggested: {rec.category_name}} -
-
- - handleDecisionField('due_day', e.target.value ? parseInt(e.target.value, 10) : null)} - placeholder="Due day" - className="h-8 text-sm w-24" - /> - handleDecisionField('expected_amount', e.target.value === '' ? null : parseFloat(e.target.value))} - placeholder="Expected amount" - className="h-8 text-sm w-40" - /> -
-
- )} - - {action && action !== 'skip_row' && ( -
- - handleDecisionField('payment_date', e.target.value || null)} - className="h-8 text-sm w-40" - /> - handleDecisionField('payment_amount', e.target.value === '' ? null : parseFloat(e.target.value))} - placeholder="Paid amount" - className="h-8 text-sm w-36" - /> -
- )} - - {/* Quick skip */} - {action !== 'skip_row' && ( - - )} -
- )} -
- ); -} - -// ─── XLSX Import: Preview Table ─────────────────────────────────────────────── - -function PreviewTable({ rows, decisions, onDecisionChange, allBills, categories, selectedRows, onSelectedChange }) { - const groups = groupRowsBySheet(rows); - const multiTab = groups.length > 1; - - return ( -
- {groups.map(({ name, rows: groupRows }) => ( -
- {multiTab && ( -
- - {name} - · {groupRows.length} rows -
- )} - {groupRows.map(row => ( - - ))} -
- ))} -
- ); -} - -function BulkActionBar({ - rows, - selectedRows, - onSelectAll, - onClearSelection, - onBulkSkip, - onBulkCreateNew, - onBulkReset, -}) { - const allSelected = rows.length > 0 && rows.every(r => selectedRows.has(r.row_id)); - const selectedCount = selectedRows.size; - - return ( -
-
- - -
- {selectedCount > 0 && ( - {selectedCount} row{selectedCount === 1 ? '' : 's'} selected - )} - {selectedCount > 0 && ( - <> - - - - - - )} -
-
-
- ); -} - -// ─── Section 1: Import Spreadsheet History ──────────────────────────────────── - -const INITIAL_OPTIONS = { - parseAllSheets: true, - defaultYear: new Date().getFullYear(), - defaultMonth: '', -}; - -// ─── Bill History Import helpers ────────────────────────────────────────────── - -function ConfidenceDot({ level }) { - const cls = level === 'high' ? 'bg-emerald-500' - : level === 'medium' ? 'bg-amber-500' - : 'bg-muted-foreground/30'; - return ; -} - -function useBillGroups(previewRows, allBills) { - return useMemo(() => { - const billMap = new Map(allBills.map(b => [b.id, b])); - const groups = new Map(); - - for (const row of previewRows) { - for (const match of (row.possible_bill_matches ?? [])) { - if (!billMap.has(match.bill_id)) continue; - if (!groups.has(match.bill_id)) { - groups.set(match.bill_id, { - bill: billMap.get(match.bill_id), - rows: [], - counts: { high: 0, medium: 0, low: 0 }, - }); - } - const g = groups.get(match.bill_id); - if (!g.rows.find(r => r.row_id === row.row_id)) { - g.rows.push({ ...row, _match: match }); - g.counts[match.match_confidence] = (g.counts[match.match_confidence] || 0) + 1; - } - } - } - return [...groups.values()].sort((a, b) => - b.rows.length !== a.rows.length ? b.rows.length - a.rows.length : b.counts.high - a.counts.high - ); - }, [previewRows, allBills]); -} - -function rowDateLabel(row) { - if (row.detected_year && row.detected_month) - return `${row.detected_year}-${String(row.detected_month).padStart(2, '0')}`; - return row.detected_paid_date ?? '—'; -} - -function billImportProgress(rows, importResult) { - const completedRowIds = importResult?.completedRowIds ?? new Set(); - const remainingRows = rows.filter(row => !completedRowIds.has(row.row_id)); - return { - completedCount: rows.length - remainingRows.length, - remainingRows, - remainingCount: remainingRows.length, - }; -} - -function detailImportedAnything(detail) { - return ['created', 'updated', 'overwritten', 'imported'].includes(detail?.result) - || detail?.payment === 'created'; -} - -function detailCompletesImport(detail) { - if (!detail?.row_id) return false; - if (['error', 'ambiguous', 'skipped_conflict'].includes(detail.result)) return false; - if (detail.result === 'skipped') return false; - return detailImportedAnything(detail) - || detail.result === 'skipped_duplicate' - || detail.payment === 'skipped_duplicate'; -} - -function BillDetailView({ group, onBack, onImport, isImporting, importResult }) { - const { bill, rows } = group; - const { completedCount, remainingCount } = billImportProgress(rows, importResult); - const sorted = [...rows].sort((a, b) => { - const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0); - const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0); - return da - db; - }); - - return ( -
-
- - {bill.name} -
- {importResult && ( -
- - {completedCount === rows.length - ? 'All imported' - : `${completedCount} imported · ${remainingCount} remaining`} - {importResult.duplicates > 0 - && ` · ${importResult.duplicates} dupes`} - - {importResult.duplicates > 0 && importResult.earliestDup && ( -

- {importResult.duplicates} dup{importResult.duplicates > 1 ? 's' : ''} · recorded{' '} - {importResult.earliestDup.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} -

- )} -
- )} - -
-
-
- {sorted.map(row => ( -
- - {rowDateLabel(row)} - - {row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'} - - {row.detected_name ?? '—'} - - {row._match.match_confidence} - -
- ))} -
-
- ); -} - -function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }) { - const [selectedBillId, setSelectedBillId] = useState(null); - const billGroups = useBillGroups(previewRows, allBills); - - if (billGroups.length === 0) { - return ( -
- No existing bills matched rows in this file. -
- ); - } - - if (selectedBillId) { - const group = billGroups.find(g => g.bill.id === selectedBillId); - return group - ? setSelectedBillId(null)} - onImport={() => onImportBill(group)} /> - : null; - } - - return ( -
- {billGroups.map(g => { - const { bill, rows, counts } = g; - const isImporting = importingBillId === bill.id; - const importResult = billImportResults.get(bill.id) ?? null; - const { completedCount, remainingCount } = billImportProgress(rows, importResult); - - const sorted3 = [...rows] - .sort((a, b) => { - const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0); - const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0); - return da - db; - }) - .slice(0, 3); - - return ( -
-
-
- {bill.name} - - {rows.length} row{rows.length !== 1 ? 's' : ''} - - {counts.high > 0 && {counts.high} high} - {counts.medium > 0 && {counts.medium} med} - {counts.low > 0 && {counts.low} low} - {importResult && (() => { - const allImported = completedCount === rows.length; - const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); - const dupDate = importResult.earliestDup ? ` · ${fmtDate(importResult.earliestDup)}` : ''; - return ( -
- - {allImported ? 'All imported' : `${completedCount} imported · ${remainingCount} remaining`} - {importResult.duplicates > 0 && ` · ${importResult.duplicates} dupes`} - {importResult.errored > 0 && ` · ${importResult.errored} errors`} - - {importResult.duplicates > 0 && ( -

- {importResult.duplicates} duplicate{importResult.duplicates > 1 ? 's' : ''}{dupDate} -

- )} -
- ); - })()} -
-
- {sorted3.map(row => ( -
- - {rowDateLabel(row)} - {row.detected_amount != null && ( - ${Number(row.detected_amount).toFixed(2)} - )} - {row.detected_name && - row.detected_name.toLowerCase() !== bill.name.toLowerCase() && ( - "{row.detected_name}" - )} -
- ))} - {rows.length > 3 && ( - - )} -
-
-
- {importResult ? ( - - ) : ( - - )} - -
-
- ); - })} -
- ); -} - -// ───────────────────────────────────────────────────────────────────────────── - -export function ImportSpreadsheetSection({ onHistoryRefresh }) { - const fileRef = useRef(null); - const [file, setFile] = useState(null); - const [options, setOptions] = useState(INITIAL_OPTIONS); - const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); - const [decisions, setDecisions] = useState({}); - const [applyState, setApplyState] = useState({ status: 'idle', result: null, error: null }); - const [allBills, setAllBills] = useState([]); - const [categories, setCategories] = useState([]); - const [selectedRows, setSelectedRows] = useState(new Set()); - const [viewMode, setViewMode] = useState('rows'); // 'rows' | 'bills' - const [importingBillId, setImportingBillId] = useState(null); - const [billImportResults, setBillImportResults] = useState(new Map()); // bill_id → { created, updated, errored } - - // Load bills/categories for the decision controls - useEffect(() => { - api.bills().then(setAllBills).catch(() => {}); - api.categories().then(setCategories).catch(() => {}); - }, []); - - const opt = (k, v) => setOptions(prev => ({ ...prev, [k]: v })); - - // ── Preview ────────────────────────────────────────────────────────────────── - const handlePreview = async () => { - if (!file) return; - setPreview({ status: 'loading', data: null, error: null }); - setDecisions({}); - setSelectedRows(new Set()); - setApplyState({ status: 'idle', result: null, error: null }); - setViewMode('rows'); - setImportingBillId(null); - setBillImportResults(new Map()); - try { - const data = await api.previewSpreadsheetImport(file, { - parseAllSheets: options.parseAllSheets, - defaultYear: options.defaultYear ? parseInt(options.defaultYear, 10) : null, - defaultMonth: options.defaultMonth ? parseInt(options.defaultMonth, 10) : null, - }); - setPreview({ status: 'ready', data, error: null }); - setDecisions(buildInitialDecisions(data.rows)); - } catch (err) { - setPreview({ status: 'error', data: null, error: importErrorState(err, 'Preview failed.') }); - } - }; - - // ── Decision update ────────────────────────────────────────────────────────── - const handleDecisionChange = (rowId, decision) => { - setDecisions(prev => ({ ...prev, [rowId]: decision })); - }; - - const handleSelectedChange = (rowId, selected) => { - setSelectedRows(prev => { - const next = new Set(prev); - if (selected) next.add(rowId); - else next.delete(rowId); - return next; - }); - }; - - const clearSelection = () => setSelectedRows(new Set()); - - // ── Bill-history direct import ──────────────────────────────────────────── - // Applies all matching rows for a bill immediately — no queue, no review step. - const handleDirectImportBill = async (group) => { - const sessionId = preview.data?.import_session_id; - if (!sessionId || importingBillId) return; - - const previousResult = billImportResults.get(group.bill.id) ?? null; - const rowsToImport = billImportProgress(group.rows, previousResult).remainingRows; - if (rowsToImport.length === 0) { - toast.info(`All rows for "${group.bill.name}" have already been imported.`); - return; - } - - setImportingBillId(group.bill.id); - try { - const decisionsList = rowsToImport.map(row => ({ - row_id: row.row_id, - action: 'match_existing_bill', - bill_id: group.bill.id, - actual_amount: row.detected_amount ?? null, - payment_amount: row.detected_payment_amount ?? row.detected_amount ?? null, - payment_date: row.detected_paid_date ?? null, - })); - - const result = await api.applySpreadsheetImport({ - import_session_id: sessionId, - decisions: decisionsList, - options: {}, - }); - - const created = result.rows_created ?? 0; - const updated = result.rows_updated ?? 0; - const errored = result.rows_errored ?? 0; - const details = result.details ?? []; - const duplicateRowIds = new Set( - details - .filter(d => d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') - .map(d => d.row_id) - .filter(Boolean), - ); - const duplicates = duplicateRowIds.size || (result.rows_duplicates ?? 0); - - // Collect created_at dates from duplicate detail entries so we can show - // when the existing payments were originally recorded. - const dupDates = details - .filter(d => (d.result === 'skipped_duplicate' || d.payment === 'skipped_duplicate') && d.existing_created_at) - .map(d => new Date(d.existing_created_at)) - .filter(d => !isNaN(d.getTime())) - .sort((a, b) => a - b); - - const earliestDup = dupDates[0] ?? null; - const latestDup = dupDates.at(-1) ?? null; - const completedRowIds = new Set(previousResult?.completedRowIds ?? []); - const erroredRowIds = new Set(previousResult?.erroredRowIds ?? []); - - for (const detail of details) { - if (detailCompletesImport(detail)) { - completedRowIds.add(detail.row_id); - erroredRowIds.delete(detail.row_id); - } else if (['error', 'ambiguous', 'skipped_conflict'].includes(detail?.result)) { - erroredRowIds.add(detail.row_id); - } - } - - const mergedResult = { - created: (previousResult?.created ?? 0) + created, - updated: (previousResult?.updated ?? 0) + updated, - errored: erroredRowIds.size, - duplicates: (previousResult?.duplicates ?? 0) + duplicates, - earliestDup: previousResult?.earliestDup && earliestDup - ? (previousResult.earliestDup < earliestDup ? previousResult.earliestDup : earliestDup) - : (previousResult?.earliestDup ?? earliestDup), - latestDup: previousResult?.latestDup && latestDup - ? (previousResult.latestDup > latestDup ? previousResult.latestDup : latestDup) - : (previousResult?.latestDup ?? latestDup), - completedRowIds, - erroredRowIds, - }; - - setBillImportResults(prev => new Map(prev).set(group.bill.id, mergedResult)); - - const imported = created + updated; - const fmtDate = d => d?.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); - const remainingCount = billImportProgress(group.rows, mergedResult).remainingCount; - - if (imported === 0 && duplicates > 0) { - const dateHint = earliestDup - ? ` (first recorded ${fmtDate(earliestDup)})` - : ''; - toast.warning( - remainingCount === 0 - ? `All rows for "${group.bill.name}" are now imported${dateHint}.` - : `${duplicates} row${duplicates > 1 ? 's' : ''} for "${group.bill.name}" already exist${dateHint}. ${remainingCount} remaining.`, - ); - } else { - const parts = [`${imported} entr${imported === 1 ? 'y' : 'ies'} imported`]; - if (duplicates > 0) { - const dateHint = earliestDup ? ` (recorded ${fmtDate(earliestDup)})` : ''; - parts.push(`${duplicates} already existed${dateHint}`); - } - if (errored > 0) parts.push(`${errored} error${errored > 1 ? 's' : ''}`); - if (remainingCount > 0) parts.push(`${remainingCount} remaining`); - toast.success(`${group.bill.name} — ${parts.join(' · ')}`); - } - onHistoryRefresh?.(); - } catch (err) { - toast.error(err.message || `Import failed for "${group.bill.name}"`); - } finally { - setImportingBillId(null); - } - }; - - const selectAllVisibleRows = () => { - setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id))); - }; - - const selectedPreviewRows = () => { - const selected = selectedRows; - return (preview.data?.rows || []).filter(r => selected.has(r.row_id)); - }; - - const handleBulkSkip = () => { - const rows = selectedPreviewRows(); - setDecisions(prev => { - const next = { ...prev }; - rows.forEach(row => { - next[row.row_id] = { ...(next[row.row_id] || {}), action: 'skip_row', bill_id: null, bill_name: null }; - }); - return next; - }); - toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} marked skipped.`); - }; - - const handleBulkCreateNew = () => { - const rows = selectedPreviewRows(); - let missingNames = 0; - setDecisions(prev => { - const next = { ...prev }; - rows.forEach(row => { - const decision = buildCreateNewDecision(row, next[row.row_id] || {}); - if (!decision.bill_name?.trim()) missingNames++; - next[row.row_id] = decision; - }); - return next; - }); - if (missingNames > 0) { - toast.warning(`${missingNames} selected row${missingNames === 1 ? '' : 's'} still need a bill name.`); - } else { - toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} set to create new bills.`); - } - }; - - const handleBulkReset = () => { - const rows = selectedPreviewRows(); - setDecisions(prev => { - const next = { ...prev }; - rows.forEach(row => { - next[row.row_id] = initialDecisionFromRecommendation(row); - }); - return next; - }); - toast.success(`${rows.length} row${rows.length === 1 ? '' : 's'} reset to recommendation.`); - }; - - const buildApplyDecision = (row, d) => { - if (!d?.action) return null; - - const base = { - row_id: row.row_id, - action: d.action, - actual_amount: d.actual_amount ?? row.detected_amount ?? undefined, - year: row.detected_year ?? undefined, - month: row.detected_month ?? undefined, - notes: d.notes ?? row.detected_notes ?? undefined, - payment_amount: d.payment_amount ?? row.detected_payment_amount ?? undefined, - payment_date: d.payment_date ?? row.detected_paid_date ?? undefined, - }; - - if (d.action === 'create_new_bill') { - return { - ...base, - bill_name: d.bill_name?.trim() || undefined, - category_id: d.category_id ?? undefined, - due_day: d.due_day ?? undefined, - expected_amount: d.expected_amount ?? undefined, - }; - } - - if (ACTIONS_NEEDING_BILL.has(d.action)) { - return { - ...base, - bill_id: d.bill_id ?? undefined, - }; - } - - return base; - }; - - // ── Apply ──────────────────────────────────────────────────────────────────── - const handleApply = async () => { - if (!preview.data) return; - setApplyState({ status: 'loading', result: null, error: null }); - try { - const decisionsList = preview.data.rows - .map(row => { - const d = decisions[row.row_id]; - if (d?.action === 'skip_row') return null; - return buildApplyDecision(row, d); - }) - .filter(Boolean); - - if (decisionsList.length === 0) { - throw new Error('No rows are selected to import. Choose at least one row to match or create, or keep the preview for review.'); - } - - const result = await api.applySpreadsheetImport({ - import_session_id: preview.data.import_session_id, - decisions: decisionsList, - options: { reviewed_skipped_count: skipRows.length }, - }); - setApplyState({ status: 'done', result, error: null }); - setSelectedRows(new Set()); - toast.success(`Import applied — ${result.rows_created} created, ${result.rows_updated} updated.`); - onHistoryRefresh(); - } catch (err) { - const errorState = importErrorState(err, 'Apply failed.'); - setApplyState({ status: 'error', result: null, error: errorState }); - toast.error(errorState.message || 'Apply failed.'); - } - }; - - // ── Reset ──────────────────────────────────────────────────────────────────── - const handleReset = () => { - setFile(null); - setOptions(INITIAL_OPTIONS); - setPreview({ status: 'idle', data: null, error: null }); - setDecisions({}); - setSelectedRows(new Set()); - setApplyState({ status: 'idle', result: null, error: null }); - setViewMode('rows'); - setImportingBillId(null); - setBillImportResults(new Map()); - if (fileRef.current) fileRef.current.value = ''; - }; - - // ── Derived state ──────────────────────────────────────────────────────────── - const previewRows = preview.data?.rows ?? []; - const unresolvedRows = previewRows.filter(r => { - const d = decisions[r.row_id]; - return !d?.action || !isDecisionComplete(d.action, d); - }); - const pendingRows = previewRows.filter(r => decisions[r.row_id]?.action && decisions[r.row_id]?.action !== 'skip_row'); - const skipRows = previewRows.filter(r => decisions[r.row_id]?.action === 'skip_row'); - const canApply = unresolvedRows.length === 0 && pendingRows.length > 0 && applyState.status !== 'loading'; - - // ── Render ──────────────────────────────────────────────────────────────────── - return ( - - - {/* ── Upload panel ──────────────────────────────────────────────────────── */} -
- - {/* File picker */} -
- -
- setFile(e.target.files?.[0] ?? null)} /> - - {file && ( - - {file.name} - - )} -
-
- - {/* Options */} -
-
- opt('parseAllSheets', v)} - id="parse-all" /> - -
-
- - opt('defaultYear', e.target.value)} - className="w-24 h-8 text-sm" /> -
- {!options.parseAllSheets && ( -
- - opt('defaultMonth', e.target.value)} - className="w-20 h-8 text-sm" /> -
- )} -
- - {/* Preview button */} -
- - {(preview.status === 'ready' || preview.status === 'error' || applyState.status !== 'idle') && ( - - )} -
- - {/* Error from preview */} - {preview.status === 'error' && ( -
- {preview.error?.message || preview.error || 'Preview failed.'} - {preview.error?.details?.length > 0 && ( -
    - {preview.error.details.map((d, i) => ( -
  • {d.row_id ? `${d.row_id}: ` : ''}{d.message}
  • - ))} -
- )} -
- )} -
- - {/* ── Preview results ────────────────────────────────────────────────────── */} - {preview.status === 'ready' && preview.data && !['loading', 'done'].includes(applyState.status) && ( -
- - {/* Workbook summary */} - - - {/* Row decision table */} - {previewRows.length > 0 ? ( -
- {/* Tab header */} -
-
- - -
- - {viewMode === 'rows' - ? 'Select rows, apply bulk decisions, then import.' - : 'Click a bill to queue its entire history from this file.'} - -
- - {/* Rows view */} - {viewMode === 'rows' && ( - <> - - - - )} - - {/* Bills view */} - {viewMode === 'bills' && ( - - )} -
- ) : ( -

No data rows found in this file.

- )} - - {/* Apply bar */} - {previewRows.length > 0 && ( -
-
- {previewRows.length} rows reviewed - {pendingRows.length} to apply - {skipRows.length} skipped - {unresolvedRows.length > 0 && ( - {unresolvedRows.length} need a decision - )} -
- -
- )} -
- )} - - {/* ── Applying ──────────────────────────────────────────────────────────── */} - {applyState.status === 'loading' && ( -
- - Applying import… -
- )} - - {/* ── Apply result ──────────────────────────────────────────────────────── */} - {applyState.status === 'done' && applyState.result && ( -
-
-
- -

Import applied successfully

-
-
- {[ - { label: 'Created', value: applyState.result.rows_created, color: 'text-emerald-600' }, - { label: 'Updated', value: applyState.result.rows_updated, color: 'text-blue-600' }, - { label: 'Skipped', value: applyState.result.rows_skipped, color: 'text-muted-foreground' }, - { label: 'Errors', value: applyState.result.rows_errored, color: 'text-red-500' }, - ].map(({ label, value, color }) => ( -
-

{value}

-

{label}

-
- ))} -
-
- -
- )} - - {/* ── Apply error ───────────────────────────────────────────────────────── */} - {applyState.status === 'error' && ( -
-
- {applyState.error?.message || applyState.error || 'Apply failed.'} - {applyState.error?.details?.length > 0 && ( -
    - {applyState.error.details.map((d, i) => ( -
  • - {d.row_id ? `${d.row_id}: ` : ''} - {d.field ? `${d.field} - ` : ''} - {d.message} -
  • - ))} -
- )} - {applyState.error?.error_id && ( -

Error ID: {applyState.error.error_id}

- )} -
-
- )} -
- ); -} - -// ─── DataPage ───────────────────────────────────────────────────────────────── - -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 ( - -
- {statusLoading ? ( -

Loading…

- ) : seeded ? ( - <> -

Demo data seeded

-
-
-

Bills

-

{counts.bills}

-
-
-

Categories

-

{counts.categories}

-
-
- - ) : ( -

- Create 20 realistic demo bills and 8 demo categories for testing purposes. - The data will be associated with your account. -

- )} - -
- - - - - - - - - Clear Demo Data - - This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone. - - - - Cancel - - {clearing ? <>Clearing… : 'Clear Data'} - - - - -
-
-
- ); -} +import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection'; +import TransactionMatchingSection from '@/components/data/TransactionMatchingSection'; +import ImportSpreadsheetSection from '@/components/data/ImportSpreadsheetSection'; +import ImportMyDataSection from '@/components/data/ImportMyDataSection'; +import SeedDemoDataSection from '@/components/data/SeedDemoDataSection'; +import DownloadMyDataSection from '@/components/data/DownloadMyDataSection'; +import ImportHistorySection from '@/components/data/ImportHistorySection'; export default function DataPage() { const [history, setHistory] = useState(null); diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 35a1b11..e75ad1a 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -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 ( - - - - - {row.name} - - {MONTHS[month - 1]} {year} - - -

- Monthly overrides — changes only affect {MONTHS[month - 1]} -

-
- -
- {/* Actual amount this month */} -
- - setActualAmount(e.target.value)} - className="font-mono bg-background/50 border-border/60" - /> -

- Leave blank to use the template default ({fmt(row.expected_amount)}). -

-
- - {/* Monthly notes */} -
- - setNotes(e.target.value)} - placeholder="e.g. higher than usual, double-billed…" - className="bg-background/50 border-border/60" - /> -
- - {/* Skip this month */} - -
- - - - - -
-
- ); -} - -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 ( - { if (!value) onClose(); }}> - - - Monthly Starting Amounts -

{monthName}

-
- -
- {error && ( -
- {error} -
- )} - -
- - - -
- -
-
-
-

Total starting

-

{fmt(totalStarting)}

-
-
-

Paid so far

-

{fmt(paidSoFar)}

-
-
-

Total remaining

-

{fmt(totalRemaining)}

-
-
-
-
- 1st remaining - {fmt(firstRemaining)} -
-
- 15th remaining - {fmt(fifteenthRemaining)} -
-
- Other - {fmt(localOther)} -
-
-
-
- - - - - -
-
- ); -} - -// ── 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 ( - <> - { if (!v) onClose(); }}> - - - Edit Payment - - -
-
- - setAmount(e.target.value)} - className="font-mono bg-background/50 border-border/60" /> -
-
- - setDate(e.target.value)} - className="font-mono bg-background/50 border-border/60" /> -
-
- - -
-
- - setNotes(e.target.value)} - className="bg-background/50 border-border/60" /> -
-
- - - -
- - -
-
-
-
- - - - - Remove this payment? - - This marks the payment as removed and reverses any debt balance update. You can undo it from the toast. - - - - Cancel - - {busy ? 'Removing...' : 'Remove Payment'} - - - - - - ); -} - // ── Table row ────────────────────────────────────────────────────────────── function Row({ row, year, month, refresh, index, onEditBill }) { const amountRef = useRef(null); diff --git a/client/public/img/apple-touch-icon.png b/client/public/img/apple-touch-icon.png new file mode 100644 index 0000000..06c79f7 Binary files /dev/null and b/client/public/img/apple-touch-icon.png differ diff --git a/client/public/img/pwa-192.png b/client/public/img/pwa-192.png new file mode 100644 index 0000000..22a25bc Binary files /dev/null and b/client/public/img/pwa-192.png differ diff --git a/client/public/img/pwa-512.png b/client/public/img/pwa-512.png new file mode 100644 index 0000000..68ad5ec Binary files /dev/null and b/client/public/img/pwa-512.png differ diff --git a/index.html b/index.html index 4694330..7110bd8 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,12 @@ + + + + + + Bill Tracker diff --git a/package-lock.json b/package-lock.json index ac4b83d..f8ffcd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bill-tracker", - "version": "0.28.3", + "version": "0.28.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bill-tracker", - "version": "0.28.3", + "version": "0.28.4.4", "license": "ISC", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.2", @@ -52,7 +52,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" } }, "node_modules/@alloc/quick-lru": { @@ -67,14 +68,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -83,9 +101,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -124,14 +142,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -140,15 +158,28 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -157,40 +188,20 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -199,20 +210,174 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -220,9 +385,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -230,15 +395,30 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helpers": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", @@ -254,13 +434,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -269,6 +449,828 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", @@ -301,34 +1303,342 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -336,14 +1646,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -778,6 +2088,16 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -808,6 +2128,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1930,6 +3261,139 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", @@ -2372,6 +3836,22 @@ "react": "^18 || ^19" } }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2475,6 +3955,20 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2521,6 +4015,19 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/adler-32": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", @@ -2530,6 +4037,23 @@ "node": ">=0.8" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2593,12 +4117,78 @@ "node": ">=10" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -2636,6 +4226,64 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2646,6 +4294,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2770,6 +4428,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2840,6 +4511,13 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2849,6 +4527,25 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3127,6 +4824,16 @@ "node": ">= 6" } }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -3208,6 +4915,20 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -3237,6 +4958,31 @@ "node": ">=0.8" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3256,6 +5002,60 @@ "license": "MIT", "peer": true }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3310,6 +5110,52 @@ "node": ">=4.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3398,6 +5244,22 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -3430,6 +5292,75 @@ "once": "^1.4.0" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3460,6 +5391,40 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -3537,6 +5502,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3640,6 +5635,13 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3668,6 +5670,30 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3683,6 +5709,46 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3728,6 +5794,39 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3775,6 +5874,22 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3798,6 +5913,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3851,6 +6007,13 @@ "node": ">=6" } }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3864,12 +6027,55 @@ "node": ">= 0.4" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3882,6 +6088,23 @@ "node": ">=10.13.0" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3894,6 +6117,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3904,6 +6147,35 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3916,6 +6188,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -4025,6 +6313,13 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4063,6 +6358,21 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -4105,6 +6415,60 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4117,6 +6481,36 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -4132,6 +6526,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -4151,6 +6580,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4161,6 +6606,26 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4183,6 +6648,39 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4192,6 +6690,33 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -4204,6 +6729,222 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -4241,6 +6982,13 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4254,6 +7002,39 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4272,6 +7053,20 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -4313,6 +7108,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5259,6 +8064,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -5268,6 +8089,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -5412,6 +8243,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/oidc-token-hash": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", @@ -5475,6 +8337,31 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -5509,12 +8396,49 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -5557,6 +8481,16 @@ "node": ">= 6" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -5740,6 +8674,19 @@ "node": ">=10" } }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -5773,6 +8720,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -6045,6 +9002,108 @@ "node": ">=8.10.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/rehype-sanitize": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", @@ -6135,6 +9194,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6244,6 +9313,26 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6264,6 +9353,41 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6328,6 +9452,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -6343,12 +9477,84 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -6434,6 +9640,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -6479,6 +9698,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -6489,6 +9718,20 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6498,6 +9741,27 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -6529,6 +9793,20 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6553,6 +9831,93 @@ "node": ">=8" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6567,6 +9932,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6580,6 +9960,16 @@ "node": ">=8" } }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -6750,6 +10140,61 @@ "node": ">=6" } }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6837,6 +10282,16 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -6891,6 +10346,19 @@ "node": "*" } }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -6904,6 +10372,147 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -6923,6 +10532,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -6991,6 +10613,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7000,6 +10632,17 @@ "node": ">= 0.8" } }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -7186,6 +10829,161 @@ } } }, + "node_modules/vite-plugin-pwa": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", + "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", @@ -7204,6 +11002,226 @@ "node": ">=0.8" } }, + "node_modules/workbox-background-sync": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-build": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "eta": "^4.5.1", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "pretty-bytes": "^5.3.0", + "rollup": "^4.53.3", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.1" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 3568b18..826bc90 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/vite.config.mjs b/vite.config.mjs index 70ecd0e..df354b2 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -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