2026-07-04 23:20:47 -05:00
|
|
|
import { Fragment, useCallback, useEffect, useMemo, useState, type ComponentType } from 'react';
|
2026-06-12 03:59:42 -05:00
|
|
|
import { Link } from 'react-router-dom';
|
2026-07-03 20:25:47 -05:00
|
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { useBankLedger } from '@/hooks/useQueries';
|
2026-06-14 15:15:31 -05:00
|
|
|
import { toast } from 'sonner';
|
2026-06-12 03:59:42 -05:00
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
ArrowDown,
|
|
|
|
|
ArrowDownUp,
|
|
|
|
|
ArrowUp,
|
|
|
|
|
Building2,
|
|
|
|
|
Check,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Clock3,
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
|
|
|
|
Landmark,
|
|
|
|
|
Link2,
|
|
|
|
|
Link2Off,
|
|
|
|
|
MoreVertical,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Search,
|
|
|
|
|
Sparkles,
|
|
|
|
|
TrendingDown,
|
|
|
|
|
TrendingUp,
|
|
|
|
|
WalletCards,
|
2026-06-12 03:59:42 -05:00
|
|
|
} 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';
|
2026-06-14 15:15:31 -05:00
|
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
2026-06-12 03:59:42 -05:00
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
2026-06-12 03:59:42 -05:00
|
|
|
} from '@/components/ui/select';
|
|
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
Table,
|
|
|
|
|
TableBody,
|
|
|
|
|
TableCell,
|
|
|
|
|
TableHead,
|
|
|
|
|
TableHeader,
|
|
|
|
|
TableRow,
|
2026-06-12 03:59:42 -05:00
|
|
|
} from '@/components/ui/table';
|
2026-06-14 15:15:31 -05:00
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
2026-06-14 15:15:31 -05:00
|
|
|
} from '@/components/ui/dropdown-menu';
|
|
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
2026-06-14 15:15:31 -05:00
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
|
|
|
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
|
|
|
|
import { MatchBillDialog } from '@/components/transactions/MatchBillDialog';
|
|
|
|
|
import BillModal from '@/components/BillModal';
|
2026-07-04 23:20:47 -05:00
|
|
|
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
|
2026-07-05 12:02:28 -05:00
|
|
|
import { formatUSD, asDollars } from '@/lib/money';
|
|
|
|
|
import type {
|
2026-07-06 14:23:53 -05:00
|
|
|
Bill,
|
|
|
|
|
Category,
|
|
|
|
|
BankTransaction,
|
2026-07-05 12:02:28 -05:00
|
|
|
BankLedgerTransaction as Tx,
|
|
|
|
|
BankAccount as Account,
|
|
|
|
|
BankLedger as Ledger,
|
|
|
|
|
BankLedgerSummary as LedgerSummary,
|
|
|
|
|
BankCategoryBreakdown as CategoryBreakdown,
|
|
|
|
|
AutoCategorizeChange as AutoCatChange,
|
|
|
|
|
AutoCategorizePreview as AutoCatPreview,
|
|
|
|
|
} from '@/types';
|
2026-07-04 23:20:47 -05:00
|
|
|
|
|
|
|
|
function fmt(v: number | null | undefined): string {
|
|
|
|
|
return formatUSD(asDollars(Number(v) || 0));
|
|
|
|
|
}
|
2026-06-12 03:59:42 -05:00
|
|
|
|
|
|
|
|
const PAGE_SIZE = 50;
|
2026-06-14 15:15:31 -05:00
|
|
|
const TABLE_COLUMN_COUNT = 8;
|
2026-06-12 03:59:42 -05:00
|
|
|
|
|
|
|
|
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' },
|
2026-06-14 15:15:31 -05:00
|
|
|
{ value: 'uncategorized', label: 'Needs category' },
|
2026-06-12 03:59:42 -05:00
|
|
|
{ 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' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function formatCents(
|
|
|
|
|
value: number | null | undefined,
|
|
|
|
|
{ signed = false }: { signed?: boolean } = {},
|
|
|
|
|
): string {
|
2026-06-12 03:59:42 -05:00
|
|
|
const cents = Number(value || 0);
|
|
|
|
|
const amount = fmt(Math.abs(cents) / 100);
|
|
|
|
|
if (!signed || cents === 0) return amount;
|
|
|
|
|
return `${cents > 0 ? '+' : '-'}${amount}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
function formatSyncTime(value: unknown): string {
|
2026-06-12 03:59:42 -05:00
|
|
|
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, {
|
2026-07-06 14:23:53 -05:00
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
hour: 'numeric',
|
|
|
|
|
minute: '2-digit',
|
2026-06-12 03:59:42 -05:00
|
|
|
}).format(date);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
function transactionTitle(tx: Tx): string {
|
2026-06-12 03:59:42 -05:00
|
|
|
return tx.payee || tx.description || tx.memo || 'Transaction';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
function transactionDate(tx: Tx): string | null {
|
2026-06-12 03:59:42 -05:00
|
|
|
return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
function dateGroupLabel(dateStr: string | null): string {
|
2026-06-14 15:15:31 -05:00
|
|
|
if (!dateStr) return 'Unknown date';
|
|
|
|
|
const today = localDateString();
|
|
|
|
|
const yesterdayDate = new Date();
|
|
|
|
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
|
|
|
|
const yesterday = localDateString(yesterdayDate);
|
|
|
|
|
if (dateStr === today) return 'Today';
|
|
|
|
|
if (dateStr === yesterday) return 'Yesterday';
|
|
|
|
|
return fmtDate(dateStr);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
interface DateGroup {
|
|
|
|
|
date?: string | null;
|
|
|
|
|
label: string | null;
|
|
|
|
|
items: Tx[];
|
|
|
|
|
}
|
2026-07-04 23:20:47 -05:00
|
|
|
|
|
|
|
|
function groupByDate(transactions: Tx[], sortBy: string): DateGroup[] | null {
|
2026-06-14 15:15:31 -05:00
|
|
|
if (sortBy !== 'date') return null;
|
2026-07-04 23:20:47 -05:00
|
|
|
const groups: DateGroup[] = [];
|
|
|
|
|
let current: DateGroup | null = null;
|
2026-06-14 15:15:31 -05:00
|
|
|
for (const tx of transactions) {
|
|
|
|
|
const date = transactionDate(tx);
|
|
|
|
|
if (!current || current.date !== date) {
|
|
|
|
|
current = { date, label: dateGroupLabel(date), items: [] };
|
|
|
|
|
groups.push(current);
|
|
|
|
|
}
|
|
|
|
|
current.items.push(tx);
|
|
|
|
|
}
|
|
|
|
|
return groups;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
function accountLabel(account?: Account): string {
|
2026-06-12 03:59:42 -05:00
|
|
|
if (!account) return 'Unknown account';
|
|
|
|
|
return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
function StatusBadge({ tx }: { tx: Tx }) {
|
2026-06-12 03:59:42 -05:00
|
|
|
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') {
|
2026-07-06 14:23:53 -05:00
|
|
|
return (
|
|
|
|
|
<Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">
|
|
|
|
|
Ignored
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
2026-06-12 03:59:42 -05:00
|
|
|
}
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
return (
|
|
|
|
|
<Badge className="border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200">Review</Badge>
|
|
|
|
|
);
|
2026-06-12 03:59:42 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function SummaryTile({
|
|
|
|
|
icon: Icon,
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
tone,
|
|
|
|
|
detail,
|
|
|
|
|
}: {
|
2026-07-04 23:20:47 -05:00
|
|
|
icon: ComponentType<{ className?: string }>;
|
|
|
|
|
label: React.ReactNode;
|
|
|
|
|
value: React.ReactNode;
|
|
|
|
|
tone: string;
|
|
|
|
|
detail?: React.ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
const tones: Record<string, string> = {
|
2026-06-12 03:59:42 -05:00
|
|
|
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>
|
2026-07-06 14:23:53 -05:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex h-9 w-9 items-center justify-center rounded-full border',
|
|
|
|
|
tones[tone],
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-12 03:59:42 -05:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
function MerchantAvatar({ tx }: { tx: Tx }) {
|
2026-06-14 15:15:31 -05:00
|
|
|
const tone = categoryColor(tx.spending_category_name || 'Uncategorized');
|
|
|
|
|
const initial = transactionTitle(tx).trim().charAt(0).toUpperCase() || '?';
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold',
|
|
|
|
|
tone.bg,
|
|
|
|
|
tone.text,
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
{initial}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function SuggestionBadge({
|
|
|
|
|
tx,
|
|
|
|
|
onApply,
|
|
|
|
|
applying,
|
|
|
|
|
}: {
|
|
|
|
|
tx: Tx;
|
|
|
|
|
onApply: (tx: Tx) => void;
|
|
|
|
|
applying?: boolean;
|
|
|
|
|
}) {
|
2026-06-14 15:15:31 -05:00
|
|
|
if (!tx.suggested_match) return null;
|
|
|
|
|
return (
|
|
|
|
|
<div className="mt-1 flex items-center gap-1.5">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Badge
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="max-w-[160px] truncate border-dashed text-[10px] text-muted-foreground"
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
{tx.suggested_match.display_name} · {tx.suggested_match.category}
|
|
|
|
|
</Badge>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={applying}
|
|
|
|
|
onClick={() => onApply(tx)}
|
|
|
|
|
title="Apply suggested category"
|
|
|
|
|
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<Check className="h-3 w-3" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function RowActionsMenu({
|
|
|
|
|
tx,
|
|
|
|
|
onMatch,
|
|
|
|
|
onUnmatch,
|
|
|
|
|
onIgnore,
|
|
|
|
|
onUnignore,
|
|
|
|
|
}: {
|
2026-07-04 23:20:47 -05:00
|
|
|
tx: Tx;
|
|
|
|
|
onMatch: (tx: Tx) => void;
|
|
|
|
|
onUnmatch: (tx: Tx) => void;
|
|
|
|
|
onIgnore: (tx: Tx) => void;
|
|
|
|
|
onUnignore: (tx: Tx) => void;
|
|
|
|
|
}) {
|
2026-06-14 15:15:31 -05:00
|
|
|
const isMatched = tx.match_status === 'matched';
|
|
|
|
|
const isIgnored = tx.ignored || tx.match_status === 'ignored';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8">
|
|
|
|
|
<MoreVertical className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
<DropdownMenuItem onClick={() => onMatch(tx)}>
|
|
|
|
|
<Link2 className="h-3.5 w-3.5" />
|
|
|
|
|
{isMatched ? 'Change bill match…' : 'Match to bill…'}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
{isMatched && (
|
|
|
|
|
<DropdownMenuItem onClick={() => onUnmatch(tx)}>
|
|
|
|
|
<Link2Off className="h-3.5 w-3.5" />
|
|
|
|
|
Unmatch
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
)}
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
{isIgnored ? (
|
|
|
|
|
<DropdownMenuItem onClick={() => onUnignore(tx)}>
|
|
|
|
|
<Eye className="h-3.5 w-3.5" />
|
|
|
|
|
Unignore
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
) : (
|
|
|
|
|
<DropdownMenuItem onClick={() => onIgnore(tx)}>
|
|
|
|
|
<EyeOff className="h-3.5 w-3.5" />
|
|
|
|
|
Ignore
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
)}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function TransactionMobileCard({
|
|
|
|
|
tx,
|
|
|
|
|
categories,
|
|
|
|
|
onCategorize,
|
|
|
|
|
onApplySuggestion,
|
|
|
|
|
applyingSuggestionId,
|
|
|
|
|
onMatch,
|
|
|
|
|
onUnmatch,
|
|
|
|
|
onIgnore,
|
|
|
|
|
onUnignore,
|
|
|
|
|
selected,
|
|
|
|
|
onToggleSelected,
|
|
|
|
|
}: {
|
2026-07-04 23:20:47 -05:00
|
|
|
tx: Tx;
|
|
|
|
|
categories: Category[];
|
|
|
|
|
onCategorize: (tx: Tx, categoryId: number | string | null) => void;
|
|
|
|
|
onApplySuggestion: (tx: Tx) => void;
|
|
|
|
|
applyingSuggestionId: number | null;
|
|
|
|
|
onMatch: (tx: Tx) => void;
|
|
|
|
|
onUnmatch: (tx: Tx) => void;
|
|
|
|
|
onIgnore: (tx: Tx) => void;
|
|
|
|
|
onUnignore: (tx: Tx) => void;
|
|
|
|
|
selected: boolean;
|
|
|
|
|
onToggleSelected: (id: number) => void;
|
|
|
|
|
}) {
|
2026-06-12 03:59:42 -05:00
|
|
|
const cents = Number(tx.amount || 0);
|
|
|
|
|
const isCredit = cents > 0;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm',
|
|
|
|
|
selected && 'ring-2 ring-primary/50',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-12 03:59:42 -05:00
|
|
|
<div className="flex items-start justify-between gap-3">
|
2026-06-14 15:15:31 -05:00
|
|
|
<div className="flex min-w-0 items-start gap-2.5">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Checkbox
|
|
|
|
|
className="mt-1"
|
|
|
|
|
checked={selected}
|
|
|
|
|
onCheckedChange={() => onToggleSelected(tx.id)}
|
|
|
|
|
aria-label="Select transaction"
|
|
|
|
|
/>
|
2026-06-14 15:15:31 -05:00
|
|
|
<MerchantAvatar tx={tx} />
|
|
|
|
|
<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>
|
2026-06-12 03:59:42 -05:00
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<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',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-12 03:59:42 -05:00
|
|
|
{formatCents(cents, { signed: true })}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Badge variant="outline" className="text-muted-foreground">
|
|
|
|
|
{fmtDate(transactionDate(tx))}
|
|
|
|
|
</Badge>
|
2026-06-12 03:59:42 -05:00
|
|
|
<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>
|
2026-06-14 15:15:31 -05:00
|
|
|
<div className="mt-3 flex items-center justify-between gap-2">
|
|
|
|
|
<div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<CategoryPicker
|
|
|
|
|
categories={categories}
|
|
|
|
|
current={tx.spending_category_id}
|
|
|
|
|
onSelect={(categoryId) => onCategorize(tx, categoryId)}
|
|
|
|
|
/>
|
|
|
|
|
<SuggestionBadge
|
|
|
|
|
tx={tx}
|
|
|
|
|
onApply={onApplySuggestion}
|
|
|
|
|
applying={applyingSuggestionId === tx.id}
|
|
|
|
|
/>
|
2026-06-14 15:15:31 -05:00
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<RowActionsMenu
|
|
|
|
|
tx={tx}
|
|
|
|
|
onMatch={onMatch}
|
|
|
|
|
onUnmatch={onUnmatch}
|
|
|
|
|
onIgnore={onIgnore}
|
|
|
|
|
onUnignore={onUnignore}
|
|
|
|
|
/>
|
2026-06-14 15:15:31 -05:00
|
|
|
</div>
|
2026-06-12 03:59:42 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function BankTransactionsPage() {
|
2026-07-03 20:25:47 -05:00
|
|
|
const queryClient = useQueryClient();
|
2026-06-12 03:59:42 -05:00
|
|
|
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);
|
2026-07-04 23:20:47 -05:00
|
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
|
|
|
const [bills, setBills] = useState<Bill[]>([]);
|
|
|
|
|
const [matchTarget, setMatchTarget] = useState<Tx | null>(null);
|
2026-06-14 15:15:31 -05:00
|
|
|
const [matchSubmitting, setMatchSubmitting] = useState(false);
|
2026-07-04 23:20:47 -05:00
|
|
|
const [applyingSuggestionId, setApplyingSuggestionId] = useState<number | null>(null);
|
2026-06-14 15:15:31 -05:00
|
|
|
const [autoCategorizing, setAutoCategorizing] = useState(false);
|
2026-07-04 23:20:47 -05:00
|
|
|
const [autoCategorizePreview, setAutoCategorizePreview] = useState<AutoCatPreview | null>(null);
|
2026-07-06 14:23:53 -05:00
|
|
|
const [createBillSourceTx, setCreateBillSourceTx] = useState<{
|
|
|
|
|
tx: Tx;
|
|
|
|
|
initialBill: Partial<Bill>;
|
|
|
|
|
} | null>(null);
|
2026-07-04 23:20:47 -05:00
|
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
|
2026-06-14 15:15:31 -05:00
|
|
|
const [bulkActing, setBulkActing] = useState(false);
|
2026-07-04 23:20:47 -05:00
|
|
|
// React Query keys the ledger on the filters/page.
|
2026-07-06 14:23:53 -05:00
|
|
|
const {
|
|
|
|
|
data: ledgerRaw = null,
|
|
|
|
|
isPending: loading,
|
|
|
|
|
isFetching,
|
|
|
|
|
error: ledgerErr,
|
|
|
|
|
refetch: loadLedger,
|
|
|
|
|
} = useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
|
2026-07-05 12:02:28 -05:00
|
|
|
const ledger = ledgerRaw;
|
2026-07-06 14:23:53 -05:00
|
|
|
const error = ledgerErr ? ledgerErr.message || 'Unable to load bank transactions' : '';
|
|
|
|
|
const setLedger = useCallback(
|
|
|
|
|
(u: (prev: Ledger | undefined) => Ledger | undefined) =>
|
|
|
|
|
queryClient.setQueryData<Ledger>(
|
|
|
|
|
['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
|
|
|
|
|
u,
|
|
|
|
|
),
|
|
|
|
|
[queryClient, accountId, flow, page, query, sortBy, sortDir],
|
|
|
|
|
);
|
2026-06-12 03:59:42 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const id = window.setTimeout(() => {
|
|
|
|
|
setQuery(search.trim());
|
|
|
|
|
setPage(1);
|
|
|
|
|
}, 250);
|
|
|
|
|
return () => window.clearTimeout(id);
|
|
|
|
|
}, [search]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setPage(1);
|
|
|
|
|
}, [accountId, flow, sortBy, sortDir]);
|
|
|
|
|
|
2026-06-14 15:15:31 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
setSelectedIds(new Set());
|
|
|
|
|
}, [accountId, flow, sortBy, sortDir, query, page]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-07-06 14:23:53 -05:00
|
|
|
api
|
|
|
|
|
.categories()
|
|
|
|
|
.then((data) => setCategories((data as Category[]) || []))
|
|
|
|
|
.catch(() => setCategories([]));
|
|
|
|
|
api
|
|
|
|
|
.bills()
|
|
|
|
|
.then((data) => setBills((data as Bill[]) || []))
|
|
|
|
|
.catch(() => setBills([]));
|
2026-06-14 15:15:31 -05:00
|
|
|
}, []);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const updateTransaction = useCallback(
|
|
|
|
|
(id: number, patch: Partial<Tx>) => {
|
|
|
|
|
setLedger((prev) => {
|
|
|
|
|
if (!prev) return prev;
|
|
|
|
|
return {
|
|
|
|
|
...prev,
|
|
|
|
|
transactions: (prev.transactions || []).map((tx) =>
|
|
|
|
|
tx.id === id ? { ...tx, ...patch } : tx,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[setLedger],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleCategorize = useCallback(
|
|
|
|
|
async (tx: Tx, categoryId: number | string | null) => {
|
|
|
|
|
try {
|
|
|
|
|
await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false });
|
|
|
|
|
const categoryName = categories.find((c) => c.id === categoryId)?.name ?? null;
|
2026-06-14 15:15:31 -05:00
|
|
|
updateTransaction(tx.id, {
|
2026-07-06 14:23:53 -05:00
|
|
|
spending_category_id: categoryId as number | null,
|
|
|
|
|
spending_category_name: categoryName,
|
2026-06-14 15:15:31 -05:00
|
|
|
suggested_match: null,
|
|
|
|
|
});
|
2026-07-06 14:23:53 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(errMessage(err, 'Failed to categorize transaction'));
|
2026-06-14 15:15:31 -05:00
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
},
|
|
|
|
|
[categories, updateTransaction],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleApplySuggestion = useCallback(
|
|
|
|
|
async (tx: Tx) => {
|
|
|
|
|
setApplyingSuggestionId(tx.id);
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.applyTransactionMerchantMatch(tx.id);
|
|
|
|
|
if (result.matched && result.category) {
|
|
|
|
|
updateTransaction(tx.id, {
|
|
|
|
|
spending_category_id: result.category.id,
|
|
|
|
|
spending_category_name: result.category.name,
|
|
|
|
|
suggested_match: null,
|
|
|
|
|
});
|
|
|
|
|
toast.success(`Categorized as ${result.category.name}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(errMessage(err, 'Failed to apply suggestion'));
|
|
|
|
|
} finally {
|
|
|
|
|
setApplyingSuggestionId(null);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[updateTransaction],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
|
|
|
const handleAutoCategorize = useCallback(async () => {
|
|
|
|
|
setAutoCategorizing(true);
|
|
|
|
|
try {
|
2026-07-05 12:02:28 -05:00
|
|
|
const preview = await api.autoCategorizeTransactions({ dry_run: true });
|
2026-06-14 15:15:31 -05:00
|
|
|
if (!preview?.changes?.length) {
|
|
|
|
|
toast.success('No new matches found');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setAutoCategorizePreview(preview);
|
|
|
|
|
} catch (err) {
|
2026-07-04 23:20:47 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to preview auto-categorize'));
|
2026-06-14 15:15:31 -05:00
|
|
|
} finally {
|
|
|
|
|
setAutoCategorizing(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleUndoAutoCategorize = useCallback(
|
|
|
|
|
async (changes: AutoCatChange[]) => {
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all(
|
|
|
|
|
changes.map((c) =>
|
|
|
|
|
api.categorizeTransaction(c.transaction_id, { category_id: null, save_rule: false }),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
toast.success('Auto-categorize undone');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(errMessage(err, 'Failed to undo auto-categorize'));
|
|
|
|
|
} finally {
|
|
|
|
|
loadLedger();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[loadLedger],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
|
|
|
const handleConfirmAutoCategorize = useCallback(async () => {
|
|
|
|
|
setAutoCategorizing(true);
|
|
|
|
|
try {
|
2026-07-05 12:02:28 -05:00
|
|
|
const result = await api.autoCategorizeTransactions();
|
2026-06-14 15:15:31 -05:00
|
|
|
const changes = result?.changes || [];
|
|
|
|
|
setAutoCategorizePreview(null);
|
|
|
|
|
await Promise.all([
|
|
|
|
|
loadLedger(),
|
2026-07-06 14:23:53 -05:00
|
|
|
api
|
|
|
|
|
.categories()
|
|
|
|
|
.then((data) => setCategories((data as Category[]) || []))
|
|
|
|
|
.catch(() => {}),
|
2026-06-14 15:15:31 -05:00
|
|
|
]);
|
|
|
|
|
const count = changes.length;
|
|
|
|
|
toast.success(`Categorized ${count} transaction${count === 1 ? '' : 's'}`, {
|
|
|
|
|
action: { label: 'Undo', onClick: () => handleUndoAutoCategorize(changes) },
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
2026-07-04 23:20:47 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to auto-categorize transactions'));
|
2026-06-14 15:15:31 -05:00
|
|
|
} finally {
|
|
|
|
|
setAutoCategorizing(false);
|
|
|
|
|
}
|
|
|
|
|
}, [loadLedger, handleUndoAutoCategorize]);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleConfirmMatch = useCallback(
|
|
|
|
|
async (billId: number | string) => {
|
|
|
|
|
if (!matchTarget) return;
|
|
|
|
|
setMatchSubmitting(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.matchTransaction(matchTarget.id, billId);
|
|
|
|
|
updateTransaction(matchTarget.id, result.transaction);
|
|
|
|
|
setMatchTarget(null);
|
|
|
|
|
toast.success('Transaction matched to bill');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(errMessage(err, 'Failed to match transaction'));
|
|
|
|
|
} finally {
|
|
|
|
|
setMatchSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[matchTarget, updateTransaction],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
const openCreateBill = useCallback((txArg: BankTransaction | null, nameOverride?: string) => {
|
|
|
|
|
const tx = txArg as Tx | null;
|
|
|
|
|
if (!tx) return;
|
2026-06-14 15:15:31 -05:00
|
|
|
const amount = Math.abs(Number(tx.amount || 0)) / 100;
|
|
|
|
|
const dateStr = transactionDate(tx);
|
|
|
|
|
const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1;
|
|
|
|
|
setCreateBillSourceTx({
|
|
|
|
|
tx,
|
|
|
|
|
initialBill: {
|
|
|
|
|
name: nameOverride || transactionTitle(tx),
|
2026-07-04 23:20:47 -05:00
|
|
|
expected_amount: asDollars(amount || 0),
|
2026-06-14 15:15:31 -05:00
|
|
|
due_day: day >= 1 && day <= 31 ? day : 1,
|
|
|
|
|
billing_cycle: 'monthly',
|
|
|
|
|
cycle_type: 'monthly',
|
|
|
|
|
cycle_day: '1',
|
|
|
|
|
active: 1,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleBillCreated = useCallback(
|
|
|
|
|
async (newBill?: Bill) => {
|
|
|
|
|
const tx = createBillSourceTx?.tx;
|
|
|
|
|
setCreateBillSourceTx(null);
|
|
|
|
|
setMatchTarget(null);
|
|
|
|
|
if (tx && newBill?.id) {
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.matchTransaction(tx.id, newBill.id);
|
|
|
|
|
updateTransaction(tx.id, result.transaction);
|
|
|
|
|
toast.success('Bill created and matched to transaction');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(errMessage(err, 'Bill created but match failed'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
api
|
|
|
|
|
.bills()
|
|
|
|
|
.then((data) => setBills((data as Bill[]) || []))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
},
|
|
|
|
|
[createBillSourceTx, updateTransaction],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleUnmatch = useCallback(
|
|
|
|
|
async (tx: Tx) => {
|
2026-06-14 15:15:31 -05:00
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const result = await api.unmatchTransaction(tx.id);
|
2026-06-14 15:15:31 -05:00
|
|
|
updateTransaction(tx.id, result.transaction);
|
2026-07-06 14:23:53 -05:00
|
|
|
toast.success('Transaction unmatched');
|
2026-06-14 15:15:31 -05:00
|
|
|
} catch (err) {
|
2026-07-06 14:23:53 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to unmatch transaction'));
|
|
|
|
|
loadLedger();
|
2026-06-14 15:15:31 -05:00
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
},
|
|
|
|
|
[updateTransaction, loadLedger],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleIgnore = useCallback(
|
|
|
|
|
async (tx: Tx) => {
|
|
|
|
|
try {
|
|
|
|
|
const transaction = await api.ignoreTransaction(tx.id);
|
|
|
|
|
updateTransaction(tx.id, transaction);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(errMessage(err, 'Failed to ignore transaction'));
|
|
|
|
|
loadLedger();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[updateTransaction, loadLedger],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleUnignore = useCallback(
|
|
|
|
|
async (tx: Tx) => {
|
|
|
|
|
try {
|
|
|
|
|
const transaction = await api.unignoreTransaction(tx.id);
|
|
|
|
|
updateTransaction(tx.id, transaction);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(errMessage(err, 'Failed to unignore transaction'));
|
|
|
|
|
loadLedger();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[updateTransaction, loadLedger],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-06-12 03:59:42 -05:00
|
|
|
const accounts = ledger?.accounts || [];
|
2026-07-03 19:47:14 -05:00
|
|
|
const transactions = useMemo(() => ledger?.transactions || [], [ledger]);
|
2026-06-12 03:59:42 -05:00
|
|
|
const summary = ledger?.summary || {};
|
|
|
|
|
const total = Number(ledger?.total || 0);
|
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
2026-06-14 15:15:31 -05:00
|
|
|
const categoryBreakdown = summary.category_breakdown || [];
|
2026-07-06 14:23:53 -05:00
|
|
|
const maxCategoryTotal =
|
|
|
|
|
categoryBreakdown.reduce((max, c) => Math.max(max, Number(c.total || 0)), 0) || 1;
|
2026-06-14 15:15:31 -05:00
|
|
|
const dateGroups = useMemo(() => groupByDate(transactions, sortBy), [transactions, sortBy]);
|
2026-06-12 03:59:42 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!loading && page > totalPages) setPage(totalPages);
|
|
|
|
|
}, [loading, page, totalPages]);
|
|
|
|
|
const latestSync = useMemo(() => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const times = (ledger?.sources || [])
|
|
|
|
|
.map((source) => source.last_sync_at)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.sort();
|
2026-06-12 03:59:42 -05:00
|
|
|
return times[times.length - 1] || null;
|
|
|
|
|
}, [ledger?.sources]);
|
|
|
|
|
const connected = Boolean(ledger?.enabled && ledger?.has_connections);
|
|
|
|
|
|
2026-06-14 15:15:31 -05:00
|
|
|
const selectedTransactions = useMemo(
|
2026-07-06 14:23:53 -05:00
|
|
|
() => transactions.filter((tx) => selectedIds.has(tx.id)),
|
2026-06-14 15:15:31 -05:00
|
|
|
[transactions, selectedIds],
|
|
|
|
|
);
|
2026-07-06 14:23:53 -05:00
|
|
|
const allOnPageSelected =
|
|
|
|
|
transactions.length > 0 && transactions.every((tx) => selectedIds.has(tx.id));
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-07-04 23:20:47 -05:00
|
|
|
const toggleSelected = useCallback((id: number) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
setSelectedIds((prev) => {
|
2026-06-14 15:15:31 -05:00
|
|
|
const next = new Set(prev);
|
2026-07-06 14:23:53 -05:00
|
|
|
if (next.has(id)) next.delete(id);
|
|
|
|
|
else next.add(id);
|
2026-06-14 15:15:31 -05:00
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const toggleSelectAll = useCallback(() => {
|
2026-07-06 14:23:53 -05:00
|
|
|
setSelectedIds((prev) => {
|
|
|
|
|
if (transactions.length > 0 && transactions.every((tx) => prev.has(tx.id))) return new Set();
|
|
|
|
|
return new Set(transactions.map((tx) => tx.id));
|
2026-06-14 15:15:31 -05:00
|
|
|
});
|
|
|
|
|
}, [transactions]);
|
|
|
|
|
|
|
|
|
|
const handleBulkApplySuggestions = useCallback(async () => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const targets = selectedTransactions.filter(
|
|
|
|
|
(tx) => tx.suggested_match && !tx.spending_category_id,
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
if (targets.length === 0) return;
|
|
|
|
|
setBulkActing(true);
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
targets.map((tx) => api.applyTransactionMerchantMatch(tx.id)),
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
let applied = 0;
|
|
|
|
|
results.forEach((r, i) => {
|
2026-07-05 12:02:28 -05:00
|
|
|
const val = r.status === 'fulfilled' ? r.value : null;
|
2026-07-04 23:20:47 -05:00
|
|
|
if (val?.matched && val.category) {
|
|
|
|
|
updateTransaction(targets[i]!.id, {
|
|
|
|
|
spending_category_id: val.category.id,
|
|
|
|
|
spending_category_name: val.category.name,
|
2026-06-14 15:15:31 -05:00
|
|
|
suggested_match: null,
|
|
|
|
|
});
|
|
|
|
|
applied++;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
toast.success(`Applied suggestions to ${applied} of ${targets.length}`);
|
|
|
|
|
if (applied < targets.length) loadLedger();
|
|
|
|
|
} finally {
|
|
|
|
|
setBulkActing(false);
|
|
|
|
|
setSelectedIds(new Set());
|
|
|
|
|
}
|
|
|
|
|
}, [selectedTransactions, updateTransaction, loadLedger]);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleBulkCategorize = useCallback(
|
|
|
|
|
async (categoryId: number | string | null) => {
|
|
|
|
|
const targets = selectedTransactions;
|
|
|
|
|
if (targets.length === 0) return;
|
|
|
|
|
const categoryName = categories.find((c) => c.id === categoryId)?.name ?? null;
|
|
|
|
|
setBulkActing(true);
|
|
|
|
|
try {
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
targets.map((tx) =>
|
|
|
|
|
api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false }),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
let applied = 0;
|
|
|
|
|
results.forEach((r, i) => {
|
|
|
|
|
if (r.status === 'fulfilled') {
|
|
|
|
|
updateTransaction(targets[i]!.id, {
|
|
|
|
|
spending_category_id: categoryId as number | null,
|
|
|
|
|
spending_category_name: categoryName,
|
|
|
|
|
suggested_match: null,
|
|
|
|
|
});
|
|
|
|
|
applied++;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
toast.success(`Categorized ${applied} of ${targets.length}`);
|
|
|
|
|
if (applied < targets.length) loadLedger();
|
|
|
|
|
} finally {
|
|
|
|
|
setBulkActing(false);
|
|
|
|
|
setSelectedIds(new Set());
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[selectedTransactions, categories, updateTransaction, loadLedger],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const handleBulkIgnoreToggle = useCallback(
|
|
|
|
|
async (ignore: boolean) => {
|
|
|
|
|
const targets = selectedTransactions;
|
|
|
|
|
if (targets.length === 0) return;
|
|
|
|
|
setBulkActing(true);
|
|
|
|
|
try {
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
targets.map((tx) =>
|
|
|
|
|
ignore ? api.ignoreTransaction(tx.id) : api.unignoreTransaction(tx.id),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
let applied = 0;
|
|
|
|
|
results.forEach((r, i) => {
|
|
|
|
|
if (r.status === 'fulfilled') {
|
|
|
|
|
updateTransaction(targets[i]!.id, r.value);
|
|
|
|
|
applied++;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
toast.success(`${ignore ? 'Ignored' : 'Unignored'} ${applied} of ${targets.length}`);
|
|
|
|
|
if (applied < targets.length) loadLedger();
|
|
|
|
|
} finally {
|
|
|
|
|
setBulkActing(false);
|
|
|
|
|
setSelectedIds(new Set());
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[selectedTransactions, updateTransaction, loadLedger],
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-06-12 03:59:42 -05:00
|
|
|
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>
|
2026-07-04 23:20:47 -05:00
|
|
|
<Button type="button" variant="outline" onClick={() => loadLedger()}>
|
2026-06-12 03:59:42 -05:00
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
<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>
|
2026-06-12 03:59:42 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button asChild>
|
2026-07-03 15:02:36 -05:00
|
|
|
<Link to="/data?section=bank-sync">
|
2026-06-12 03:59:42 -05:00
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
<h1 className="text-2xl font-bold tracking-normal text-foreground sm:text-3xl">
|
|
|
|
|
Bank Transactions
|
|
|
|
|
</h1>
|
2026-06-12 03:59:42 -05:00
|
|
|
<p className="mt-1 text-sm font-medium text-muted-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
{accounts.length} monitored {accounts.length === 1 ? 'account' : 'accounts'}{' '}
|
|
|
|
|
synced through SimpleFIN
|
2026-06-12 03:59:42 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<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',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-12 03:59:42 -05:00
|
|
|
{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>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={handleAutoCategorize}
|
|
|
|
|
disabled={autoCategorizing}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
<Sparkles className={cn('h-4 w-4', autoCategorizing && 'animate-pulse')} />
|
|
|
|
|
Auto-categorize
|
|
|
|
|
</Button>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => loadLedger()}
|
|
|
|
|
disabled={isFetching}
|
|
|
|
|
>
|
2026-06-12 03:59:42 -05:00
|
|
|
<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">
|
2026-07-06 14:23:53 -05:00
|
|
|
<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`}
|
|
|
|
|
/>
|
2026-06-12 03:59:42 -05:00
|
|
|
</section>
|
|
|
|
|
|
2026-06-14 15:15:31 -05:00
|
|
|
{categoryBreakdown.length > 0 && (
|
|
|
|
|
<section className="flex gap-3 overflow-x-auto pb-1">
|
2026-07-06 14:23:53 -05:00
|
|
|
{categoryBreakdown.map((cat) => {
|
2026-06-14 15:15:31 -05:00
|
|
|
const tone = categoryColor(cat.name);
|
|
|
|
|
const pct = Math.max(4, Math.round((Number(cat.total || 0) / maxCategoryTotal) * 100));
|
|
|
|
|
const isUncategorized = cat.name === 'Uncategorized';
|
|
|
|
|
const active = isUncategorized
|
|
|
|
|
? flow === 'uncategorized'
|
|
|
|
|
: search.trim().toLowerCase() === cat.name.toLowerCase();
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={cat.name}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (isUncategorized) {
|
|
|
|
|
setSearch('');
|
2026-07-06 14:23:53 -05:00
|
|
|
setFlow((prev) => (prev === 'uncategorized' ? 'all' : 'uncategorized'));
|
2026-06-14 15:15:31 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
setSearch((prev) =>
|
|
|
|
|
prev.trim().toLowerCase() === cat.name.toLowerCase() ? '' : cat.name,
|
|
|
|
|
);
|
2026-06-14 15:15:31 -05:00
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex min-w-[160px] shrink-0 flex-col gap-1.5 rounded-lg border p-3 text-left transition-shadow',
|
2026-07-06 14:23:53 -05:00
|
|
|
tone.border,
|
|
|
|
|
tone.bg,
|
2026-06-14 15:15:31 -05:00
|
|
|
active && 'ring-2 ring-primary/50',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
<span className={cn('truncate text-sm font-semibold', tone.text)}>
|
|
|
|
|
{cat.name}
|
|
|
|
|
</span>
|
2026-06-14 15:15:31 -05:00
|
|
|
<span className="text-xs font-medium text-muted-foreground">{cat.count}</span>
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<span className="tracker-number text-sm font-bold text-foreground">
|
|
|
|
|
{formatCents(cat.total)}
|
|
|
|
|
</span>
|
2026-06-14 15:15:31 -05:00
|
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-background/60">
|
2026-07-06 14:23:53 -05:00
|
|
|
<div
|
|
|
|
|
className={cn('h-full rounded-full', tone.bar)}
|
|
|
|
|
style={{ width: `${pct}%` }}
|
|
|
|
|
/>
|
2026-06-14 15:15:31 -05:00
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-12 03:59:42 -05:00
|
|
|
{accounts.length > 0 && (
|
|
|
|
|
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
{accounts.map((account) => (
|
|
|
|
|
<div
|
|
|
|
|
key={account.id}
|
|
|
|
|
className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm"
|
|
|
|
|
>
|
2026-06-12 03:59:42 -05:00
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
<div className="min-w-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="truncate text-sm font-semibold text-foreground">
|
|
|
|
|
{accountLabel(account)}
|
|
|
|
|
</p>
|
2026-06-12 03:59:42 -05:00
|
|
|
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
{[account.account_type, account.currency].filter(Boolean).join(' - ') ||
|
|
|
|
|
'Bank account'}
|
2026-06-12 03:59:42 -05:00
|
|
|
</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>
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="tracker-number mt-1 text-lg font-bold text-foreground">
|
|
|
|
|
{formatCents(account.balance)}
|
|
|
|
|
</p>
|
2026-06-12 03:59:42 -05:00
|
|
|
</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">
|
2026-07-06 14:23:53 -05:00
|
|
|
{account.available_balance == null
|
|
|
|
|
? '—'
|
|
|
|
|
: formatCents(account.available_balance)}
|
2026-06-12 03:59:42 -05:00
|
|
|
</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>
|
2026-07-06 14:23:53 -05:00
|
|
|
<span>
|
|
|
|
|
{account.last_transaction_date
|
|
|
|
|
? fmtDate(account.last_transaction_date)
|
|
|
|
|
: 'No activity'}
|
|
|
|
|
</span>
|
2026-06-12 03:59:42 -05:00
|
|
|
</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" />
|
2026-07-06 14:23:53 -05:00
|
|
|
<Input
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
|
|
|
placeholder="Search merchant, memo, category"
|
|
|
|
|
className="pl-9"
|
|
|
|
|
/>
|
2026-06-12 03:59:42 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Select value={accountId} onValueChange={setAccountId}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="Account" />
|
|
|
|
|
</SelectTrigger>
|
2026-06-12 03:59:42 -05:00
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="all">All accounts</SelectItem>
|
2026-07-06 14:23:53 -05:00
|
|
|
{accounts.map((account) => (
|
|
|
|
|
<SelectItem key={account.id} value={String(account.id)}>
|
|
|
|
|
{accountLabel(account)}
|
|
|
|
|
</SelectItem>
|
2026-06-12 03:59:42 -05:00
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
<Select value={flow} onValueChange={setFlow}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="Type" />
|
|
|
|
|
</SelectTrigger>
|
2026-06-12 03:59:42 -05:00
|
|
|
<SelectContent>
|
2026-07-06 14:23:53 -05:00
|
|
|
{flowOptions.map((option) => (
|
|
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
2026-06-12 03:59:42 -05:00
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
<Select value={sortBy} onValueChange={setSortBy}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="Sort" />
|
|
|
|
|
</SelectTrigger>
|
2026-06-12 03:59:42 -05:00
|
|
|
<SelectContent>
|
2026-07-06 14:23:53 -05:00
|
|
|
{sortOptions.map((option) => (
|
|
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
2026-06-12 03:59:42 -05:00
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<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" />
|
|
|
|
|
)}
|
2026-06-12 03:59:42 -05:00
|
|
|
{sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-14 15:15:31 -05:00
|
|
|
{selectedIds.size > 0 && (
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 p-3 shadow-sm">
|
|
|
|
|
<span className="text-sm font-medium text-foreground">{selectedIds.size} selected</span>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={bulkActing}
|
|
|
|
|
onClick={handleBulkApplySuggestions}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
|
|
|
Apply suggestions
|
|
|
|
|
</Button>
|
2026-07-06 14:23:53 -05:00
|
|
|
<CategoryPicker
|
|
|
|
|
categories={categories}
|
|
|
|
|
current={null}
|
|
|
|
|
onSelect={(categoryId) => handleBulkCategorize(categoryId)}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={bulkActing}
|
|
|
|
|
onClick={() => handleBulkIgnoreToggle(true)}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
<EyeOff className="h-3.5 w-3.5" />
|
|
|
|
|
Ignore
|
|
|
|
|
</Button>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={bulkActing}
|
|
|
|
|
onClick={() => handleBulkIgnoreToggle(false)}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
<Eye className="h-3.5 w-3.5" />
|
|
|
|
|
Unignore
|
|
|
|
|
</Button>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="ml-auto"
|
|
|
|
|
disabled={bulkActing}
|
|
|
|
|
onClick={() => setSelectedIds(new Set())}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
Clear selection
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-12 03:59:42 -05:00
|
|
|
<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">
|
2026-06-14 15:15:31 -05:00
|
|
|
<TableHead className="w-10">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Checkbox
|
|
|
|
|
checked={allOnPageSelected}
|
|
|
|
|
onCheckedChange={toggleSelectAll}
|
|
|
|
|
aria-label="Select all transactions on this page"
|
|
|
|
|
/>
|
2026-06-14 15:15:31 -05:00
|
|
|
</TableHead>
|
2026-06-12 03:59:42 -05:00
|
|
|
<TableHead className="w-32">Date</TableHead>
|
|
|
|
|
<TableHead>Merchant</TableHead>
|
|
|
|
|
<TableHead>Account</TableHead>
|
2026-06-14 15:15:31 -05:00
|
|
|
<TableHead>Category</TableHead>
|
2026-06-12 03:59:42 -05:00
|
|
|
<TableHead>Status</TableHead>
|
|
|
|
|
<TableHead className="text-right">Amount</TableHead>
|
2026-06-14 15:15:31 -05:00
|
|
|
<TableHead className="w-12"></TableHead>
|
2026-06-12 03:59:42 -05:00
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
2026-07-06 14:23:53 -05:00
|
|
|
{loading &&
|
|
|
|
|
transactions.length === 0 &&
|
2026-06-14 15:15:31 -05:00
|
|
|
Array.from({ length: 6 }).map((_, i) => (
|
|
|
|
|
<TableRow key={`skeleton-${i}`}>
|
|
|
|
|
{Array.from({ length: TABLE_COLUMN_COUNT }).map((__, j) => (
|
2026-07-06 14:23:53 -05:00
|
|
|
<TableCell key={j}>
|
|
|
|
|
<Skeleton variant="line" />
|
|
|
|
|
</TableCell>
|
2026-06-14 15:15:31 -05:00
|
|
|
))}
|
|
|
|
|
</TableRow>
|
2026-07-06 14:23:53 -05:00
|
|
|
))}
|
2026-06-12 03:59:42 -05:00
|
|
|
{!loading && transactions.length === 0 && (
|
|
|
|
|
<TableRow>
|
2026-07-06 14:23:53 -05:00
|
|
|
<TableCell
|
|
|
|
|
colSpan={TABLE_COLUMN_COUNT}
|
|
|
|
|
className="py-10 text-center text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
No transactions found.
|
|
|
|
|
</TableCell>
|
2026-06-12 03:59:42 -05:00
|
|
|
</TableRow>
|
|
|
|
|
)}
|
2026-06-14 15:15:31 -05:00
|
|
|
{(dateGroups ?? [{ label: null, items: transactions }]).map((group, gi) => (
|
|
|
|
|
<Fragment key={group.label ?? gi}>
|
|
|
|
|
{group.label && (
|
|
|
|
|
<TableRow className="bg-muted/20 hover:bg-muted/20">
|
2026-07-06 14:23:53 -05:00
|
|
|
<TableCell
|
|
|
|
|
colSpan={TABLE_COLUMN_COUNT}
|
|
|
|
|
className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
{group.label}
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
)}
|
2026-07-06 14:23:53 -05:00
|
|
|
{group.items.map((tx) => {
|
2026-06-14 15:15:31 -05:00
|
|
|
const cents = Number(tx.amount || 0);
|
|
|
|
|
const isCredit = cents > 0;
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<TableRow
|
|
|
|
|
key={tx.id}
|
|
|
|
|
data-state={selectedIds.has(tx.id) ? 'selected' : undefined}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
<TableCell>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Checkbox
|
|
|
|
|
checked={selectedIds.has(tx.id)}
|
|
|
|
|
onCheckedChange={() => toggleSelected(tx.id)}
|
|
|
|
|
aria-label="Select transaction"
|
|
|
|
|
/>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-muted-foreground">
|
|
|
|
|
{fmtDate(transactionDate(tx))}
|
2026-06-14 15:15:31 -05:00
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
|
|
|
<MerchantAvatar tx={tx} />
|
|
|
|
|
<div className="min-w-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="truncate font-semibold text-foreground">
|
|
|
|
|
{transactionTitle(tx)}
|
|
|
|
|
</p>
|
2026-06-14 15:15:31 -05:00
|
|
|
<p className="truncate text-xs font-medium text-muted-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
{[tx.memo, tx.category].filter(Boolean).join(' - ') ||
|
|
|
|
|
tx.description ||
|
|
|
|
|
'SimpleFIN'}
|
2026-06-14 15:15:31 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="truncate text-sm font-semibold text-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
{[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'}
|
2026-06-14 15:15:31 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
2026-07-06 14:23:53 -05:00
|
|
|
<CategoryPicker
|
|
|
|
|
categories={categories}
|
|
|
|
|
current={tx.spending_category_id}
|
|
|
|
|
onSelect={(categoryId) => handleCategorize(tx, categoryId)}
|
|
|
|
|
/>
|
|
|
|
|
<SuggestionBadge
|
|
|
|
|
tx={tx}
|
|
|
|
|
onApply={handleApplySuggestion}
|
|
|
|
|
applying={applyingSuggestionId === tx.id}
|
|
|
|
|
/>
|
2026-06-14 15:15:31 -05:00
|
|
|
</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>
|
2026-07-06 14:23:53 -05:00
|
|
|
<TableCell
|
|
|
|
|
className={cn(
|
|
|
|
|
'tracker-number text-right font-bold',
|
|
|
|
|
isCredit
|
|
|
|
|
? 'text-emerald-600 dark:text-emerald-300'
|
|
|
|
|
: 'text-rose-600 dark:text-rose-300',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
{formatCents(cents, { signed: true })}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
2026-07-06 14:23:53 -05:00
|
|
|
<RowActionsMenu
|
|
|
|
|
tx={tx}
|
|
|
|
|
onMatch={setMatchTarget}
|
|
|
|
|
onUnmatch={handleUnmatch}
|
|
|
|
|
onIgnore={handleIgnore}
|
|
|
|
|
onUnignore={handleUnignore}
|
|
|
|
|
/>
|
2026-06-14 15:15:31 -05:00
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</Fragment>
|
|
|
|
|
))}
|
2026-06-12 03:59:42 -05:00
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-3 lg:hidden">
|
2026-07-06 14:23:53 -05:00
|
|
|
{loading &&
|
|
|
|
|
transactions.length === 0 &&
|
|
|
|
|
Array.from({ length: 4 }).map((_, i) => (
|
|
|
|
|
<Skeleton key={`skeleton-${i}`} variant="card" />
|
|
|
|
|
))}
|
2026-06-12 03:59:42 -05:00
|
|
|
{!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>
|
|
|
|
|
)}
|
2026-06-14 15:15:31 -05:00
|
|
|
{(dateGroups ?? [{ label: null, items: transactions }]).map((group, gi) => (
|
|
|
|
|
<div key={group.label ?? gi} className="space-y-3">
|
|
|
|
|
{group.label && (
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
{group.label}
|
|
|
|
|
</p>
|
2026-06-14 15:15:31 -05:00
|
|
|
)}
|
2026-07-06 14:23:53 -05:00
|
|
|
{group.items.map((tx) => (
|
2026-06-14 15:15:31 -05:00
|
|
|
<TransactionMobileCard
|
|
|
|
|
key={tx.id}
|
|
|
|
|
tx={tx}
|
|
|
|
|
categories={categories}
|
|
|
|
|
onCategorize={handleCategorize}
|
|
|
|
|
onApplySuggestion={handleApplySuggestion}
|
|
|
|
|
applyingSuggestionId={applyingSuggestionId}
|
|
|
|
|
onMatch={setMatchTarget}
|
|
|
|
|
onUnmatch={handleUnmatch}
|
|
|
|
|
onIgnore={handleIgnore}
|
|
|
|
|
onUnignore={handleUnignore}
|
|
|
|
|
selected={selectedIds.has(tx.id)}
|
|
|
|
|
onToggleSelected={toggleSelected}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2026-06-12 03:59:42 -05:00
|
|
|
</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">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled={page <= 1 || loading}
|
|
|
|
|
onClick={() => setPage((value) => Math.max(1, value - 1))}
|
|
|
|
|
>
|
2026-06-12 03:59:42 -05:00
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
Previous
|
|
|
|
|
</Button>
|
|
|
|
|
<span className="min-w-20 text-center text-sm font-semibold text-foreground">
|
|
|
|
|
{page} / {totalPages}
|
|
|
|
|
</span>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled={page >= totalPages || loading}
|
|
|
|
|
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
|
|
|
|
|
>
|
2026-06-12 03:59:42 -05:00
|
|
|
Next
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
|
|
|
<MatchBillDialog
|
|
|
|
|
open={!!matchTarget}
|
2026-07-06 14:23:53 -05:00
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) setMatchTarget(null);
|
|
|
|
|
}}
|
2026-06-14 15:15:31 -05:00
|
|
|
transaction={matchTarget}
|
|
|
|
|
bills={bills}
|
|
|
|
|
loading={matchSubmitting}
|
|
|
|
|
onConfirm={handleConfirmMatch}
|
|
|
|
|
onCreateBill={openCreateBill}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{createBillSourceTx && (
|
|
|
|
|
<BillModal
|
|
|
|
|
key={`create-from-tx-${createBillSourceTx.tx.id}`}
|
|
|
|
|
initialBill={createBillSourceTx.initialBill}
|
|
|
|
|
categories={categories}
|
|
|
|
|
onClose={() => setCreateBillSourceTx(null)}
|
|
|
|
|
onSave={handleBillCreated}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<Dialog
|
|
|
|
|
open={!!autoCategorizePreview}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) setAutoCategorizePreview(null);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-06-14 15:15:31 -05:00
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Auto-categorize transactions</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
2026-07-06 14:23:53 -05:00
|
|
|
{autoCategorizePreview?.changes?.length} transaction
|
|
|
|
|
{autoCategorizePreview?.changes?.length === 1 ? '' : 's'} will be categorized based on
|
|
|
|
|
the merchant/store reference list. This also saves merchant rules, so future
|
|
|
|
|
transactions from these merchants will be categorized automatically too.
|
2026-06-14 15:15:31 -05:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
{(autoCategorizePreview?.categories || []).map((cat) => {
|
2026-06-14 15:15:31 -05:00
|
|
|
const tone = categoryColor(cat.name);
|
|
|
|
|
return (
|
|
|
|
|
<Badge key={cat.name} className={cn(tone.border, tone.bg, tone.text)}>
|
|
|
|
|
{cat.name} · {cat.count}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setAutoCategorizePreview(null)}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="button" onClick={handleConfirmAutoCategorize} disabled={autoCategorizing}>
|
|
|
|
|
<Sparkles className={cn('h-4 w-4', autoCategorizing && 'animate-pulse')} />
|
|
|
|
|
Apply
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2026-06-12 03:59:42 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|