Compare commits
No commits in common. "ee7026872c35bfd2ecd62b5b1210fba379cd3e5b" and "8ef794a94a4d3115de6046333e02b7b3d9667105" have entirely different histories.
ee7026872c
...
8ef794a94a
|
|
@ -48,7 +48,6 @@ const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
|
|||
const HealthPage = lazy(() => import('@/pages/HealthPage'));
|
||||
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
|
||||
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
|
||||
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
|
||||
|
||||
function RequireAuth({ children, role }) {
|
||||
const { user, singleUserMode } = useAuth();
|
||||
|
|
@ -222,7 +221,6 @@ export default function App() {
|
|||
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="spending" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SpendingPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="bank-transactions" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BankTransactionsPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
|
|
|||
|
|
@ -403,7 +403,6 @@ export const api = {
|
|||
|
||||
// Transactions
|
||||
transactions: (params = {}) => get(`/transactions${queryString(params)}`),
|
||||
bankTransactionsLedger: (params = {}) => get(`/transactions/bank-ledger${queryString(params)}`),
|
||||
createManualTransaction: (data) => post('/transactions/manual', data),
|
||||
updateTransaction: (id, data) => put(`/transactions/${id}`, data),
|
||||
deleteTransaction: (id) => del(`/transactions/${id}`),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||
Landmark, Repeat, ShoppingCart,
|
||||
Repeat, ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useOverdueCount } from '@/hooks/useQueries';
|
||||
|
|
@ -43,17 +42,16 @@ const trackerItems = [
|
|||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
|
||||
{ to: '/spending', icon: ShoppingCart, label: 'Spending' },
|
||||
{ to: '/bank-transactions', icon: Landmark, label: 'Banking', simplefinOnly: true },
|
||||
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
||||
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
|
||||
];
|
||||
|
||||
function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems }) {
|
||||
function TrackerMenu({ onNavigate, badge, badgeNames = [] }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isTrackerActive = useMemo(() => items.some(item => (
|
||||
const isTrackerActive = useMemo(() => trackerItems.some(item => (
|
||||
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
||||
)), [items, location.pathname]);
|
||||
)), [location.pathname]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
|
@ -95,7 +93,7 @@ function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems
|
|||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
{items.map(item => {
|
||||
{trackerItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<DropdownMenuItem key={item.to} onSelect={() => { navigate(item.to); onNavigate?.(); }}>
|
||||
|
|
@ -195,27 +193,9 @@ export default function Sidebar({ adminMode = false }) {
|
|||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
|
||||
const [simplefinReady, setSimplefinReady] = useState(false);
|
||||
const { data: overdueData } = useOverdueCount();
|
||||
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
|
||||
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
|
||||
const trackerMenuItems = useMemo(
|
||||
() => trackerItems.filter(item => !item.simplefinOnly || simplefinReady),
|
||||
[simplefinReady],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (adminMode) return undefined;
|
||||
let cancelled = false;
|
||||
api.simplefinStatus()
|
||||
.then(status => {
|
||||
if (!cancelled) setSimplefinReady(Boolean(status?.enabled && status?.has_connections));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSimplefinReady(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [adminMode]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/95 shadow-sm shadow-foreground/10 backdrop-blur-md supports-[backdrop-filter]:bg-card/90">
|
||||
|
|
@ -223,7 +203,7 @@ export default function Sidebar({ adminMode = false }) {
|
|||
<BrandBlock adminMode={adminMode} />
|
||||
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} items={trackerMenuItems} />}
|
||||
{!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} />}
|
||||
{items.map(item => (
|
||||
<NavPill key={item.to} item={item} />
|
||||
))}
|
||||
|
|
@ -264,7 +244,7 @@ export default function Sidebar({ adminMode = false }) {
|
|||
{mobileOpen && (
|
||||
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-foreground/10 lg:hidden">
|
||||
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
||||
{!adminMode && trackerMenuItems.map(item => (
|
||||
{!adminMode && trackerItems.map(item => (
|
||||
<NavPill
|
||||
key={item.to}
|
||||
item={item}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Check, CloudUpload, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STATES = {
|
||||
saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' },
|
||||
saved: { icon: Check, text: 'Saved', cls: 'text-emerald-600 dark:text-emerald-300 border-emerald-500/30 bg-emerald-500/10' },
|
||||
error: { icon: AlertCircle, text: 'Save failed', cls: 'text-rose-500 dark:text-rose-300 border-rose-500/35 bg-rose-500/10' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tiny animated pill that reflects auto-save state. Renders nothing while idle —
|
||||
* the page communicates "changes save automatically" once, statically.
|
||||
*/
|
||||
export function SaveStatus({ status, className }) {
|
||||
const state = STATES[status];
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{state && (
|
||||
<motion.span
|
||||
key={status}
|
||||
initial={{ opacity: 0, y: -2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 2 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium',
|
||||
state.cls,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<state.icon className={cn('h-3 w-3', status === 'saving' && 'animate-pulse')} />
|
||||
{state.text}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Debounced auto-save with a status the UI can render.
|
||||
*
|
||||
* const { status, schedule, flush } = useAutoSave(payload => api.save(payload));
|
||||
* - schedule(payload[, delayMs]) — (re)arms the debounce with the latest payload
|
||||
* - flush() — saves any pending payload immediately (e.g. on blur)
|
||||
* - status — 'idle' | 'saving' | 'saved' | 'error' ('saved' fades to 'idle')
|
||||
*
|
||||
* Pending changes are flushed on unmount so navigating away never loses edits.
|
||||
*/
|
||||
export function useAutoSave(saveFn, defaultDelay = 400) {
|
||||
const [status, setStatus] = useState('idle');
|
||||
const timer = useRef(null);
|
||||
const pending = useRef(null);
|
||||
const saveRef = useRef(saveFn);
|
||||
saveRef.current = saveFn;
|
||||
|
||||
const run = useCallback(async (payload) => {
|
||||
pending.current = null;
|
||||
setStatus('saving');
|
||||
try {
|
||||
await saveRef.current(payload);
|
||||
setStatus('saved');
|
||||
} catch {
|
||||
setStatus('error'); // saveFn is responsible for surfacing the error (toast)
|
||||
}
|
||||
}, []);
|
||||
|
||||
const schedule = useCallback((payload, delay = defaultDelay) => {
|
||||
pending.current = payload;
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => run(payload), delay);
|
||||
}, [run, defaultDelay]);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (pending.current != null) {
|
||||
clearTimeout(timer.current);
|
||||
run(pending.current);
|
||||
}
|
||||
}, [run]);
|
||||
|
||||
// Fade the "Saved" confirmation back to idle.
|
||||
useEffect(() => {
|
||||
if (status !== 'saved') return undefined;
|
||||
const t = setTimeout(() => setStatus('idle'), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [status]);
|
||||
|
||||
// Never lose a pending edit on unmount.
|
||||
useEffect(() => () => {
|
||||
clearTimeout(timer.current);
|
||||
if (pending.current != null) {
|
||||
Promise.resolve(saveRef.current(pending.current)).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { status, schedule, flush };
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAutoSave } from './useAutoSave';
|
||||
|
||||
describe('useAutoSave', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('debounces: only the latest payload is saved, once', async () => {
|
||||
const save = vi.fn().mockResolvedValue();
|
||||
const { result } = renderHook(() => useAutoSave(save, 400));
|
||||
|
||||
act(() => {
|
||||
result.current.schedule({ a: 1 });
|
||||
result.current.schedule({ a: 2 });
|
||||
result.current.schedule({ a: 3 });
|
||||
});
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith({ a: 3 });
|
||||
expect(result.current.status).toBe('saved');
|
||||
});
|
||||
|
||||
it('per-call delay overrides the default', async () => {
|
||||
const save = vi.fn().mockResolvedValue();
|
||||
const { result } = renderHook(() => useAutoSave(save, 400));
|
||||
|
||||
act(() => { result.current.schedule({ slow: true }, 900); });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
|
||||
expect(save).toHaveBeenCalledWith({ slow: true });
|
||||
});
|
||||
|
||||
it('flush() saves a pending payload immediately and is a no-op when idle', async () => {
|
||||
const save = vi.fn().mockResolvedValue();
|
||||
const { result } = renderHook(() => useAutoSave(save, 400));
|
||||
|
||||
await act(async () => { result.current.flush(); });
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
act(() => { result.current.schedule({ b: 1 }, 900); });
|
||||
await act(async () => { result.current.flush(); });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith({ b: 1 });
|
||||
|
||||
// payload no longer pending — flushing again must not double-save
|
||||
await act(async () => { result.current.flush(); });
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports error status when the save rejects, then recovers on next save', async () => {
|
||||
const save = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce();
|
||||
const { result } = renderHook(() => useAutoSave(save, 100));
|
||||
|
||||
act(() => { result.current.schedule({ x: 1 }); });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(result.current.status).toBe('error');
|
||||
|
||||
act(() => { result.current.schedule({ x: 2 }); });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(result.current.status).toBe('saved');
|
||||
});
|
||||
|
||||
it('"saved" fades back to idle after 2 seconds', async () => {
|
||||
const save = vi.fn().mockResolvedValue();
|
||||
const { result } = renderHook(() => useAutoSave(save, 100));
|
||||
|
||||
act(() => { result.current.schedule({ y: 1 }); });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(result.current.status).toBe('saved');
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||
expect(result.current.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('flushes a pending payload on unmount so edits are never lost', async () => {
|
||||
const save = vi.fn().mockResolvedValue();
|
||||
const { result, unmount } = renderHook(() => useAutoSave(save, 900));
|
||||
|
||||
act(() => { result.current.schedule({ unsaved: true }); });
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith({ unsaved: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,567 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
ArrowUp,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Landmark,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
WalletCards,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const flowOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'in', label: 'Money in' },
|
||||
{ value: 'out', label: 'Money out' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'unmatched', label: 'Needs review' },
|
||||
{ value: 'matched', label: 'Matched' },
|
||||
{ value: 'ignored', label: 'Ignored' },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'amount', label: 'Amount' },
|
||||
{ value: 'merchant', label: 'Merchant' },
|
||||
{ value: 'account', label: 'Account' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
];
|
||||
|
||||
function formatCents(value, { signed = false } = {}) {
|
||||
const cents = Number(value || 0);
|
||||
const amount = fmt(Math.abs(cents) / 100);
|
||||
if (!signed || cents === 0) return amount;
|
||||
return `${cents > 0 ? '+' : '-'}${amount}`;
|
||||
}
|
||||
|
||||
function formatSyncTime(value) {
|
||||
if (!value) return 'Not synced yet';
|
||||
const normalized = String(value).includes('T') ? String(value) : String(value).replace(' ', 'T');
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function transactionTitle(tx) {
|
||||
return tx.payee || tx.description || tx.memo || 'Transaction';
|
||||
}
|
||||
|
||||
function transactionDate(tx) {
|
||||
return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null);
|
||||
}
|
||||
|
||||
function accountLabel(account) {
|
||||
if (!account) return 'Unknown account';
|
||||
return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account';
|
||||
}
|
||||
|
||||
function StatusBadge({ tx }) {
|
||||
if (tx.pending) {
|
||||
return (
|
||||
<Badge className="border-amber-300/50 bg-amber-400/15 text-amber-700 dark:text-amber-200">
|
||||
<Clock3 className="mr-1 h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (tx.ignored || tx.match_status === 'ignored') {
|
||||
return <Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">Ignored</Badge>;
|
||||
}
|
||||
if (tx.match_status === 'matched') {
|
||||
return (
|
||||
<Badge className="border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Matched
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge className="border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200">Review</Badge>;
|
||||
}
|
||||
|
||||
function SummaryTile({ icon: Icon, label, value, tone, detail }) {
|
||||
const tones = {
|
||||
emerald: 'border-emerald-300/40 bg-emerald-400/10 text-emerald-700 dark:text-emerald-200',
|
||||
rose: 'border-rose-300/40 bg-rose-400/10 text-rose-700 dark:text-rose-200',
|
||||
sky: 'border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200',
|
||||
amber: 'border-amber-300/40 bg-amber-400/10 text-amber-700 dark:text-amber-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/80 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
<span className={cn('flex h-9 w-9 items-center justify-center rounded-full border', tones[tone])}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="tracker-number mt-3 text-2xl font-bold text-foreground">{value}</p>
|
||||
{detail && <p className="mt-1 text-xs font-medium text-muted-foreground">{detail}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionMobileCard({ tx }) {
|
||||
const cents = Number(tx.amount || 0);
|
||||
const isCredit = cents > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{transactionTitle(tx)}</p>
|
||||
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
||||
{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cn('tracker-number shrink-0 text-sm font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
|
||||
{formatCents(cents, { signed: true })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="text-muted-foreground">{fmtDate(transactionDate(tx))}</Badge>
|
||||
<StatusBadge tx={tx} />
|
||||
{tx.matched_bill_name && (
|
||||
<Badge className="border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200">
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
{tx.matched_bill_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BankTransactionsPage() {
|
||||
const [ledger, setLedger] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [accountId, setAccountId] = useState('all');
|
||||
const [flow, setFlow] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('date');
|
||||
const [sortDir, setSortDir] = useState('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
// Monotonic request id: a response only lands if it belongs to the newest
|
||||
// request, so a slow Refresh can never overwrite fresher filter results.
|
||||
const requestSeq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setQuery(search.trim());
|
||||
setPage(1);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [accountId, flow, sortBy, sortDir]);
|
||||
|
||||
// Single fetch path — used by both the load effect and the Refresh button.
|
||||
const loadLedger = useCallback(async () => {
|
||||
const seq = ++requestSeq.current;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.bankTransactionsLedger({
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
q: query,
|
||||
account_id: accountId === 'all' ? undefined : accountId,
|
||||
flow,
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir,
|
||||
});
|
||||
if (seq === requestSeq.current) setLedger(data);
|
||||
} catch (err) {
|
||||
if (seq === requestSeq.current) setError(err.message || 'Unable to load bank transactions');
|
||||
} finally {
|
||||
if (seq === requestSeq.current) setLoading(false);
|
||||
}
|
||||
}, [accountId, flow, page, query, sortBy, sortDir]);
|
||||
|
||||
useEffect(() => { loadLedger(); }, [loadLedger]);
|
||||
|
||||
const accounts = ledger?.accounts || [];
|
||||
const transactions = ledger?.transactions || [];
|
||||
const summary = ledger?.summary || {};
|
||||
const total = Number(ledger?.total || 0);
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
// If a filter shrinks the result set below the current page, snap back to
|
||||
// the last real page instead of stranding the user on an empty one.
|
||||
useEffect(() => {
|
||||
if (!loading && page > totalPages) setPage(totalPages);
|
||||
}, [loading, page, totalPages]);
|
||||
const latestSync = useMemo(() => {
|
||||
const times = (ledger?.sources || []).map(source => source.last_sync_at).filter(Boolean).sort();
|
||||
return times[times.length - 1] || null;
|
||||
}, [ledger?.sources]);
|
||||
const connected = Boolean(ledger?.enabled && ledger?.has_connections);
|
||||
|
||||
// A failed request must never masquerade as "not connected" — without this,
|
||||
// a transient API error told users with working bank sync to go connect it.
|
||||
if (!loading && error && !ledger) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-lg border border-rose-300/40 bg-rose-400/10 p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">Bank Transactions</h1>
|
||||
<p className="mt-1 text-sm font-medium text-rose-700 dark:text-rose-200">{error}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={loadLedger}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && !connected) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-lg border border-border/70 bg-card/85 p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200">
|
||||
<Landmark className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-normal text-foreground">Bank Transactions</h1>
|
||||
<p className="text-sm font-medium text-muted-foreground">SimpleFIN bridge required</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/data">
|
||||
<WalletCards className="h-4 w-4" />
|
||||
Open Data
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-lg border border-border/70 bg-[linear-gradient(135deg,oklch(var(--card))_0%,oklch(var(--muted)/0.72)_58%,oklch(var(--primary)/0.10)_100%)] p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200">
|
||||
<Landmark className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-normal text-foreground sm:text-3xl">Bank Transactions</h1>
|
||||
<p className="mt-1 text-sm font-medium text-muted-foreground">
|
||||
{accounts.length} monitored {accounts.length === 1 ? 'account' : 'accounts'} synced through SimpleFIN
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={cn(
|
||||
connected
|
||||
? 'border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200'
|
||||
: 'border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200',
|
||||
)}>
|
||||
{connected ? 'Connected' : 'Loading'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="rounded-lg border border-border/70 bg-card/75 px-3 py-2 text-sm shadow-sm">
|
||||
<span className="font-medium text-muted-foreground">Last sync </span>
|
||||
<span className="font-semibold text-foreground">{formatSyncTime(latestSync)}</span>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={loadLedger} disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-rose-300/40 bg-rose-400/10 px-4 py-3 text-sm font-medium text-rose-700 dark:text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryTile
|
||||
icon={TrendingUp}
|
||||
label="Money in"
|
||||
value={formatCents(summary.inflow)}
|
||||
tone="emerald"
|
||||
detail={`${Number(summary.matched || 0)} matched`}
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={TrendingDown}
|
||||
label="Money out"
|
||||
value={formatCents(summary.outflow)}
|
||||
tone="rose"
|
||||
detail={`${Number(summary.unmatched || 0)} need review`}
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={ArrowDownUp}
|
||||
label="Net flow"
|
||||
value={formatCents(summary.net, { signed: true })}
|
||||
tone="sky"
|
||||
detail={summary.latest_date ? `Latest ${fmtDate(summary.latest_date)}` : 'No posted activity'}
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={Clock3}
|
||||
label="Pending"
|
||||
value={String(Number(summary.pending || 0))}
|
||||
tone="amber"
|
||||
detail={`${Number(summary.total || 0)} total transactions`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accounts.map(account => (
|
||||
<div key={account.id} className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{accountLabel(account)}</p>
|
||||
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
||||
{[account.account_type, account.currency].filter(Boolean).join(' - ') || 'Bank account'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-indigo-300/40 bg-indigo-400/10 text-indigo-700 dark:text-indigo-200">
|
||||
<Building2 className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Balance</p>
|
||||
<p className="tracker-number mt-1 text-lg font-bold text-foreground">{formatCents(account.balance)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Available</p>
|
||||
<p className="tracker-number mt-1 text-lg font-bold text-emerald-600 dark:text-emerald-300">
|
||||
{account.available_balance == null ? '—' : formatCents(account.available_balance)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-xs font-medium text-muted-foreground">
|
||||
<span>{Number(account.transaction_count || 0)} transactions</span>
|
||||
<span>{account.last_transaction_date ? fmtDate(account.last_transaction_date) : 'No activity'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-3 rounded-lg border border-border/70 bg-card/85 p-3 shadow-sm lg:grid-cols-[minmax(18rem,1fr)_12rem_12rem_10rem_auto]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={event => setSearch(event.target.value)}
|
||||
placeholder="Search merchant, memo, category"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={accountId} onValueChange={setAccountId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All accounts</SelectItem>
|
||||
{accounts.map(account => (
|
||||
<SelectItem key={account.id} value={String(account.id)}>{accountLabel(account)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={flow} onValueChange={setFlow}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{flowOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSortDir(value => (value === 'asc' ? 'desc' : 'asc'))}
|
||||
title={`Sort ${sortDir === 'asc' ? 'ascending' : 'descending'}`}
|
||||
>
|
||||
{sortDir === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />}
|
||||
{sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-hidden rounded-lg border border-border/70 bg-card/85 shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/35 hover:bg-muted/35">
|
||||
<TableHead className="w-32">Date</TableHead>
|
||||
<TableHead>Merchant</TableHead>
|
||||
<TableHead>Account</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && transactions.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">Loading transactions...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && transactions.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">No transactions found.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{transactions.map(tx => {
|
||||
const cents = Number(tx.amount || 0);
|
||||
const isCredit = cents > 0;
|
||||
return (
|
||||
<TableRow key={tx.id}>
|
||||
<TableCell className="text-muted-foreground">{fmtDate(transactionDate(tx))}</TableCell>
|
||||
<TableCell>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{transactionTitle(tx)}</p>
|
||||
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||
{[tx.memo, tx.category].filter(Boolean).join(' - ') || tx.description || 'SimpleFIN'}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
|
||||
</p>
|
||||
<p className="truncate text-xs font-medium text-muted-foreground">{tx.account_type || tx.currency || 'Bank'}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge tx={tx} />
|
||||
{tx.matched_bill_name && (
|
||||
<Badge className="border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200">
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
{tx.matched_bill_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cn('tracker-number text-right font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
|
||||
{formatCents(cents, { signed: true })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:hidden">
|
||||
{loading && transactions.length === 0 && (
|
||||
<div className="rounded-lg border border-border/70 bg-card/85 p-8 text-center text-sm font-medium text-muted-foreground">
|
||||
Loading transactions...
|
||||
</div>
|
||||
)}
|
||||
{!loading && transactions.length === 0 && (
|
||||
<div className="rounded-lg border border-border/70 bg-card/85 p-8 text-center text-sm font-medium text-muted-foreground">
|
||||
No transactions found.
|
||||
</div>
|
||||
)}
|
||||
{transactions.map(tx => <TransactionMobileCard key={tx.id} tx={tx} />)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-card/85 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{total === 0
|
||||
? '0 transactions'
|
||||
: `${(page - 1) * PAGE_SIZE + 1}-${Math.min(page * PAGE_SIZE, total)} of ${total} transactions`}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => setPage(value => Math.max(1, value - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="min-w-20 text-center text-sm font-semibold text-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages || loading}
|
||||
onClick={() => setPage(value => Math.min(totalPages, value + 1))}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,8 +6,6 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useAutoSave } from '@/hooks/useAutoSave';
|
||||
import { SaveStatus } from '@/components/ui/save-status';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
|
@ -36,7 +34,7 @@ function formatDateTime(value) {
|
|||
+ d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function SectionCard({ title, icon: Icon, subtitle, action, children }) {
|
||||
function SectionCard({ title, icon: Icon, subtitle, children }) {
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
|
||||
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
||||
|
|
@ -47,7 +45,6 @@ function SectionCard({ title, icon: Icon, subtitle, action, children }) {
|
|||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-muted-foreground mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="ml-auto shrink-0">{action}</div>}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
|
|
@ -365,7 +362,13 @@ function EditProfile({ profile, onSaved }) {
|
|||
|
||||
// Exported: rendered on the Settings page ("Notifications" section). Lives here
|
||||
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
|
||||
function buildNotificationPayload(form) {
|
||||
export function NotificationPreferences({ settings, onSaved }) {
|
||||
const [form, setForm] = useState(settings);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => setForm(settings), [settings]);
|
||||
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
|
||||
|
||||
const payload = {
|
||||
email: form.email || form.notification_email || '',
|
||||
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
|
||||
|
|
@ -380,48 +383,26 @@ function buildNotificationPayload(form) {
|
|||
payload.notify_1d = payload.notify_1_day;
|
||||
payload.notify_day_of = payload.notify_due;
|
||||
payload.notify_daily_overdue = payload.notify_overdue;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function NotificationPreferences({ settings }) {
|
||||
const [form, setForm] = useState(settings);
|
||||
|
||||
// Auto-save: toggles persist almost instantly, the email field debounces so
|
||||
// we never save a half-typed address. Local form stays the source of truth —
|
||||
// no parent refresh that could clobber in-flight edits.
|
||||
const { status, schedule, flush } = useAutoSave(
|
||||
(payload) => api.updateProfileSettings(payload).catch((err) => {
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = await api.updateProfileSettings(payload);
|
||||
toast.success('Notification preferences saved.');
|
||||
onSaved(asSettings(data));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save notification preferences.');
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
|
||||
const set = (k, v, delay) => setForm(prev => {
|
||||
const next = { ...prev, [k]: v };
|
||||
schedule(buildNotificationPayload(next), delay);
|
||||
return next;
|
||||
});
|
||||
|
||||
const payload = buildNotificationPayload(form);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Notification Preferences"
|
||||
icon={Mail}
|
||||
subtitle="Manage email reminders for your bills. Changes save automatically."
|
||||
action={<SaveStatus status={status} />}
|
||||
>
|
||||
<SectionCard title="Notification Preferences" icon={Mail} subtitle="Manage email reminders for your bills.">
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div className="space-y-1.5 max-w-md">
|
||||
<label htmlFor="profile-email" className="text-xs font-medium text-muted-foreground">Email</label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={payload.email}
|
||||
onChange={e => set('email', e.target.value, 900)}
|
||||
onBlur={flush}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<Input id="profile-email" type="email" value={payload.email} onChange={e => set('email', e.target.value)} placeholder="you@example.com" />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<CheckRow id="n-enabled" label="Notifications enabled" checked={payload.notifications_enabled} onChange={v => set('notifications_enabled', v)} />
|
||||
|
|
@ -432,6 +413,11 @@ export function NotificationPreferences({ settings }) {
|
|||
<CheckRow id="n-amount" label="Notify on price changes" checked={payload.notify_amount_change} onChange={v => set('notify_amount_change', v)} disabled={!payload.notifications_enabled} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-border/50 flex justify-end">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</> : 'Save Preferences'}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -456,37 +442,23 @@ export function PushNotifications({ settings, onSaved }) {
|
|||
|
||||
const ch = PUSH_CHANNELS.find(c => c.value === channel) || PUSH_CHANNELS[0];
|
||||
|
||||
// Auto-save. Toggle/channel persist immediately; URL and chat ID debounce.
|
||||
// The token is deliberately NOT auto-saved while typing — a half-typed token
|
||||
// must never overwrite a working one. It saves on blur, when complete.
|
||||
const buildPatch = (over = {}) => {
|
||||
const s = { enabled, channel, url, chatId, ...over };
|
||||
return {
|
||||
notify_push_enabled: s.enabled,
|
||||
push_channel: s.channel,
|
||||
push_url: (s.url || '').trim() || null,
|
||||
push_chat_id: (s.chatId || '').trim() || null,
|
||||
};
|
||||
};
|
||||
|
||||
const { status, schedule, flush } = useAutoSave(
|
||||
(patch) => api.updateProfileSettings(patch).catch((err) => {
|
||||
toast.error(err.message || 'Failed to save push settings.');
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
|
||||
const saveToken = async () => {
|
||||
const t = token.trim();
|
||||
if (!t) return;
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateProfileSettings({ ...buildPatch(), push_token: t });
|
||||
setTokenSet(true);
|
||||
const patch = {
|
||||
notify_push_enabled: enabled,
|
||||
push_channel: channel,
|
||||
push_url: url.trim() || null,
|
||||
push_chat_id: chatId.trim() || null,
|
||||
};
|
||||
if (token.trim()) patch.push_token = token.trim();
|
||||
await api.updateProfileSettings(patch);
|
||||
setTokenSet(!!token.trim() || tokenSet);
|
||||
setToken('');
|
||||
toast.success('Token saved.');
|
||||
toast.success('Push notification settings saved.');
|
||||
onSaved?.();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save token.');
|
||||
toast.error(err.message || 'Failed to save push settings.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -505,12 +477,7 @@ export function PushNotifications({ settings, onSaved }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Push Notifications"
|
||||
icon={Bell}
|
||||
subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram. Changes save automatically."
|
||||
action={<SaveStatus status={status} />}
|
||||
>
|
||||
<SectionCard title="Push Notifications" icon={Bell} subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram.">
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
|
||||
{/* Master toggle — same CheckRow pattern as the email section */}
|
||||
|
|
@ -518,7 +485,7 @@ export function PushNotifications({ settings, onSaved }) {
|
|||
id="push-enabled"
|
||||
label="Enable push notifications"
|
||||
checked={enabled}
|
||||
onChange={(v) => { setEnabled(v); schedule(buildPatch({ enabled: v }), 150); }}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
{enabled && (
|
||||
<p className="text-xs text-muted-foreground -mt-3 pl-1">
|
||||
|
|
@ -536,7 +503,7 @@ export function PushNotifications({ settings, onSaved }) {
|
|||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
onClick={() => { setChannel(c.value); schedule(buildPatch({ channel: c.value }), 150); }}
|
||||
onClick={() => setChannel(c.value)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
channel === c.value
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
|
|
@ -555,8 +522,7 @@ export function PushNotifications({ settings, onSaved }) {
|
|||
<label className="text-xs font-medium text-muted-foreground">{ch.urlLabel}</label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={e => { setUrl(e.target.value); schedule(buildPatch({ url: e.target.value }), 900); }}
|
||||
onBlur={flush}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder={ch.urlHint}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
|
@ -573,8 +539,7 @@ export function PushNotifications({ settings, onSaved }) {
|
|||
<Input
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
onBlur={saveToken}
|
||||
placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token — saves when you click away'}
|
||||
placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token…'}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
|
@ -586,8 +551,7 @@ export function PushNotifications({ settings, onSaved }) {
|
|||
<label className="text-xs font-medium text-muted-foreground">{ch.chatIdLabel}</label>
|
||||
<Input
|
||||
value={chatId}
|
||||
onChange={e => { setChatId(e.target.value); schedule(buildPatch({ chatId: e.target.value }), 900); }}
|
||||
onBlur={flush}
|
||||
onChange={e => setChatId(e.target.value)}
|
||||
placeholder="e.g. 123456789"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
|
@ -620,20 +584,22 @@ export function PushNotifications({ settings, onSaved }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border/50 flex items-center gap-3">
|
||||
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={test}
|
||||
disabled={testing || saving || !enabled || !url.trim()}
|
||||
disabled={testing || !enabled || !url.trim()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{testing
|
||||
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Sending…</>
|
||||
: <><SendHorizontal className="h-3.5 w-3.5" />Send test</>}
|
||||
</Button>
|
||||
<p className="text-[11px] text-muted-foreground">Settings save as you change them.</p>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import {
|
|||
import { Switch } from '@/components/ui/switch';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useAutoSave } from '@/hooks/useAutoSave';
|
||||
import { SaveStatus } from '@/components/ui/save-status';
|
||||
import { NotificationPreferences, PushNotifications, asSettings } from '@/pages/ProfilePage';
|
||||
|
||||
export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
|
||||
|
|
@ -289,6 +287,7 @@ export default function SettingsPage() {
|
|||
const [settings, setSettings] = useState(DEFAULTS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadSettings = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
|
@ -301,36 +300,31 @@ export default function SettingsPage() {
|
|||
|
||||
useEffect(() => { loadSettings(); }, [loadSettings]);
|
||||
|
||||
const buildPayload = (s) => ({
|
||||
currency: s.currency,
|
||||
date_format: s.date_format,
|
||||
grace_period_days: s.grace_period_days,
|
||||
drift_threshold_pct: s.drift_threshold_pct,
|
||||
tracker_show_bank_projection_banner: s.tracker_show_bank_projection_banner,
|
||||
tracker_bank_projection_banner_snoozed_until: s.tracker_bank_projection_banner_snoozed_until || '',
|
||||
tracker_show_search_sort: s.tracker_show_search_sort,
|
||||
tracker_show_summary_cards: s.tracker_show_summary_cards,
|
||||
tracker_show_safe_to_spend: s.tracker_show_safe_to_spend,
|
||||
tracker_show_overdue_command_center: s.tracker_show_overdue_command_center,
|
||||
tracker_show_drift_insights: s.tracker_show_drift_insights,
|
||||
});
|
||||
const set = (k, v) => setSettings((p) => ({ ...p, [k]: v }));
|
||||
|
||||
// Auto-save: every change persists on its own — no Save button. Toggles and
|
||||
// selects feel instant (short debounce); typed inputs get a longer one so we
|
||||
// don't save half-typed numbers.
|
||||
const { status: saveStatus, schedule } = useAutoSave(
|
||||
(payload) => api.saveSettings(payload).catch((err) => {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveSettings({
|
||||
currency: settings.currency,
|
||||
date_format: settings.date_format,
|
||||
grace_period_days: settings.grace_period_days,
|
||||
drift_threshold_pct: settings.drift_threshold_pct,
|
||||
tracker_show_bank_projection_banner: settings.tracker_show_bank_projection_banner,
|
||||
tracker_bank_projection_banner_snoozed_until: settings.tracker_bank_projection_banner_snoozed_until || '',
|
||||
tracker_show_search_sort: settings.tracker_show_search_sort,
|
||||
tracker_show_summary_cards: settings.tracker_show_summary_cards,
|
||||
tracker_show_safe_to_spend: settings.tracker_show_safe_to_spend,
|
||||
tracker_show_overdue_command_center: settings.tracker_show_overdue_command_center,
|
||||
tracker_show_drift_insights: settings.tracker_show_drift_insights,
|
||||
});
|
||||
toast.success('Settings saved.');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save settings.');
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
|
||||
const set = (k, v, delay) => setSettings((p) => {
|
||||
const next = { ...p, [k]: v };
|
||||
schedule(buildPayload(next), delay);
|
||||
return next;
|
||||
});
|
||||
const setTyped = (k, v) => set(k, v, 900); // for keystroke-driven inputs
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -358,17 +352,10 @@ export default function SettingsPage() {
|
|||
return (
|
||||
<div>
|
||||
|
||||
{/* Page header — flat on background, live save status on the right */}
|
||||
<div className="mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Manage your display, billing, and notification preferences · changes save automatically
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-1.5">
|
||||
<SaveStatus status={saveStatus} />
|
||||
</div>
|
||||
{/* Page header — flat on background */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Manage your display, billing, and notification preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
|
|
@ -484,7 +471,7 @@ export default function SettingsPage() {
|
|||
min={0}
|
||||
max={30}
|
||||
value={settings.grace_period_days}
|
||||
onChange={(e) => setTyped('grace_period_days', parseInt(e.target.value, 10) || 0)}
|
||||
onChange={(e) => set('grace_period_days', parseInt(e.target.value, 10) || 0)}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
|
|
@ -501,7 +488,7 @@ export default function SettingsPage() {
|
|||
max={25}
|
||||
step={1}
|
||||
value={settings.drift_threshold_pct ?? '5'}
|
||||
onChange={(e) => setTyped('drift_threshold_pct', e.target.value)}
|
||||
onChange={(e) => set('drift_threshold_pct', e.target.value)}
|
||||
className="w-20 font-mono"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
|
|
@ -509,7 +496,14 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* Notifications — email + push reminder preferences (auto-save too) */}
|
||||
{/* Save button — right-aligned below all cards */}
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Notifications — email + push reminder preferences (save independently) */}
|
||||
<div id="notifications" className="mt-6 scroll-mt-24">
|
||||
<NotificationsSection />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.39.0",
|
||||
"version": "0.38.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bill-tracker",
|
||||
"version": "0.39.0",
|
||||
"version": "0.38.4",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
|
|
@ -53,11 +53,9 @@
|
|||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^5.4.10",
|
||||
|
|
@ -94,57 +92,6 @@
|
|||
"ajv": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
|
|
@ -1719,159 +1666,6 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
|
||||
"integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
|
||||
"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
|
|
@ -2297,24 +2091,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
|
|
@ -5509,55 +5285,6 @@
|
|||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -5585,14 +5312,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -5960,17 +5679,6 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -6235,16 +5943,6 @@
|
|||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
|
@ -6888,20 +6586,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
|
|
@ -6921,58 +6605,6 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
|
|
@ -7053,13 +6685,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
|
|
@ -7217,14 +6842,6 @@
|
|||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
@ -7292,19 +6909,6 @@
|
|||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
|
|
@ -8340,19 +7944,6 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
|
@ -8810,13 +8401,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
|
|
@ -9058,95 +8642,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/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/jsdom/node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
|
|
@ -9559,17 +9054,6 @@
|
|||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -9869,13 +9353,6 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
|
|
@ -10935,19 +10412,6 @@
|
|||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
|
@ -11273,36 +10737,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
|
|
@ -11542,14 +10976,6 @@
|
|||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
|
|
@ -12167,19 +11593,6 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
|
|
@ -12855,13 +12268,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||
|
|
@ -13103,26 +12509,6 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
|
||||
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.2"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
|
||||
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -13144,19 +12530,6 @@
|
|||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
||||
|
|
@ -13362,16 +12735,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -13981,19 +13344,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||
|
|
@ -14001,16 +13351,6 @@
|
|||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
||||
|
|
@ -14434,23 +13774,6 @@
|
|||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.39.0",
|
||||
"version": "0.38.4",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -61,11 +61,9 @@
|
|||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^5.4.10",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const {
|
|||
getTransactionForUser,
|
||||
} = require('../services/transactionService');
|
||||
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
|
||||
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
|
||||
const {
|
||||
ignoreTransaction,
|
||||
matchTransactionToBill,
|
||||
|
|
@ -273,81 +272,6 @@ function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction op
|
|||
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
|
||||
}
|
||||
|
||||
function emptyBankLedger(enabled, hasConnections = false) {
|
||||
return {
|
||||
enabled: Boolean(enabled),
|
||||
has_connections: Boolean(hasConnections),
|
||||
sources: [],
|
||||
accounts: [],
|
||||
summary: {
|
||||
total: 0,
|
||||
inflow: 0,
|
||||
outflow: 0,
|
||||
net: 0,
|
||||
pending: 0,
|
||||
matched: 0,
|
||||
unmatched: 0,
|
||||
latest_date: null,
|
||||
},
|
||||
transactions: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function bankLedgerWhere(query, userId) {
|
||||
const where = [
|
||||
't.user_id = ?',
|
||||
"ds.type = 'provider_sync'",
|
||||
"ds.provider = 'simplefin'",
|
||||
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
|
||||
];
|
||||
const params = [userId];
|
||||
|
||||
if (query.account_id && query.account_id !== 'all') {
|
||||
const parsed = parseInteger(query.account_id, 'account_id');
|
||||
if (parsed.error) return { error: parsed.error };
|
||||
where.push('t.account_id = ?');
|
||||
params.push(parsed.value);
|
||||
}
|
||||
|
||||
const flow = query.flow ? String(query.flow).trim() : 'all';
|
||||
if (!['all', 'in', 'out', 'pending', 'matched', 'unmatched', 'ignored'].includes(flow)) {
|
||||
return { error: standardizeError('flow must be all, in, out, pending, matched, unmatched, or ignored', 'VALIDATION_ERROR', 'flow') };
|
||||
}
|
||||
if (flow === 'in') where.push('t.amount > 0');
|
||||
if (flow === 'out') where.push('t.amount < 0');
|
||||
if (flow === 'pending') where.push('t.pending = 1');
|
||||
if (flow === 'matched') where.push("t.match_status = 'matched'");
|
||||
if (flow === 'unmatched') where.push("t.match_status = 'unmatched' AND t.ignored = 0");
|
||||
if (flow === 'ignored') where.push('t.ignored = 1');
|
||||
|
||||
if (query.start_date) {
|
||||
const parsed = parseDate(query.start_date, 'start_date');
|
||||
if (parsed.error) return { error: parsed.error };
|
||||
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?");
|
||||
params.push(parsed.value);
|
||||
}
|
||||
if (query.end_date) {
|
||||
const parsed = parseDate(query.end_date, 'end_date');
|
||||
if (parsed.error) return { error: parsed.error };
|
||||
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?");
|
||||
params.push(parsed.value);
|
||||
}
|
||||
|
||||
if (query.q) {
|
||||
const q = `%${String(query.q).trim()}%`;
|
||||
where.push(`(
|
||||
t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?
|
||||
OR fa.name LIKE ? OR fa.org_name LIKE ? OR b.name LIKE ?
|
||||
)`);
|
||||
params.push(q, q, q, q, q, q, q);
|
||||
}
|
||||
|
||||
return { whereClause: where.join(' AND '), params };
|
||||
}
|
||||
|
||||
// GET /api/transactions
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
@ -476,109 +400,6 @@ router.get('/', (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
// GET /api/transactions/bank-ledger
|
||||
router.get('/bank-ledger', (req, res) => {
|
||||
const config = getBankSyncConfig();
|
||||
const page = parseLimitOffset(req.query);
|
||||
if (page.error) return res.status(400).json(page.error);
|
||||
|
||||
const db = getDb();
|
||||
const sources = db.prepare(`
|
||||
SELECT id, user_id, type, provider, name, status, last_sync_at, created_at, updated_at
|
||||
FROM data_sources
|
||||
WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
`).all(req.user.id);
|
||||
|
||||
if (!config.enabled || sources.length === 0) {
|
||||
return res.json({ ...emptyBankLedger(config.enabled, sources.length > 0), sources, limit: page.limit, offset: page.offset });
|
||||
}
|
||||
|
||||
const accounts = db.prepare(`
|
||||
SELECT
|
||||
fa.id, fa.data_source_id, fa.name, fa.org_name, fa.account_type, fa.currency,
|
||||
fa.balance, fa.available_balance, fa.monitored, fa.updated_at,
|
||||
ds.name AS source_name, ds.status AS source_status, ds.last_sync_at,
|
||||
COUNT(t.id) AS transaction_count,
|
||||
MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS last_transaction_date
|
||||
FROM financial_accounts fa
|
||||
JOIN data_sources ds ON ds.id = fa.data_source_id AND ds.user_id = fa.user_id
|
||||
LEFT JOIN transactions t ON t.account_id = fa.id AND t.user_id = fa.user_id
|
||||
WHERE fa.user_id = ?
|
||||
AND fa.monitored = 1
|
||||
AND ds.type = 'provider_sync'
|
||||
AND ds.provider = 'simplefin'
|
||||
GROUP BY fa.id
|
||||
ORDER BY COALESCE(fa.org_name, ''), fa.name
|
||||
`).all(req.user.id);
|
||||
|
||||
const filtered = bankLedgerWhere(req.query, req.user.id);
|
||||
if (filtered.error) return res.status(400).json(filtered.error);
|
||||
|
||||
const SORT_COLUMNS = {
|
||||
date: "COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)",
|
||||
amount: 't.amount',
|
||||
merchant: "LOWER(COALESCE(t.payee, t.description, t.memo, ''))",
|
||||
account: "LOWER(COALESCE(fa.org_name, fa.name, ''))",
|
||||
status: 't.match_status',
|
||||
};
|
||||
const sortBy = SORT_COLUMNS[req.query.sort_by] ? req.query.sort_by : 'date';
|
||||
const sortDir = req.query.sort_dir === 'asc' ? 'ASC' : 'DESC';
|
||||
const orderBy = `${SORT_COLUMNS[sortBy]} ${sortDir}, t.id ${sortDir}`;
|
||||
|
||||
const joins = `
|
||||
FROM transactions t
|
||||
JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
|
||||
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
|
||||
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
|
||||
`;
|
||||
|
||||
const summary = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0) AS inflow,
|
||||
COALESCE(SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END), 0) AS outflow,
|
||||
COALESCE(SUM(t.amount), 0) AS net,
|
||||
COALESCE(SUM(CASE WHEN t.pending = 1 THEN 1 ELSE 0 END), 0) AS pending,
|
||||
COALESCE(SUM(CASE WHEN t.match_status = 'matched' THEN 1 ELSE 0 END), 0) AS matched,
|
||||
COALESCE(SUM(CASE WHEN t.match_status = 'unmatched' AND t.ignored = 0 THEN 1 ELSE 0 END), 0) AS unmatched,
|
||||
MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS latest_date
|
||||
${joins}
|
||||
WHERE ${filtered.whereClause}
|
||||
`).get(...filtered.params);
|
||||
|
||||
const total = summary.total;
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
|
||||
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
|
||||
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
|
||||
t.match_status, t.ignored, t.pending, t.created_at, t.updated_at,
|
||||
ds.type AS data_source_type, ds.provider AS data_source_provider,
|
||||
ds.name AS data_source_name, ds.status AS data_source_status,
|
||||
fa.name AS account_name, fa.org_name AS account_org_name,
|
||||
fa.account_type AS account_type, fa.balance AS account_balance,
|
||||
fa.available_balance AS account_available_balance,
|
||||
b.name AS matched_bill_name
|
||||
${joins}
|
||||
WHERE ${filtered.whereClause}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...filtered.params, page.limit, page.offset);
|
||||
|
||||
res.json({
|
||||
enabled: true,
|
||||
has_connections: true,
|
||||
sources,
|
||||
accounts,
|
||||
summary,
|
||||
transactions: rows.map(row => decorateTransaction(row)),
|
||||
total,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/transactions/manual
|
||||
router.post('/manual', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export default defineConfig({
|
|||
// Server tests stay on node:test (`npm run test`); client tests run with
|
||||
// `npm run test:client`; `npm run test:all` runs both.
|
||||
test: {
|
||||
environment: 'node', // hook/component tests opt into jsdom via @vitest-environment
|
||||
include: ['client/**/*.test.{js,jsx}'],
|
||||
environment: 'node',
|
||||
include: ['client/**/*.test.js'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue