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-04 23:20:47 -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-04 23:20:47 -05:00
Select , SelectContent , SelectItem , SelectTrigger , SelectValue ,
2026-06-12 03:59:42 -05:00
} from '@/components/ui/select' ;
import {
2026-07-04 23:20:47 -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-04 23:20:47 -05:00
DropdownMenu , DropdownMenuContent , DropdownMenuItem , DropdownMenuSeparator , DropdownMenuTrigger ,
2026-06-14 15:15:31 -05:00
} from '@/components/ui/dropdown-menu' ;
import {
2026-07-04 23:20:47 -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 {
Bill , Category , BankTransaction ,
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-04 23:20:47 -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-04 23:20:47 -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-04 23:20:47 -05:00
interface DateGroup { date? : string | null ; label : string | null ; items : Tx [ ] }
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' ) {
return < Badge variant = "outline" className = "border-muted-foreground/30 text-muted-foreground" > Ignored < / Badge > ;
}
if ( tx . match_status === 'matched' ) {
return (
< Badge className = "border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200" >
< CheckCircle2 className = "mr-1 h-3 w-3" / >
Matched
< / Badge >
) ;
}
return < Badge className = "border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200" > Review < / Badge > ;
}
2026-07-04 23:20:47 -05:00
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 > = {
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 >
< span className = { cn ( 'flex h-9 w-9 items-center justify-center rounded-full border' , tones [ tone ] ) } >
< Icon className = "h-4 w-4" / >
< / span >
< / div >
< p className = "tracker-number mt-3 text-2xl font-bold text-foreground" > { value } < / p >
{ detail && < p className = "mt-1 text-xs font-medium text-muted-foreground" > { detail } < / p > }
< / div >
) ;
}
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 (
< span className = { cn ( 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold' , tone . bg , tone . text ) } >
{ initial }
< / span >
) ;
}
2026-07-04 23:20:47 -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" >
< Badge variant = "outline" className = "max-w-[160px] truncate border-dashed text-[10px] text-muted-foreground" >
{ 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-04 23:20:47 -05:00
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 ;
} ) {
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-04 23:20:47 -05:00
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 ;
} ) {
2026-06-12 03:59:42 -05:00
const cents = Number ( tx . amount || 0 ) ;
const isCredit = cents > 0 ;
return (
2026-06-14 15:15:31 -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" >
< Checkbox className = "mt-1" checked = { selected } onCheckedChange = { ( ) = > onToggleSelected ( tx . id ) } aria - label = "Select transaction" / >
< 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 >
< p className = { cn ( 'tracker-number shrink-0 text-sm font-bold' , isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300' ) } >
{ formatCents ( cents , { signed : true } ) }
< / p >
< / div >
< div className = "mt-3 flex flex-wrap items-center gap-2" >
< Badge variant = "outline" className = "text-muted-foreground" > { fmtDate ( transactionDate ( tx ) ) } < / Badge >
< StatusBadge tx = { tx } / >
{ tx . matched_bill_name && (
< Badge className = "border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200" >
< Link2 className = "mr-1 h-3 w-3" / >
{ tx . matched_bill_name }
< / Badge >
) }
< / div >
2026-06-14 15:15:31 -05:00
< div className = "mt-3 flex items-center justify-between gap-2" >
< div >
< CategoryPicker categories = { categories } current = { tx . spending_category_id } onSelect = { categoryId = > onCategorize ( tx , categoryId ) } / >
< SuggestionBadge tx = { tx } onApply = { onApplySuggestion } applying = { applyingSuggestionId === tx . id } / >
< / div >
< RowActionsMenu tx = { tx } onMatch = { onMatch } onUnmatch = { onUnmatch } onIgnore = { onIgnore } onUnignore = { onUnignore } / >
< / 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 ) ;
const [ createBillSourceTx , setCreateBillSourceTx ] = useState < { tx : Tx ; initialBill : Partial < Bill > } | null > ( null ) ;
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.
const { data : ledgerRaw = null , isPending : loading , isFetching , error : ledgerErr , refetch : loadLedger } =
2026-07-03 20:25:47 -05:00
useBankLedger ( { accountId , flow , page , query , sortBy , sortDir , pageSize : PAGE_SIZE } ) ;
2026-07-05 12:02:28 -05:00
const ledger = ledgerRaw ;
2026-07-03 20:25:47 -05:00
const error = ledgerErr ? ( ledgerErr . message || 'Unable to load bank transactions' ) : '' ;
2026-07-04 23:20:47 -05:00
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-04 23:20:47 -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-04 23:20:47 -05:00
const updateTransaction = useCallback ( ( id : number , patch : Partial < Tx > ) = > {
2026-06-14 15:15:31 -05:00
setLedger ( prev = > {
if ( ! prev ) return prev ;
return {
. . . prev ,
2026-07-04 23:20:47 -05:00
transactions : ( prev . transactions || [ ] ) . map ( tx = > ( tx . id === id ? { . . . tx , . . . patch } : tx ) ) ,
2026-06-14 15:15:31 -05:00
} ;
} ) ;
2026-07-03 20:25:47 -05:00
} , [ setLedger ] ) ;
2026-06-14 15:15:31 -05:00
2026-07-04 23:20:47 -05:00
const handleCategorize = useCallback ( async ( tx : Tx , categoryId : number | string | null ) = > {
2026-06-14 15:15:31 -05:00
try {
await api . categorizeTransaction ( tx . id , { category_id : categoryId , save_rule : false } ) ;
const categoryName = categories . find ( c = > c . id === categoryId ) ? . name ? ? null ;
2026-07-04 23:20:47 -05:00
updateTransaction ( tx . id , { spending_category_id : categoryId as number | null , spending_category_name : categoryName , suggested_match : null } ) ;
2026-06-14 15:15:31 -05:00
} catch ( err ) {
2026-07-04 23:20:47 -05:00
toast . error ( errMessage ( err , 'Failed to categorize transaction' ) ) ;
2026-06-14 15:15:31 -05:00
}
} , [ categories , updateTransaction ] ) ;
2026-07-04 23:20:47 -05:00
const handleApplySuggestion = useCallback ( async ( tx : Tx ) = > {
2026-06-14 15:15:31 -05:00
setApplyingSuggestionId ( tx . id ) ;
try {
2026-07-05 12:02:28 -05:00
const result = await api . applyTransactionMerchantMatch ( tx . id ) ;
2026-07-04 23:20:47 -05:00
if ( result . matched && result . category ) {
2026-06-14 15:15:31 -05:00
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 ) {
2026-07-04 23:20:47 -05:00
toast . error ( errMessage ( err , 'Failed to apply suggestion' ) ) ;
2026-06-14 15:15:31 -05:00
} finally {
setApplyingSuggestionId ( null ) ;
}
} , [ updateTransaction ] ) ;
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-04 23:20:47 -05:00
const handleUndoAutoCategorize = useCallback ( async ( changes : AutoCatChange [ ] ) = > {
2026-06-14 15:15:31 -05:00
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 ) {
2026-07-04 23:20:47 -05:00
toast . error ( errMessage ( err , 'Failed to undo auto-categorize' ) ) ;
2026-06-14 15:15:31 -05:00
} finally {
loadLedger ( ) ;
}
} , [ loadLedger ] ) ;
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-04 23:20:47 -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-04 23:20:47 -05:00
const handleConfirmMatch = useCallback ( async ( billId : number | string ) = > {
2026-06-14 15:15:31 -05:00
if ( ! matchTarget ) return ;
setMatchSubmitting ( true ) ;
try {
2026-07-05 12:02:28 -05:00
const result = await api . matchTransaction ( matchTarget . id , billId ) ;
2026-06-14 15:15:31 -05:00
updateTransaction ( matchTarget . id , result . transaction ) ;
setMatchTarget ( null ) ;
toast . success ( 'Transaction matched to bill' ) ;
} catch ( err ) {
2026-07-04 23:20:47 -05:00
toast . error ( errMessage ( err , 'Failed to match transaction' ) ) ;
2026-06-14 15:15:31 -05:00
} finally {
setMatchSubmitting ( false ) ;
}
} , [ matchTarget , updateTransaction ] ) ;
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-04 23:20:47 -05:00
const handleBillCreated = useCallback ( async ( newBill? : Bill ) = > {
2026-06-14 15:15:31 -05:00
const tx = createBillSourceTx ? . tx ;
setCreateBillSourceTx ( null ) ;
setMatchTarget ( null ) ;
if ( tx && newBill ? . id ) {
try {
2026-07-05 12:02:28 -05:00
const result = await api . matchTransaction ( tx . id , newBill . id ) ;
2026-06-14 15:15:31 -05:00
updateTransaction ( tx . id , result . transaction ) ;
toast . success ( 'Bill created and matched to transaction' ) ;
} catch ( err ) {
2026-07-04 23:20:47 -05:00
toast . error ( errMessage ( err , 'Bill created but match failed' ) ) ;
2026-06-14 15:15:31 -05:00
}
}
2026-07-04 23:20:47 -05:00
api . bills ( ) . then ( data = > setBills ( ( data as Bill [ ] ) || [ ] ) ) . catch ( ( ) = > { } ) ;
2026-06-14 15:15:31 -05:00
} , [ createBillSourceTx , updateTransaction ] ) ;
2026-07-04 23:20:47 -05:00
const handleUnmatch = useCallback ( async ( tx : Tx ) = > {
2026-06-14 15:15:31 -05:00
try {
2026-07-05 12:02:28 -05:00
const result = await api . unmatchTransaction ( tx . id ) ;
2026-06-14 15:15:31 -05:00
updateTransaction ( tx . id , result . transaction ) ;
toast . success ( 'Transaction unmatched' ) ;
} catch ( err ) {
2026-07-04 23:20:47 -05:00
toast . error ( errMessage ( err , 'Failed to unmatch transaction' ) ) ;
2026-06-14 15:15:31 -05:00
loadLedger ( ) ;
}
} , [ updateTransaction , loadLedger ] ) ;
2026-07-04 23:20:47 -05:00
const handleIgnore = useCallback ( async ( tx : Tx ) = > {
2026-06-14 15:15:31 -05:00
try {
2026-07-05 12:02:28 -05:00
const transaction = await api . ignoreTransaction ( tx . id ) ;
2026-06-14 15:15:31 -05:00
updateTransaction ( tx . id , transaction ) ;
} catch ( err ) {
2026-07-04 23:20:47 -05:00
toast . error ( errMessage ( err , 'Failed to ignore transaction' ) ) ;
2026-06-14 15:15:31 -05:00
loadLedger ( ) ;
}
} , [ updateTransaction , loadLedger ] ) ;
2026-07-04 23:20:47 -05:00
const handleUnignore = useCallback ( async ( tx : Tx ) = > {
2026-06-14 15:15:31 -05:00
try {
2026-07-05 12:02:28 -05:00
const transaction = await api . unignoreTransaction ( tx . id ) ;
2026-06-14 15:15:31 -05:00
updateTransaction ( tx . id , transaction ) ;
} catch ( err ) {
2026-07-04 23:20:47 -05:00
toast . error ( errMessage ( err , 'Failed to unignore transaction' ) ) ;
2026-06-14 15:15:31 -05:00
loadLedger ( ) ;
}
} , [ updateTransaction , loadLedger ] ) ;
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 || [ ] ;
const maxCategoryTotal = categoryBreakdown . reduce ( ( max , c ) = > Math . max ( max , Number ( c . total || 0 ) ) , 0 ) || 1 ;
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 ( ( ) = > {
const times = ( ledger ? . sources || [ ] ) . map ( source = > source . last_sync_at ) . filter ( Boolean ) . sort ( ) ;
return times [ times . length - 1 ] || null ;
} , [ ledger ? . sources ] ) ;
const connected = Boolean ( ledger ? . enabled && ledger ? . has_connections ) ;
2026-06-14 15:15:31 -05:00
const selectedTransactions = useMemo (
( ) = > transactions . filter ( tx = > selectedIds . has ( tx . id ) ) ,
[ transactions , selectedIds ] ,
) ;
const allOnPageSelected = transactions . length > 0 && transactions . every ( tx = > selectedIds . has ( tx . id ) ) ;
2026-07-04 23:20:47 -05:00
const toggleSelected = useCallback ( ( id : number ) = > {
2026-06-14 15:15:31 -05:00
setSelectedIds ( prev = > {
const next = new Set ( prev ) ;
if ( next . has ( id ) ) next . delete ( id ) ; else next . add ( id ) ;
return next ;
} ) ;
} , [ ] ) ;
const toggleSelectAll = useCallback ( ( ) = > {
setSelectedIds ( prev = > {
if ( transactions . length > 0 && transactions . every ( tx = > prev . has ( tx . id ) ) ) return new Set ( ) ;
return new Set ( transactions . map ( tx = > tx . id ) ) ;
} ) ;
} , [ transactions ] ) ;
const handleBulkApplySuggestions = useCallback ( async ( ) = > {
const targets = selectedTransactions . filter ( tx = > tx . suggested_match && ! tx . spending_category_id ) ;
if ( targets . length === 0 ) return ;
setBulkActing ( true ) ;
try {
const results = await Promise . allSettled ( targets . map ( tx = > api . applyTransactionMerchantMatch ( tx . id ) ) ) ;
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-04 23:20:47 -05:00
const handleBulkCategorize = useCallback ( async ( categoryId : number | string | null ) = > {
2026-06-14 15:15:31 -05:00
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' ) {
2026-07-04 23:20:47 -05:00
updateTransaction ( targets [ i ] ! . id , { spending_category_id : categoryId as number | null , spending_category_name : categoryName , suggested_match : null } ) ;
2026-06-14 15:15:31 -05:00
applied ++ ;
}
} ) ;
toast . success ( ` Categorized ${ applied } of ${ targets . length } ` ) ;
if ( applied < targets . length ) loadLedger ( ) ;
} finally {
setBulkActing ( false ) ;
setSelectedIds ( new Set ( ) ) ;
}
} , [ selectedTransactions , categories , updateTransaction , loadLedger ] ) ;
2026-07-04 23:20:47 -05:00
const handleBulkIgnoreToggle = useCallback ( async ( ignore : boolean ) = > {
2026-06-14 15:15:31 -05:00
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' ) {
2026-07-05 12:02:28 -05:00
updateTransaction ( targets [ i ] ! . id , r . value ) ;
2026-06-14 15:15:31 -05:00
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-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 >
< h1 className = "text-2xl font-bold tracking-normal text-foreground" > Bank Transactions < / h1 >
< p className = "text-sm font-medium text-muted-foreground" > SimpleFIN bridge required < / p >
< / div >
< / div >
< / div >
< Button asChild >
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 >
< h1 className = "text-2xl font-bold tracking-normal text-foreground sm:text-3xl" > Bank Transactions < / h1 >
< p className = "mt-1 text-sm font-medium text-muted-foreground" >
{ accounts . length } monitored { accounts . length === 1 ? 'account' : 'accounts' } synced through SimpleFIN
< / p >
< / div >
< Badge className = { cn (
connected
? 'border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200'
: 'border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200' ,
) } >
{ connected ? 'Connected' : 'Loading' }
< / Badge >
< / div >
< / div >
< div className = "flex flex-col gap-2 sm:flex-row sm:items-center" >
< div className = "rounded-lg border border-border/70 bg-card/75 px-3 py-2 text-sm shadow-sm" >
< span className = "font-medium text-muted-foreground" > Last sync < / span >
< span className = "font-semibold text-foreground" > { formatSyncTime ( latestSync ) } < / span >
< / div >
2026-06-14 15:15:31 -05:00
< Button type = "button" variant = "outline" onClick = { handleAutoCategorize } disabled = { autoCategorizing } >
< Sparkles className = { cn ( 'h-4 w-4' , autoCategorizing && 'animate-pulse' ) } / >
Auto - categorize
< / Button >
2026-07-04 23:20:47 -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-04 23:20:47 -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" >
{ categoryBreakdown . map ( cat = > {
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 ( '' ) ;
setFlow ( prev = > ( prev === 'uncategorized' ? 'all' : 'uncategorized' ) ) ;
return ;
}
setSearch ( prev = > ( prev . trim ( ) . toLowerCase ( ) === cat . name . toLowerCase ( ) ? '' : cat . name ) ) ;
} }
className = { cn (
'flex min-w-[160px] shrink-0 flex-col gap-1.5 rounded-lg border p-3 text-left transition-shadow' ,
tone . border , tone . bg ,
active && 'ring-2 ring-primary/50' ,
) }
>
< div className = "flex items-center justify-between gap-2" >
< span className = { cn ( 'truncate text-sm font-semibold' , tone . text ) } > { cat . name } < / span >
< span className = "text-xs font-medium text-muted-foreground" > { cat . count } < / span >
< / div >
< span className = "tracker-number text-sm font-bold text-foreground" > { formatCents ( cat . total ) } < / span >
< div className = "h-1.5 w-full overflow-hidden rounded-full bg-background/60" >
< div className = { cn ( 'h-full rounded-full' , tone . bar ) } style = { { width : ` ${ pct } % ` } } / >
< / 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" >
{ accounts . map ( account = > (
< div key = { account . id } className = "rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm" >
< div className = "flex items-start justify-between gap-3" >
< div className = "min-w-0" >
< p className = "truncate text-sm font-semibold text-foreground" > { accountLabel ( account ) } < / p >
< p className = "mt-1 truncate text-xs font-medium text-muted-foreground" >
{ [ account . account_type , account . currency ] . filter ( Boolean ) . join ( ' - ' ) || 'Bank account' }
< / p >
< / div >
< span className = "flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-indigo-300/40 bg-indigo-400/10 text-indigo-700 dark:text-indigo-200" >
< Building2 className = "h-4 w-4" / >
< / span >
< / div >
< div className = "mt-4 grid grid-cols-2 gap-3" >
< div >
< p className = "text-xs font-medium text-muted-foreground" > Balance < / p >
< p className = "tracker-number mt-1 text-lg font-bold text-foreground" > { formatCents ( account . balance ) } < / p >
< / div >
< div >
< p className = "text-xs font-medium text-muted-foreground" > Available < / p >
< p className = "tracker-number mt-1 text-lg font-bold text-emerald-600 dark:text-emerald-300" >
{ account . available_balance == null ? '—' : formatCents ( account . available_balance ) }
< / p >
< / div >
< / div >
< div className = "mt-3 flex items-center justify-between text-xs font-medium text-muted-foreground" >
< span > { Number ( account . transaction_count || 0 ) } transactions < / span >
< span > { account . last_transaction_date ? fmtDate ( account . last_transaction_date ) : 'No activity' } < / span >
< / div >
< / div >
) ) }
< / section >
) }
< section className = "space-y-4" >
< div className = "grid gap-3 rounded-lg border border-border/70 bg-card/85 p-3 shadow-sm lg:grid-cols-[minmax(18rem,1fr)_12rem_12rem_10rem_auto]" >
< div className = "relative" >
< Search className = "pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" / >
2026-07-04 23:20:47 -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-04 23:20:47 -05:00
< SelectTrigger > < SelectValue placeholder = "Account" / > < / SelectTrigger >
2026-06-12 03:59:42 -05:00
< SelectContent >
< SelectItem value = "all" > All accounts < / SelectItem >
{ accounts . map ( account = > (
< SelectItem key = { account . id } value = { String ( account . id ) } > { accountLabel ( account ) } < / SelectItem >
) ) }
< / SelectContent >
< / Select >
< Select value = { flow } onValueChange = { setFlow } >
2026-07-04 23:20:47 -05:00
< SelectTrigger > < SelectValue placeholder = "Type" / > < / SelectTrigger >
2026-06-12 03:59:42 -05:00
< SelectContent >
{ flowOptions . map ( option = > (
< SelectItem key = { option . value } value = { option . value } > { option . label } < / SelectItem >
) ) }
< / SelectContent >
< / Select >
< Select value = { sortBy } onValueChange = { setSortBy } >
2026-07-04 23:20:47 -05:00
< SelectTrigger > < SelectValue placeholder = "Sort" / > < / SelectTrigger >
2026-06-12 03:59:42 -05:00
< SelectContent >
{ sortOptions . map ( option = > (
< SelectItem key = { option . value } value = { option . value } > { option . label } < / SelectItem >
) ) }
< / SelectContent >
< / Select >
2026-07-04 23:20:47 -05:00
< Button type = "button" variant = "outline" onClick = { ( ) = > setSortDir ( value = > ( value === 'asc' ? 'desc' : 'asc' ) ) } title = { ` Sort ${ sortDir === 'asc' ? 'ascending' : 'descending' } ` } >
2026-06-12 03:59:42 -05:00
{ sortDir === 'asc' ? < ArrowUp className = "h-4 w-4" / > : < ArrowDown className = "h-4 w-4" / > }
{ 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 >
< Button type = "button" variant = "outline" size = "sm" disabled = { bulkActing } onClick = { handleBulkApplySuggestions } >
< Sparkles className = "h-3.5 w-3.5" / >
Apply suggestions
< / Button >
< CategoryPicker categories = { categories } current = { null } onSelect = { categoryId = > handleBulkCategorize ( categoryId ) } / >
< Button type = "button" variant = "outline" size = "sm" disabled = { bulkActing } onClick = { ( ) = > handleBulkIgnoreToggle ( true ) } >
< EyeOff className = "h-3.5 w-3.5" / >
Ignore
< / Button >
< Button type = "button" variant = "outline" size = "sm" disabled = { bulkActing } onClick = { ( ) = > handleBulkIgnoreToggle ( false ) } >
< Eye className = "h-3.5 w-3.5" / >
Unignore
< / Button >
< Button type = "button" variant = "ghost" size = "sm" className = "ml-auto" disabled = { bulkActing } onClick = { ( ) = > setSelectedIds ( new Set ( ) ) } >
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" >
< Checkbox checked = { allOnPageSelected } onCheckedChange = { toggleSelectAll } aria - label = "Select all transactions on this page" / >
< / 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 >
{ 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 ) = > (
< TableCell key = { j } > < Skeleton variant = "line" / > < / TableCell >
) ) }
< / TableRow >
) )
2026-06-12 03:59:42 -05:00
) }
{ ! loading && transactions . length === 0 && (
< TableRow >
2026-06-14 15:15:31 -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" >
< TableCell colSpan = { TABLE_COLUMN_COUNT } className = "py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground" >
{ group . label }
< / TableCell >
< / TableRow >
) }
{ group . items . map ( tx = > {
const cents = Number ( tx . amount || 0 ) ;
const isCredit = cents > 0 ;
return (
< TableRow key = { tx . id } data - state = { selectedIds . has ( tx . id ) ? 'selected' : undefined } >
< TableCell >
< Checkbox checked = { selectedIds . has ( tx . id ) } onCheckedChange = { ( ) = > toggleSelected ( tx . id ) } aria - label = "Select transaction" / >
< / TableCell >
< TableCell className = "text-muted-foreground" > { fmtDate ( transactionDate ( tx ) ) } < / TableCell >
< TableCell >
< div className = "flex min-w-0 items-center gap-2.5" >
< MerchantAvatar tx = { tx } / >
< div className = "min-w-0" >
< p className = "truncate font-semibold text-foreground" > { transactionTitle ( tx ) } < / p >
< p className = "truncate text-xs font-medium text-muted-foreground" >
{ [ tx . memo , tx . category ] . filter ( Boolean ) . join ( ' - ' ) || tx . description || 'SimpleFIN' }
< / p >
< / div >
< / div >
< / TableCell >
< TableCell >
< div className = "min-w-0" >
< p className = "truncate text-sm font-semibold text-foreground" >
{ [ tx . account_org_name , tx . account_name ] . filter ( Boolean ) . join ( ' - ' ) || 'Account' }
< / p >
< p className = "truncate text-xs font-medium text-muted-foreground" > { tx . account_type || tx . currency || 'Bank' } < / p >
< / div >
< / TableCell >
< TableCell >
< CategoryPicker categories = { categories } current = { tx . spending_category_id } onSelect = { categoryId = > handleCategorize ( tx , categoryId ) } / >
< SuggestionBadge tx = { tx } onApply = { handleApplySuggestion } applying = { applyingSuggestionId === tx . id } / >
< / TableCell >
< TableCell >
< div className = "flex flex-wrap gap-2" >
< StatusBadge tx = { tx } / >
{ tx . matched_bill_name && (
< Badge className = "border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200" >
< Link2 className = "mr-1 h-3 w-3" / >
{ tx . matched_bill_name }
< / Badge >
) }
< / div >
< / TableCell >
< TableCell className = { cn ( 'tracker-number text-right font-bold' , isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300' ) } >
{ formatCents ( cents , { signed : true } ) }
< / TableCell >
< TableCell >
< RowActionsMenu tx = { tx } onMatch = { setMatchTarget } onUnmatch = { handleUnmatch } onIgnore = { handleIgnore } onUnignore = { handleUnignore } / >
< / TableCell >
< / TableRow >
) ;
} ) }
< / Fragment >
) ) }
2026-06-12 03:59:42 -05:00
< / TableBody >
< / Table >
< / div >
< div className = "grid gap-3 lg:hidden" >
{ loading && transactions . length === 0 && (
2026-06-14 15:15:31 -05:00
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 && (
< p className = "px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground" > { group . label } < / p >
) }
{ group . items . map ( tx = > (
< 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-04 23:20:47 -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-04 23:20:47 -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 }
onOpenChange = { open = > { if ( ! open ) setMatchTarget ( null ) ; } }
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 }
/ >
) }
< Dialog open = { ! ! autoCategorizePreview } onOpenChange = { open = > { if ( ! open ) setAutoCategorizePreview ( null ) ; } } >
< DialogContent >
< DialogHeader >
< DialogTitle > Auto - categorize transactions < / DialogTitle >
< DialogDescription >
{ 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 .
< / DialogDescription >
< / DialogHeader >
< div className = "flex flex-wrap gap-2" >
{ ( autoCategorizePreview ? . categories || [ ] ) . map ( cat = > {
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 >
) ;
}