Skip to content

Commit 7dcb61e

Browse files
committed
✨ [feat][frontend] LinkTransactionsTable component
1 parent ee7ed92 commit 7dcb61e

4 files changed

Lines changed: 320 additions & 0 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@tailwindcss/vite": "^4.3.0",
4343
"@tanstack/react-query": "^5.100.10",
4444
"@tanstack/react-router": "^1.170.4",
45+
"@tanstack/react-table": "^8.21.3",
4546
"axios": "^1.16.1",
4647
"class-variance-authority": "^0.7.1",
4748
"clsx": "^2.1.1",

frontend/pnpm-lock.yaml

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import {
3+
type ColumnDef,
4+
type ColumnFiltersState,
5+
type Table as TanstackTable,
6+
flexRender,
7+
getCoreRowModel,
8+
getFilteredRowModel,
9+
useReactTable
10+
} from '@tanstack/react-table'
11+
import { ListFilter } from 'lucide-react'
12+
import { useMemo, useState } from 'react'
13+
14+
import { AccountSelect } from '@/components/account-select'
15+
import { Amount } from '@/components/amount'
16+
import { TransactionStatusBadge } from '@/components/transaction-status-badge'
17+
import { Button } from '@/components/ui/button'
18+
import { Checkbox } from '@/components/ui/checkbox'
19+
import {
20+
DropdownMenu,
21+
DropdownMenuContent,
22+
DropdownMenuRadioGroup,
23+
DropdownMenuRadioItem,
24+
DropdownMenuTrigger
25+
} from '@/components/ui/dropdown-menu'
26+
import {
27+
Table,
28+
TableBody,
29+
TableCell,
30+
TableHead,
31+
TableHeader,
32+
TableRow
33+
} from '@/components/ui/table'
34+
import {
35+
type AccountRead,
36+
type TransactionRead,
37+
type TransactionStatus,
38+
readTransactions
39+
} from '@/lib/client'
40+
import type { Client } from '@/lib/client/client'
41+
import { cn, formatDateTime } from '@/lib/utils'
42+
43+
type StatusFilter = TransactionStatus | 'ALL'
44+
45+
export interface SelectedTransaction {
46+
transaction: TransactionRead
47+
account: AccountRead
48+
}
49+
50+
const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
51+
{ value: 'ALL', label: 'All' },
52+
{ value: 'PENDING', label: 'Pending' },
53+
{ value: 'POSTED', label: 'Posted' },
54+
{ value: 'CLEARED', label: 'Cleared' }
55+
]
56+
57+
// Stable reference for the empty state. A fresh `[]` per render makes
58+
// react-table treat `data` as changed every render and loops forever.
59+
const EMPTY_TRANSACTIONS: TransactionRead[] = []
60+
61+
// The active filter is read from the controlled `columnFilters` React state
62+
// (via meta), not from `column.getFilterValue()`, which lags a render behind
63+
// when read inside a flexRender'd header.
64+
interface TransactionsTableMeta {
65+
statusFilter: StatusFilter
66+
setStatusFilter: (value: StatusFilter) => void
67+
selectedIds: Set<number>
68+
toggleTransaction: (transaction: TransactionRead) => void
69+
}
70+
71+
interface StatusFilterHeaderProps {
72+
table: TanstackTable<TransactionRead>
73+
}
74+
75+
interface LinkTransactionsTableProps {
76+
client: Client
77+
selection: SelectedTransaction[]
78+
onSelectionChange: (selection: SelectedTransaction[]) => void
79+
}
80+
81+
function StatusFilterHeader({ table }: StatusFilterHeaderProps) {
82+
const { statusFilter, setStatusFilter } = table.options
83+
.meta as TransactionsTableMeta
84+
85+
return (
86+
<div className="flex items-center gap-1">
87+
Status
88+
<DropdownMenu>
89+
<DropdownMenuTrigger asChild>
90+
<Button variant="ghost" size="icon-sm" className="-my-2 size-6">
91+
<ListFilter
92+
className={cn(
93+
'size-3.5',
94+
statusFilter !== 'ALL' && 'text-primary'
95+
)}
96+
/>
97+
<span className="sr-only">Filter status</span>
98+
</Button>
99+
</DropdownMenuTrigger>
100+
<DropdownMenuContent align="start">
101+
<DropdownMenuRadioGroup
102+
value={statusFilter}
103+
onValueChange={(value) => setStatusFilter(value as StatusFilter)}
104+
>
105+
{STATUS_FILTERS.map(({ value, label }) => (
106+
<DropdownMenuRadioItem key={value} value={value}>
107+
{label}
108+
</DropdownMenuRadioItem>
109+
))}
110+
</DropdownMenuRadioGroup>
111+
</DropdownMenuContent>
112+
</DropdownMenu>
113+
</div>
114+
)
115+
}
116+
117+
export function LinkTransactionsTable({
118+
client,
119+
selection,
120+
onSelectionChange
121+
}: LinkTransactionsTableProps) {
122+
const [selectedAccount, setSelectedAccount] = useState<AccountRead | null>(
123+
null
124+
)
125+
126+
const currencyCode = selectedAccount?.currency_code
127+
128+
const columns = useMemo<ColumnDef<TransactionRead>[]>(
129+
() => [
130+
{
131+
id: 'select',
132+
header: () => null,
133+
cell: ({ row, table }) => {
134+
const meta = table.options.meta as TransactionsTableMeta
135+
return (
136+
<Checkbox
137+
checked={meta.selectedIds.has(row.original.id)}
138+
onCheckedChange={() => meta.toggleTransaction(row.original)}
139+
onClick={(e) => e.stopPropagation()}
140+
aria-label="Select transaction"
141+
/>
142+
)
143+
}
144+
},
145+
{
146+
accessorKey: 'created_at',
147+
header: 'Created',
148+
cell: ({ row }) => formatDateTime(row.original.created_at)
149+
},
150+
{
151+
accessorKey: 'amount',
152+
header: 'Amount',
153+
cell: ({ row }) => (
154+
<Amount
155+
amount={parseFloat(row.original.amount)}
156+
currencyCode={currencyCode}
157+
/>
158+
)
159+
},
160+
{
161+
accessorKey: 'status',
162+
header: ({ table }) => <StatusFilterHeader table={table} />,
163+
filterFn: 'equals',
164+
cell: ({ row }) => (
165+
<TransactionStatusBadge status={row.original.status} />
166+
)
167+
}
168+
],
169+
[currencyCode]
170+
)
171+
172+
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
173+
174+
const statusFilter =
175+
(columnFilters.find((filter) => filter.id === 'status')?.value as
176+
| TransactionStatus
177+
| undefined) ?? 'ALL'
178+
179+
const setStatusFilter = (value: StatusFilter) =>
180+
setColumnFilters((prev) => {
181+
const rest = prev.filter((filter) => filter.id !== 'status')
182+
return value === 'ALL' ? rest : [...rest, { id: 'status', value }]
183+
})
184+
185+
const selectedIds = useMemo(
186+
() => new Set(selection.map((item) => item.transaction.id)),
187+
[selection]
188+
)
189+
190+
const toggleTransaction = (transaction: TransactionRead) => {
191+
if (!selectedAccount) return
192+
onSelectionChange(
193+
selectedIds.has(transaction.id)
194+
? selection.filter((item) => item.transaction.id !== transaction.id)
195+
: [...selection, { transaction, account: selectedAccount }]
196+
)
197+
}
198+
199+
const { data } = useQuery({
200+
queryKey: ['transactions', selectedAccount?.id, 'unlinked'],
201+
enabled: selectedAccount !== null,
202+
queryFn: async () => {
203+
const response = await readTransactions({
204+
client,
205+
query: { account_id: selectedAccount?.id, event_id: 'empty' }
206+
})
207+
if (response.error) throw new Error('Failed to fetch transactions')
208+
if (!response.data) throw new Error('No data returned')
209+
return response.data
210+
}
211+
})
212+
213+
const table = useReactTable({
214+
data: data ?? EMPTY_TRANSACTIONS,
215+
columns,
216+
getCoreRowModel: getCoreRowModel(),
217+
getFilteredRowModel: getFilteredRowModel(),
218+
onColumnFiltersChange: setColumnFilters,
219+
state: { columnFilters },
220+
meta: {
221+
statusFilter,
222+
setStatusFilter,
223+
selectedIds,
224+
toggleTransaction
225+
} satisfies TransactionsTableMeta
226+
})
227+
228+
return (
229+
<div className="space-y-3">
230+
<AccountSelect
231+
client={client}
232+
value={selectedAccount}
233+
onValueChange={setSelectedAccount}
234+
/>
235+
236+
<div className="min-h-66 rounded-md border">
237+
<Table>
238+
<TableHeader>
239+
{table.getHeaderGroups().map((headerGroup) => (
240+
<TableRow key={headerGroup.id}>
241+
{headerGroup.headers.map((header) => (
242+
<TableHead key={header.id}>
243+
{header.isPlaceholder
244+
? null
245+
: flexRender(
246+
header.column.columnDef.header,
247+
header.getContext()
248+
)}
249+
</TableHead>
250+
))}
251+
</TableRow>
252+
))}
253+
</TableHeader>
254+
<TableBody>
255+
{table.getRowModel().rows.length ? (
256+
table.getRowModel().rows.map((row) => (
257+
<TableRow
258+
key={row.id}
259+
data-state={
260+
selectedIds.has(row.original.id) ? 'selected' : undefined
261+
}
262+
onClick={() => toggleTransaction(row.original)}
263+
className="cursor-pointer"
264+
>
265+
{row.getVisibleCells().map((cell) => (
266+
<TableCell key={cell.id}>
267+
{flexRender(
268+
cell.column.columnDef.cell,
269+
cell.getContext()
270+
)}
271+
</TableCell>
272+
))}
273+
</TableRow>
274+
))
275+
) : (
276+
<TableRow>
277+
<TableCell
278+
colSpan={columns.length}
279+
className="h-66 text-center text-muted-foreground"
280+
>
281+
No transactions.
282+
</TableCell>
283+
</TableRow>
284+
)}
285+
</TableBody>
286+
</Table>
287+
</div>
288+
</div>
289+
)
290+
}

frontend/src/lib/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export function endExclusive(date: Date | undefined): string | null {
2323
return next.toISOString()
2424
}
2525

26+
export function formatDateTime(value: string | Date): string {
27+
return new Date(value).toLocaleString(browserLocale, {
28+
dateStyle: 'medium',
29+
timeStyle: 'short'
30+
})
31+
}
32+
2633
export function toLocalDateTimeInputValue(date: Date) {
2734
const offsetMs = date.getTimezoneOffset() * 60_000
2835
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 19)

0 commit comments

Comments
 (0)