refactor(ts): BankTransactionsPage → TypeScript
Cents-based bank ledger typed end-to-end: local Tx interface (amount: Cents, index sig for BankTransaction compat), Ledger/Account/Summary types, React Query setQueryData<Ledger> for optimistic row patches, cast-at-boundary for api.* results, Promise.allSettled bulk handlers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
18eaec04e2
commit
d49a2ae4dc
|
|
@ -1,30 +1,12 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState, type ComponentType } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useBankLedger } from '@/hooks/useQueries';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
ArrowUp,
|
||||
Building2,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Landmark,
|
||||
Link2,
|
||||
Link2Off,
|
||||
MoreVertical,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
WalletCards,
|
||||
ArrowDown, ArrowDownUp, ArrowUp, Building2, Check, CheckCircle2, ChevronLeft, ChevronRight,
|
||||
Clock3, Eye, EyeOff, Landmark, Link2, Link2Off, MoreVertical, RefreshCw, Search, Sparkles,
|
||||
TrendingDown, TrendingUp, WalletCards,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -32,44 +14,97 @@ import { Button } from '@/components/ui/button';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} 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';
|
||||
import { cn, fmt, fmtDate, categoryColor, localDateString } from '@/lib/utils';
|
||||
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
|
||||
import { formatUSD, asDollars, type Cents } from '@/lib/money';
|
||||
import type { Bill, Category, BankTransaction } from '@/types';
|
||||
|
||||
function fmt(v: number | null | undefined): string {
|
||||
return formatUSD(asDollars(Number(v) || 0));
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const TABLE_COLUMN_COUNT = 8;
|
||||
|
||||
interface Tx {
|
||||
id: number;
|
||||
amount: Cents;
|
||||
payee?: string | null;
|
||||
description?: string | null;
|
||||
memo?: string | null;
|
||||
category?: string | null;
|
||||
posted_date?: string | null;
|
||||
transacted_at?: string | null;
|
||||
pending?: boolean;
|
||||
ignored?: boolean;
|
||||
match_status?: string | null;
|
||||
matched_bill_name?: string | null;
|
||||
spending_category_id?: number | null;
|
||||
spending_category_name?: string | null;
|
||||
suggested_match?: { display_name?: string; category?: string } | null;
|
||||
account_org_name?: string | null;
|
||||
account_name?: string | null;
|
||||
account_type?: string | null;
|
||||
currency?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: number | string;
|
||||
org_name?: string;
|
||||
name?: string;
|
||||
account_type?: string;
|
||||
currency?: string;
|
||||
balance?: number;
|
||||
available_balance?: number | null;
|
||||
transaction_count?: number;
|
||||
last_transaction_date?: string;
|
||||
}
|
||||
|
||||
interface CategoryBreakdown { name: string; total?: number; count?: number }
|
||||
|
||||
interface LedgerSummary {
|
||||
inflow?: number;
|
||||
outflow?: number;
|
||||
net?: number;
|
||||
matched?: number;
|
||||
unmatched?: number;
|
||||
pending?: number;
|
||||
total?: number;
|
||||
latest_date?: string;
|
||||
category_breakdown?: CategoryBreakdown[];
|
||||
}
|
||||
|
||||
interface Ledger {
|
||||
accounts?: Account[];
|
||||
transactions?: Tx[];
|
||||
summary?: LedgerSummary;
|
||||
total?: number;
|
||||
sources?: { last_sync_at?: string | null }[];
|
||||
enabled?: boolean;
|
||||
has_connections?: boolean;
|
||||
}
|
||||
|
||||
interface AutoCatChange { transaction_id: number }
|
||||
interface AutoCatPreview {
|
||||
changes?: AutoCatChange[];
|
||||
categories?: { name: string; count?: number }[];
|
||||
}
|
||||
|
||||
const flowOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'in', label: 'Money in' },
|
||||
|
|
@ -89,35 +124,32 @@ const sortOptions = [
|
|||
{ value: 'status', label: 'Status' },
|
||||
];
|
||||
|
||||
function formatCents(value, { signed = false } = {}) {
|
||||
function formatCents(value: number | null | undefined, { signed = false }: { signed?: boolean } = {}): string {
|
||||
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) {
|
||||
function formatSyncTime(value: unknown): string {
|
||||
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',
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function transactionTitle(tx) {
|
||||
function transactionTitle(tx: Tx): string {
|
||||
return tx.payee || tx.description || tx.memo || 'Transaction';
|
||||
}
|
||||
|
||||
function transactionDate(tx) {
|
||||
function transactionDate(tx: Tx): string | null {
|
||||
return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null);
|
||||
}
|
||||
|
||||
function dateGroupLabel(dateStr) {
|
||||
function dateGroupLabel(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Unknown date';
|
||||
const today = localDateString();
|
||||
const yesterdayDate = new Date();
|
||||
|
|
@ -128,12 +160,12 @@ function dateGroupLabel(dateStr) {
|
|||
return fmtDate(dateStr);
|
||||
}
|
||||
|
||||
// Groups already-sorted transactions into date sections for display.
|
||||
// Returns null when the list isn't sorted by date (grouping wouldn't make sense).
|
||||
function groupByDate(transactions, sortBy) {
|
||||
interface DateGroup { date?: string | null; label: string | null; items: Tx[] }
|
||||
|
||||
function groupByDate(transactions: Tx[], sortBy: string): DateGroup[] | null {
|
||||
if (sortBy !== 'date') return null;
|
||||
const groups = [];
|
||||
let current = null;
|
||||
const groups: DateGroup[] = [];
|
||||
let current: DateGroup | null = null;
|
||||
for (const tx of transactions) {
|
||||
const date = transactionDate(tx);
|
||||
if (!current || current.date !== date) {
|
||||
|
|
@ -145,12 +177,12 @@ function groupByDate(transactions, sortBy) {
|
|||
return groups;
|
||||
}
|
||||
|
||||
function accountLabel(account) {
|
||||
function accountLabel(account?: Account): string {
|
||||
if (!account) return 'Unknown account';
|
||||
return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account';
|
||||
}
|
||||
|
||||
function StatusBadge({ tx }) {
|
||||
function StatusBadge({ tx }: { tx: Tx }) {
|
||||
if (tx.pending) {
|
||||
return (
|
||||
<Badge className="border-amber-300/50 bg-amber-400/15 text-amber-700 dark:text-amber-200">
|
||||
|
|
@ -173,8 +205,14 @@ function StatusBadge({ tx }) {
|
|||
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 = {
|
||||
function SummaryTile({ icon: Icon, label, value, tone, detail }: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
tone: string;
|
||||
detail?: React.ReactNode;
|
||||
}) {
|
||||
const tones: Record<string, string> = {
|
||||
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',
|
||||
|
|
@ -195,9 +233,7 @@ function SummaryTile({ icon: Icon, label, value, tone, detail }) {
|
|||
);
|
||||
}
|
||||
|
||||
// Small circular initial badge, colored by the transaction's category — gives
|
||||
// the table/cards a quick visual anchor for scanning by category.
|
||||
function MerchantAvatar({ tx }) {
|
||||
function MerchantAvatar({ tx }: { tx: Tx }) {
|
||||
const tone = categoryColor(tx.spending_category_name || 'Uncategorized');
|
||||
const initial = transactionTitle(tx).trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
|
|
@ -207,7 +243,7 @@ function MerchantAvatar({ tx }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SuggestionBadge({ tx, onApply, applying }) {
|
||||
function SuggestionBadge({ tx, onApply, applying }: { tx: Tx; onApply: (tx: Tx) => void; applying?: boolean }) {
|
||||
if (!tx.suggested_match) return null;
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
|
|
@ -227,7 +263,13 @@ function SuggestionBadge({ tx, onApply, applying }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }) {
|
||||
function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }: {
|
||||
tx: Tx;
|
||||
onMatch: (tx: Tx) => void;
|
||||
onUnmatch: (tx: Tx) => void;
|
||||
onIgnore: (tx: Tx) => void;
|
||||
onUnignore: (tx: Tx) => void;
|
||||
}) {
|
||||
const isMatched = tx.match_status === 'matched';
|
||||
const isIgnored = tx.ignored || tx.match_status === 'ignored';
|
||||
|
||||
|
|
@ -266,7 +308,19 @@ function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TransactionMobileCard({ tx, categories, onCategorize, onApplySuggestion, applyingSuggestionId, onMatch, onUnmatch, onIgnore, onUnignore, selected, onToggleSelected }) {
|
||||
function TransactionMobileCard({ tx, categories, onCategorize, onApplySuggestion, applyingSuggestionId, onMatch, onUnmatch, onIgnore, onUnignore, selected, onToggleSelected }: {
|
||||
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;
|
||||
}) {
|
||||
const cents = Number(tx.amount || 0);
|
||||
const isCredit = cents > 0;
|
||||
|
||||
|
|
@ -317,24 +371,23 @@ export default function BankTransactionsPage() {
|
|||
const [sortBy, setSortBy] = useState('date');
|
||||
const [sortDir, setSortDir] = useState('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [bills, setBills] = useState([]);
|
||||
const [matchTarget, setMatchTarget] = useState(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [bills, setBills] = useState<Bill[]>([]);
|
||||
const [matchTarget, setMatchTarget] = useState<Tx | null>(null);
|
||||
const [matchSubmitting, setMatchSubmitting] = useState(false);
|
||||
const [applyingSuggestionId, setApplyingSuggestionId] = useState(null);
|
||||
const [applyingSuggestionId, setApplyingSuggestionId] = useState<number | null>(null);
|
||||
const [autoCategorizing, setAutoCategorizing] = useState(false);
|
||||
const [autoCategorizePreview, setAutoCategorizePreview] = useState(null);
|
||||
const [createBillSourceTx, setCreateBillSourceTx] = useState(null);
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||
const [autoCategorizePreview, setAutoCategorizePreview] = useState<AutoCatPreview | null>(null);
|
||||
const [createBillSourceTx, setCreateBillSourceTx] = useState<{ tx: Tx; initialBill: Partial<Bill> } | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
|
||||
const [bulkActing, setBulkActing] = useState(false);
|
||||
// React Query keys the ledger on the filters/page, so it handles caching,
|
||||
// dedup, cancellation, and out-of-order responses (no manual request id).
|
||||
const { data: ledger = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
|
||||
// React Query keys the ledger on the filters/page.
|
||||
const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
|
||||
useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
|
||||
const ledger = ledgerRaw as Ledger | null;
|
||||
const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : '';
|
||||
const setLedger = useCallback((u) => queryClient.setQueryData(
|
||||
['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
|
||||
prev => (typeof u === 'function' ? u(prev) : u)), [queryClient, accountId, flow, page, query, sortBy, sortDir]);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
|
|
@ -353,36 +406,35 @@ export default function BankTransactionsPage() {
|
|||
}, [accountId, flow, sortBy, sortDir, query, page]);
|
||||
|
||||
useEffect(() => {
|
||||
api.categories().then(data => setCategories(data || [])).catch(() => setCategories([]));
|
||||
api.bills().then(data => setBills(data || [])).catch(() => setBills([]));
|
||||
api.categories().then(data => setCategories((data as Category[]) || [])).catch(() => setCategories([]));
|
||||
api.bills().then(data => setBills((data as Bill[]) || [])).catch(() => setBills([]));
|
||||
}, []);
|
||||
|
||||
|
||||
const updateTransaction = useCallback((id, patch) => {
|
||||
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)),
|
||||
transactions: (prev.transactions || []).map(tx => (tx.id === id ? { ...tx, ...patch } : tx)),
|
||||
};
|
||||
});
|
||||
}, [setLedger]);
|
||||
|
||||
const handleCategorize = useCallback(async (tx, categoryId) => {
|
||||
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;
|
||||
updateTransaction(tx.id, { spending_category_id: categoryId, spending_category_name: categoryName, suggested_match: null });
|
||||
updateTransaction(tx.id, { spending_category_id: categoryId as number | null, spending_category_name: categoryName, suggested_match: null });
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to categorize transaction');
|
||||
toast.error(errMessage(err, 'Failed to categorize transaction'));
|
||||
}
|
||||
}, [categories, updateTransaction]);
|
||||
|
||||
const handleApplySuggestion = useCallback(async (tx) => {
|
||||
const handleApplySuggestion = useCallback(async (tx: Tx) => {
|
||||
setApplyingSuggestionId(tx.id);
|
||||
try {
|
||||
const result = await api.applyTransactionMerchantMatch(tx.id);
|
||||
if (result.matched) {
|
||||
const result = await api.applyTransactionMerchantMatch(tx.id) as { matched?: boolean; category?: { id: number; name: string } };
|
||||
if (result.matched && result.category) {
|
||||
updateTransaction(tx.id, {
|
||||
spending_category_id: result.category.id,
|
||||
spending_category_name: result.category.name,
|
||||
|
|
@ -391,7 +443,7 @@ export default function BankTransactionsPage() {
|
|||
toast.success(`Categorized as ${result.category.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to apply suggestion');
|
||||
toast.error(errMessage(err, 'Failed to apply suggestion'));
|
||||
} finally {
|
||||
setApplyingSuggestionId(null);
|
||||
}
|
||||
|
|
@ -400,25 +452,25 @@ export default function BankTransactionsPage() {
|
|||
const handleAutoCategorize = useCallback(async () => {
|
||||
setAutoCategorizing(true);
|
||||
try {
|
||||
const preview = await api.autoCategorizeTransactions({ dry_run: true });
|
||||
const preview = await api.autoCategorizeTransactions({ dry_run: true }) as AutoCatPreview;
|
||||
if (!preview?.changes?.length) {
|
||||
toast.success('No new matches found');
|
||||
return;
|
||||
}
|
||||
setAutoCategorizePreview(preview);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to preview auto-categorize');
|
||||
toast.error(errMessage(err, 'Failed to preview auto-categorize'));
|
||||
} finally {
|
||||
setAutoCategorizing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUndoAutoCategorize = useCallback(async (changes) => {
|
||||
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(err.message || 'Failed to undo auto-categorize');
|
||||
toast.error(errMessage(err, 'Failed to undo auto-categorize'));
|
||||
} finally {
|
||||
loadLedger();
|
||||
}
|
||||
|
|
@ -427,40 +479,42 @@ export default function BankTransactionsPage() {
|
|||
const handleConfirmAutoCategorize = useCallback(async () => {
|
||||
setAutoCategorizing(true);
|
||||
try {
|
||||
const result = await api.autoCategorizeTransactions();
|
||||
const result = await api.autoCategorizeTransactions() as { changes?: AutoCatChange[] };
|
||||
const changes = result?.changes || [];
|
||||
setAutoCategorizePreview(null);
|
||||
await Promise.all([
|
||||
loadLedger(),
|
||||
api.categories().then(data => setCategories(data || [])).catch(() => {}),
|
||||
api.categories().then(data => setCategories((data as Category[]) || [])).catch(() => {}),
|
||||
]);
|
||||
const count = changes.length;
|
||||
toast.success(`Categorized ${count} transaction${count === 1 ? '' : 's'}`, {
|
||||
action: { label: 'Undo', onClick: () => handleUndoAutoCategorize(changes) },
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to auto-categorize transactions');
|
||||
toast.error(errMessage(err, 'Failed to auto-categorize transactions'));
|
||||
} finally {
|
||||
setAutoCategorizing(false);
|
||||
}
|
||||
}, [loadLedger, handleUndoAutoCategorize]);
|
||||
|
||||
const handleConfirmMatch = useCallback(async (billId) => {
|
||||
const handleConfirmMatch = useCallback(async (billId: number | string) => {
|
||||
if (!matchTarget) return;
|
||||
setMatchSubmitting(true);
|
||||
try {
|
||||
const result = await api.matchTransaction(matchTarget.id, billId);
|
||||
const result = await api.matchTransaction(matchTarget.id, billId) as { transaction: Tx };
|
||||
updateTransaction(matchTarget.id, result.transaction);
|
||||
setMatchTarget(null);
|
||||
toast.success('Transaction matched to bill');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to match transaction');
|
||||
toast.error(errMessage(err, 'Failed to match transaction'));
|
||||
} finally {
|
||||
setMatchSubmitting(false);
|
||||
}
|
||||
}, [matchTarget, updateTransaction]);
|
||||
|
||||
const openCreateBill = useCallback((tx, nameOverride) => {
|
||||
const openCreateBill = useCallback((txArg: BankTransaction | null, nameOverride?: string) => {
|
||||
const tx = txArg as Tx | null;
|
||||
if (!tx) return;
|
||||
const amount = Math.abs(Number(tx.amount || 0)) / 100;
|
||||
const dateStr = transactionDate(tx);
|
||||
const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1;
|
||||
|
|
@ -468,7 +522,7 @@ export default function BankTransactionsPage() {
|
|||
tx,
|
||||
initialBill: {
|
||||
name: nameOverride || transactionTitle(tx),
|
||||
expected_amount: amount || 0,
|
||||
expected_amount: asDollars(amount || 0),
|
||||
due_day: day >= 1 && day <= 31 ? day : 1,
|
||||
billing_cycle: 'monthly',
|
||||
cycle_type: 'monthly',
|
||||
|
|
@ -478,49 +532,49 @@ export default function BankTransactionsPage() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const handleBillCreated = useCallback(async (newBill) => {
|
||||
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);
|
||||
const result = await api.matchTransaction(tx.id, newBill.id) as { transaction: Tx };
|
||||
updateTransaction(tx.id, result.transaction);
|
||||
toast.success('Bill created and matched to transaction');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Bill created but match failed');
|
||||
toast.error(errMessage(err, 'Bill created but match failed'));
|
||||
}
|
||||
}
|
||||
api.bills().then(data => setBills(data || [])).catch(() => {});
|
||||
api.bills().then(data => setBills((data as Bill[]) || [])).catch(() => {});
|
||||
}, [createBillSourceTx, updateTransaction]);
|
||||
|
||||
const handleUnmatch = useCallback(async (tx) => {
|
||||
const handleUnmatch = useCallback(async (tx: Tx) => {
|
||||
try {
|
||||
const result = await api.unmatchTransaction(tx.id);
|
||||
const result = await api.unmatchTransaction(tx.id) as { transaction: Tx };
|
||||
updateTransaction(tx.id, result.transaction);
|
||||
toast.success('Transaction unmatched');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to unmatch transaction');
|
||||
toast.error(errMessage(err, 'Failed to unmatch transaction'));
|
||||
loadLedger();
|
||||
}
|
||||
}, [updateTransaction, loadLedger]);
|
||||
|
||||
const handleIgnore = useCallback(async (tx) => {
|
||||
const handleIgnore = useCallback(async (tx: Tx) => {
|
||||
try {
|
||||
const transaction = await api.ignoreTransaction(tx.id);
|
||||
const transaction = await api.ignoreTransaction(tx.id) as Partial<Tx>;
|
||||
updateTransaction(tx.id, transaction);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to ignore transaction');
|
||||
toast.error(errMessage(err, 'Failed to ignore transaction'));
|
||||
loadLedger();
|
||||
}
|
||||
}, [updateTransaction, loadLedger]);
|
||||
|
||||
const handleUnignore = useCallback(async (tx) => {
|
||||
const handleUnignore = useCallback(async (tx: Tx) => {
|
||||
try {
|
||||
const transaction = await api.unignoreTransaction(tx.id);
|
||||
const transaction = await api.unignoreTransaction(tx.id) as Partial<Tx>;
|
||||
updateTransaction(tx.id, transaction);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to unignore transaction');
|
||||
toast.error(errMessage(err, 'Failed to unignore transaction'));
|
||||
loadLedger();
|
||||
}
|
||||
}, [updateTransaction, loadLedger]);
|
||||
|
|
@ -534,8 +588,6 @@ export default function BankTransactionsPage() {
|
|||
const maxCategoryTotal = categoryBreakdown.reduce((max, c) => Math.max(max, Number(c.total || 0)), 0) || 1;
|
||||
const dateGroups = useMemo(() => groupByDate(transactions, sortBy), [transactions, sortBy]);
|
||||
|
||||
// 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]);
|
||||
|
|
@ -551,7 +603,7 @@ export default function BankTransactionsPage() {
|
|||
);
|
||||
const allOnPageSelected = transactions.length > 0 && transactions.every(tx => selectedIds.has(tx.id));
|
||||
|
||||
const toggleSelected = useCallback((id) => {
|
||||
const toggleSelected = useCallback((id: number) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
|
|
@ -574,10 +626,11 @@ export default function BankTransactionsPage() {
|
|||
const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id)));
|
||||
let applied = 0;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled' && r.value?.matched) {
|
||||
updateTransaction(targets[i].id, {
|
||||
spending_category_id: r.value.category.id,
|
||||
spending_category_name: r.value.category.name,
|
||||
const val = r.status === 'fulfilled' ? r.value as { matched?: boolean; category?: { id: number; name: string } } : null;
|
||||
if (val?.matched && val.category) {
|
||||
updateTransaction(targets[i]!.id, {
|
||||
spending_category_id: val.category.id,
|
||||
spending_category_name: val.category.name,
|
||||
suggested_match: null,
|
||||
});
|
||||
applied++;
|
||||
|
|
@ -591,7 +644,7 @@ export default function BankTransactionsPage() {
|
|||
}
|
||||
}, [selectedTransactions, updateTransaction, loadLedger]);
|
||||
|
||||
const handleBulkCategorize = useCallback(async (categoryId) => {
|
||||
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;
|
||||
|
|
@ -603,7 +656,7 @@ export default function BankTransactionsPage() {
|
|||
let applied = 0;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
updateTransaction(targets[i].id, { spending_category_id: categoryId, spending_category_name: categoryName, suggested_match: null });
|
||||
updateTransaction(targets[i]!.id, { spending_category_id: categoryId as number | null, spending_category_name: categoryName, suggested_match: null });
|
||||
applied++;
|
||||
}
|
||||
});
|
||||
|
|
@ -615,7 +668,7 @@ export default function BankTransactionsPage() {
|
|||
}
|
||||
}, [selectedTransactions, categories, updateTransaction, loadLedger]);
|
||||
|
||||
const handleBulkIgnoreToggle = useCallback(async (ignore) => {
|
||||
const handleBulkIgnoreToggle = useCallback(async (ignore: boolean) => {
|
||||
const targets = selectedTransactions;
|
||||
if (targets.length === 0) return;
|
||||
setBulkActing(true);
|
||||
|
|
@ -626,7 +679,7 @@ export default function BankTransactionsPage() {
|
|||
let applied = 0;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
updateTransaction(targets[i].id, r.value);
|
||||
updateTransaction(targets[i]!.id, r.value as Partial<Tx>);
|
||||
applied++;
|
||||
}
|
||||
});
|
||||
|
|
@ -638,8 +691,6 @@ export default function BankTransactionsPage() {
|
|||
}
|
||||
}, [selectedTransactions, updateTransaction, loadLedger]);
|
||||
|
||||
// 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">
|
||||
|
|
@ -649,7 +700,7 @@ export default function BankTransactionsPage() {
|
|||
<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}>
|
||||
<Button type="button" variant="outline" onClick={() => loadLedger()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try again
|
||||
</Button>
|
||||
|
|
@ -720,7 +771,7 @@ export default function BankTransactionsPage() {
|
|||
<Sparkles className={cn('h-4 w-4', autoCategorizing && 'animate-pulse')} />
|
||||
Auto-categorize
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={loadLedger} disabled={isFetching}>
|
||||
<Button type="button" variant="outline" onClick={() => loadLedger()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
|
|
@ -735,34 +786,10 @@ export default function BankTransactionsPage() {
|
|||
)}
|
||||
|
||||
<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`}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{categoryBreakdown.length > 0 && (
|
||||
|
|
@ -846,18 +873,11 @@ export default function BankTransactionsPage() {
|
|||
<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"
|
||||
/>
|
||||
<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>
|
||||
<SelectTrigger><SelectValue placeholder="Account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All accounts</SelectItem>
|
||||
{accounts.map(account => (
|
||||
|
|
@ -867,9 +887,7 @@ export default function BankTransactionsPage() {
|
|||
</Select>
|
||||
|
||||
<Select value={flow} onValueChange={setFlow}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectTrigger><SelectValue placeholder="Type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{flowOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
|
|
@ -878,9 +896,7 @@ export default function BankTransactionsPage() {
|
|||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sort" />
|
||||
</SelectTrigger>
|
||||
<SelectTrigger><SelectValue placeholder="Sort" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortOptions.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
|
|
@ -888,12 +904,7 @@ export default function BankTransactionsPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSortDir(value => (value === 'asc' ? 'desc' : 'asc'))}
|
||||
title={`Sort ${sortDir === 'asc' ? 'ascending' : 'descending'}`}
|
||||
>
|
||||
<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>
|
||||
|
|
@ -1060,24 +1071,14 @@ export default function BankTransactionsPage() {
|
|||
: `${(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))}
|
||||
>
|
||||
<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))}
|
||||
>
|
||||
<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>
|
||||
Loading…
Reference in New Issue