Skip to content

Commit 105a5fa

Browse files
committed
✨ [feat][frontend] LinkedTransactionsField component
1 parent 7dcb61e commit 105a5fa

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"clsx": "^2.1.1",
4949
"date-fns": "^4.1.0",
5050
"lucide-react": "^1.16.0",
51+
"motion": "^12.42.0",
5152
"next-themes": "^0.4.6",
5253
"radix-ui": "^1.4.3",
5354
"react": "^19.2.6",

frontend/pnpm-lock.yaml

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { GripVertical, Link2, X } from 'lucide-react'
2+
import { Reorder, useDragControls } from 'motion/react'
3+
import { useState } from 'react'
4+
5+
import { Amount } from '@/components/amount'
6+
import {
7+
LinkTransactionsTable,
8+
type SelectedTransaction
9+
} from '@/components/link-transactions-table'
10+
import { Button } from '@/components/ui/button'
11+
import {
12+
Dialog,
13+
DialogClose,
14+
DialogContent,
15+
DialogDescription,
16+
DialogFooter,
17+
DialogHeader,
18+
DialogTitle,
19+
DialogTrigger
20+
} from '@/components/ui/dialog'
21+
import {
22+
Empty,
23+
EmptyContent,
24+
EmptyDescription,
25+
EmptyHeader,
26+
EmptyMedia,
27+
EmptyTitle
28+
} from '@/components/ui/empty'
29+
import {
30+
Item,
31+
ItemActions,
32+
ItemContent,
33+
ItemDescription,
34+
ItemTitle
35+
} from '@/components/ui/item'
36+
import type { Client } from '@/lib/client/client'
37+
import { formatDateTime } from '@/lib/utils'
38+
39+
interface LinkedTransactionsFieldProps {
40+
client: Client
41+
value: SelectedTransaction[]
42+
onChange: (value: SelectedTransaction[]) => void
43+
}
44+
45+
interface LinkedTransactionItemProps {
46+
item: SelectedTransaction
47+
onUnlink: (id: number) => void
48+
}
49+
50+
export function LinkedTransactionsField({
51+
client,
52+
value,
53+
onChange
54+
}: LinkedTransactionsFieldProps) {
55+
const [selection, setSelection] = useState<SelectedTransaction[]>([])
56+
57+
const handleLink = () => {
58+
if (selection.length === 0) return
59+
const existing = new Set(value.map((item) => item.transaction.id))
60+
const additions = selection.filter(
61+
(item) => !existing.has(item.transaction.id)
62+
)
63+
if (additions.length > 0) onChange([...value, ...additions])
64+
}
65+
66+
const unlinkTransaction = (id: number) =>
67+
onChange(value.filter((item) => item.transaction.id !== id))
68+
69+
return (
70+
<Dialog
71+
onOpenChange={(dialogOpen) => {
72+
if (!dialogOpen) setSelection([])
73+
}}
74+
>
75+
{value.length === 0 ? (
76+
<Empty className="border border-dashed py-8">
77+
<EmptyHeader>
78+
<EmptyMedia variant="icon">
79+
<Link2 />
80+
</EmptyMedia>
81+
<EmptyTitle>No transactions linked</EmptyTitle>
82+
<EmptyDescription>
83+
Link one or more transactions to this event.
84+
</EmptyDescription>
85+
</EmptyHeader>
86+
<EmptyContent>
87+
<DialogTrigger asChild>
88+
<Button type="button" variant="outline" size="sm">
89+
Link transactions
90+
</Button>
91+
</DialogTrigger>
92+
</EmptyContent>
93+
</Empty>
94+
) : (
95+
<div className="space-y-3">
96+
<Reorder.Group
97+
axis="y"
98+
values={value}
99+
onReorder={onChange}
100+
className="flex w-full flex-col gap-2.5"
101+
>
102+
{value.map((item) => (
103+
<LinkedTransactionItem
104+
key={item.transaction.id}
105+
item={item}
106+
onUnlink={unlinkTransaction}
107+
/>
108+
))}
109+
</Reorder.Group>
110+
<DialogTrigger asChild>
111+
<Button type="button" variant="outline" size="sm">
112+
Link more transactions
113+
</Button>
114+
</DialogTrigger>
115+
</div>
116+
)}
117+
118+
<DialogContent className="min-w-sm sm:min-w-sm sm:max-w-[min(calc(100%-2rem),48rem)]">
119+
<DialogHeader>
120+
<DialogTitle>Link transactions</DialogTitle>
121+
<DialogDescription>
122+
Select transactions to link to this event.
123+
</DialogDescription>
124+
</DialogHeader>
125+
<LinkTransactionsTable
126+
client={client}
127+
selection={selection}
128+
onSelectionChange={setSelection}
129+
/>
130+
<DialogFooter>
131+
<DialogClose asChild>
132+
<Button type="button" variant="outline">
133+
Cancel
134+
</Button>
135+
</DialogClose>
136+
<DialogClose asChild>
137+
<Button
138+
type="button"
139+
disabled={selection.length === 0}
140+
onClick={handleLink}
141+
>
142+
Link
143+
</Button>
144+
</DialogClose>
145+
</DialogFooter>
146+
</DialogContent>
147+
</Dialog>
148+
)
149+
}
150+
151+
function LinkedTransactionItem({ item, onUnlink }: LinkedTransactionItemProps) {
152+
const { transaction, account } = item
153+
const dragControls = useDragControls()
154+
155+
return (
156+
<Reorder.Item
157+
value={item}
158+
dragListener={false}
159+
dragControls={dragControls}
160+
className="list-none"
161+
>
162+
<Item variant="outline" size="sm" className="bg-background">
163+
<span
164+
aria-label="Drag to reorder"
165+
className="flex cursor-grab touch-none items-center text-muted-foreground active:cursor-grabbing"
166+
onPointerDown={(e) => dragControls.start(e)}
167+
>
168+
<GripVertical className="size-4" />
169+
</span>
170+
<ItemContent>
171+
<ItemTitle>{account.name}</ItemTitle>
172+
<ItemDescription>
173+
{formatDateTime(transaction.created_at)}
174+
</ItemDescription>
175+
</ItemContent>
176+
<ItemActions>
177+
<Amount
178+
amount={parseFloat(transaction.amount)}
179+
currencyCode={account.currency_code}
180+
/>
181+
<Button
182+
type="button"
183+
variant="ghost"
184+
size="icon-sm"
185+
aria-label="Unlink transaction"
186+
onClick={() => onUnlink(transaction.id)}
187+
>
188+
<X />
189+
</Button>
190+
</ItemActions>
191+
</Item>
192+
</Reorder.Item>
193+
)
194+
}

0 commit comments

Comments
 (0)