Skip to content

Commit 1b072b6

Browse files
authored
chore: improve some functionalities (#132)
1 parent 2b5955d commit 1b072b6

File tree

4 files changed

+484
-137
lines changed

4 files changed

+484
-137
lines changed

frontend/components/custom/AccountAnalytics/AccountAnalytics.tsx

Lines changed: 274 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { AddAccountModal } from "@/components/custom/Modal/Accounts/AddAccountModal";
22
import { useAccounts } from "@/components/hooks/useAccounts";
3+
import { useTransactions } from "@/components/hooks/useTransactions";
34
import { Button } from "@/components/ui/button";
45
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6+
import {
7+
DropdownMenu,
8+
DropdownMenuCheckboxItem,
9+
DropdownMenuContent,
10+
DropdownMenuSeparator,
11+
DropdownMenuTrigger,
12+
} from "@/components/ui/dropdown-menu";
513
import {
614
Table,
715
TableBody,
@@ -11,14 +19,20 @@ import {
1119
TableRow,
1220
} from "@/components/ui/table";
1321
import type { AccountAnalyticsListResponse } from "@/lib/models/analytics";
14-
import { formatCurrency } from "@/lib/utils";
22+
import { formatCurrency, getTransactionColor } from "@/lib/utils";
23+
import { format } from "date-fns";
1524
import { ChevronRight, Plus, Wallet } from "lucide-react";
16-
import { useState } from "react";
25+
import { useTheme } from "next-themes";
26+
import { Fragment, useEffect, useState } from "react";
1727

1828
interface AccountAnalyticsProps {
1929
data?: AccountAnalyticsListResponse["account_analytics"];
2030
}
2131

32+
interface AccountTransactionsProps {
33+
accountId: number;
34+
}
35+
2236
// Color palette for different accounts
2337
const accountColors = [
2438
"bg-purple-500",
@@ -33,12 +47,104 @@ const accountColors = [
3347
"bg-red-500",
3448
];
3549

50+
function AccountTransactions({ accountId }: AccountTransactionsProps) {
51+
const { theme } = useTheme();
52+
const { data, isLoading, error } = useTransactions({
53+
account_id: accountId,
54+
page: 1,
55+
page_size: 5,
56+
sort_by: "date",
57+
sort_order: "desc",
58+
});
59+
60+
if (isLoading) {
61+
return (
62+
<TableRow>
63+
<TableCell colSpan={4} className="bg-muted/40">
64+
<div className="px-4 py-3 text-sm text-muted-foreground">
65+
Loading latest transactions...
66+
</div>
67+
</TableCell>
68+
</TableRow>
69+
);
70+
}
71+
72+
if (error) {
73+
return (
74+
<TableRow>
75+
<TableCell colSpan={4} className="bg-muted/40">
76+
<div className="px-4 py-3 text-sm text-destructive">
77+
Failed to load transactions.
78+
</div>
79+
</TableCell>
80+
</TableRow>
81+
);
82+
}
83+
84+
const transactions = data?.transactions ?? [];
85+
86+
if (transactions.length === 0) {
87+
return (
88+
<TableRow>
89+
<TableCell colSpan={4} className="bg-muted/40">
90+
<div className="px-4 py-3 text-sm text-muted-foreground">
91+
No recent transactions for this account.
92+
</div>
93+
</TableCell>
94+
</TableRow>
95+
);
96+
}
97+
98+
return (
99+
<TableRow>
100+
<TableCell colSpan={4} className="bg-muted/40">
101+
<div className="px-4 py-3">
102+
<div className="text-xs uppercase tracking-wide text-muted-foreground">
103+
Latest 5 transactions
104+
</div>
105+
<div className="mt-3 space-y-2">
106+
{transactions.map((transaction) => (
107+
<div
108+
key={transaction.id}
109+
className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-background px-3 py-2"
110+
>
111+
<div className="min-w-0">
112+
<div className="text-sm font-medium text-foreground">
113+
{transaction.name}
114+
</div>
115+
<div className="text-xs text-muted-foreground">
116+
{format(new Date(transaction.date), "MMM d, yyyy")}
117+
</div>
118+
</div>
119+
<div
120+
className={`text-sm font-semibold ${getTransactionColor(
121+
transaction.amount,
122+
theme
123+
)}`}
124+
>
125+
{formatCurrency(Math.abs(transaction.amount))}
126+
</div>
127+
</div>
128+
))}
129+
</div>
130+
</div>
131+
</TableCell>
132+
</TableRow>
133+
);
134+
}
135+
36136
export function AccountAnalytics({ data }: AccountAnalyticsProps) {
37137
const [expandedAccounts, setExpandedAccounts] = useState<Set<number>>(
38138
new Set()
39139
);
40140
const [isAddAccountModalOpen, setIsAddAccountModalOpen] = useState(false);
41141
const { data: accountsData } = useAccounts();
142+
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
143+
const [draftSelectedIds, setDraftSelectedIds] = useState<number[]>([]);
144+
145+
useEffect(() => {
146+
setDraftSelectedIds(selectedAccountIds);
147+
}, [selectedAccountIds]);
42148

43149
if (!data || data.length === 0) {
44150
return (
@@ -85,8 +191,59 @@ export function AccountAnalytics({ data }: AccountAnalyticsProps) {
85191
);
86192
}
87193

194+
const hasAccountList = !!accountsData && accountsData.length > 0;
195+
const showFilter = hasAccountList;
196+
const allAccountIds = hasAccountList
197+
? accountsData.map((account) => account.id)
198+
: [];
199+
const hasAllSelectedApplied =
200+
selectedAccountIds.length > 0 &&
201+
allAccountIds.every((accountId) => selectedAccountIds.includes(accountId));
202+
const hasAllSelectedDraft =
203+
draftSelectedIds.length > 0 &&
204+
allAccountIds.every((accountId) => draftSelectedIds.includes(accountId));
205+
const selectedAccountCount = selectedAccountIds.length;
206+
const triggerLabel =
207+
selectedAccountCount === 0 || hasAllSelectedApplied
208+
? "All accounts"
209+
: `${selectedAccountCount} selected`;
210+
const isDirty =
211+
selectedAccountIds.length !== draftSelectedIds.length ||
212+
selectedAccountIds.some(
213+
(accountId) => !draftSelectedIds.includes(accountId)
214+
);
215+
216+
const toggleAccountSelection = (accountId: number, checked: boolean) => {
217+
if (checked) {
218+
if (draftSelectedIds.includes(accountId)) {
219+
return;
220+
}
221+
setDraftSelectedIds([...draftSelectedIds, accountId]);
222+
return;
223+
}
224+
225+
setDraftSelectedIds(draftSelectedIds.filter((id) => id !== accountId));
226+
};
227+
228+
const toggleSelectAll = () => {
229+
if (hasAllSelectedDraft) {
230+
setDraftSelectedIds([]);
231+
return;
232+
}
233+
234+
setDraftSelectedIds(allAccountIds);
235+
};
236+
237+
const applyAccountFilter = () => {
238+
setSelectedAccountIds(draftSelectedIds);
239+
};
240+
241+
const filteredData = selectedAccountIds.length
242+
? data.filter((account) => selectedAccountIds.includes(account.account_id))
243+
: data;
244+
88245
// Calculate percentages and prepare data with account names and initial balances
89-
const accountsWithBalances = data.map((account, index) => {
246+
const accountsWithBalances = filteredData.map((account, index) => {
90247
// Find the account info from accounts data
91248
const accountInfo = accountsData?.find(
92249
(acc) => acc.id === account.account_id
@@ -137,22 +294,70 @@ export function AccountAnalytics({ data }: AccountAnalyticsProps) {
137294
<>
138295
<Card>
139296
<CardHeader>
140-
<CardTitle className="flex items-center justify-between">
297+
<CardTitle className="flex items-center justify-between gap-3">
141298
<div className="flex items-center gap-2">
142299
<span>Accounts</span>
143300
<span className="text-muted-foreground"></span>
144301
<span>{formatCurrency(totalBalance)}</span>
145302
</div>
146-
<Button
147-
variant="ghost"
148-
size="sm"
149-
onClick={() => setIsAddAccountModalOpen(true)}
150-
className="h-8 w-8 p-0"
151-
>
152-
<Plus className="h-4 w-4" />
153-
</Button>
303+
<div className="flex items-center gap-2">
304+
{showFilter && (
305+
<DropdownMenu>
306+
<DropdownMenuTrigger asChild>
307+
<Button variant="outline" size="sm" className="h-8">
308+
{triggerLabel}
309+
</Button>
310+
</DropdownMenuTrigger>
311+
<DropdownMenuContent align="end" className="w-56">
312+
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-muted-foreground">
313+
<span>Accounts</span>
314+
<Button
315+
variant="ghost"
316+
size="sm"
317+
className="h-6 px-2"
318+
onClick={toggleSelectAll}
319+
>
320+
{hasAllSelectedDraft ? "Deselect all" : "Select all"}
321+
</Button>
322+
</div>
323+
<DropdownMenuSeparator />
324+
{accountsData?.map((account) => (
325+
<DropdownMenuCheckboxItem
326+
key={account.id}
327+
checked={draftSelectedIds.includes(account.id)}
328+
onCheckedChange={(checked) =>
329+
toggleAccountSelection(account.id, Boolean(checked))
330+
}
331+
onSelect={(event) => event.preventDefault()}
332+
>
333+
{account.name}
334+
</DropdownMenuCheckboxItem>
335+
))}
336+
<DropdownMenuSeparator />
337+
<div className="flex justify-end px-2 py-2">
338+
<Button
339+
size="sm"
340+
onClick={applyAccountFilter}
341+
disabled={!isDirty}
342+
>
343+
Apply
344+
</Button>
345+
</div>
346+
</DropdownMenuContent>
347+
</DropdownMenu>
348+
)}
349+
<Button
350+
variant="ghost"
351+
size="sm"
352+
onClick={() => setIsAddAccountModalOpen(true)}
353+
className="h-8 w-8 p-0"
354+
>
355+
<Plus className="h-4 w-4" />
356+
</Button>
357+
</div>
154358
</CardTitle>
155359
</CardHeader>
360+
156361
<CardContent className="space-y-6">
157362
{/* Horizontal Progress Bar */}
158363
<div className="space-y-4">
@@ -203,56 +408,65 @@ export function AccountAnalytics({ data }: AccountAnalyticsProps) {
203408
</TableRow>
204409
</TableHeader>
205410
<TableBody>
206-
{accountsWithPercentages.map((account) => (
207-
<TableRow key={account.account_id} className="border-b">
208-
<TableCell className="w-12">
209-
<button
210-
onClick={() =>
211-
toggleAccountExpansion(account.account_id)
212-
}
213-
className="p-1 hover:bg-muted rounded transition-colors"
214-
>
215-
<ChevronRight
216-
className={`h-4 w-4 transition-transform ${
217-
expandedAccounts.has(account.account_id)
218-
? "rotate-90"
219-
: ""
220-
}`}
221-
/>
222-
</button>
223-
</TableCell>
224-
<TableCell>
225-
<span className="font-medium">{account.accountName}</span>
226-
</TableCell>
227-
<TableCell>
228-
<div className="flex items-center gap-2">
229-
<div className="w-16 h-2 flex">
230-
{Array.from({ length: 5 }).map((_, i) => (
231-
<div
232-
key={i}
233-
className={`flex-1 h-full ${
234-
i < Math.floor(account.percentage / 20)
235-
? account.color
236-
: "bg-gray-200"
411+
{accountsWithPercentages.map((account) => {
412+
const isExpanded = expandedAccounts.has(account.account_id);
413+
414+
return (
415+
<Fragment key={account.account_id}>
416+
<TableRow className="border-b">
417+
<TableCell className="w-12">
418+
<button
419+
onClick={() =>
420+
toggleAccountExpansion(account.account_id)
421+
}
422+
className="p-1 hover:bg-muted rounded transition-colors"
423+
>
424+
<ChevronRight
425+
className={`h-4 w-4 transition-transform ${
426+
isExpanded ? "rotate-90" : ""
237427
}`}
238-
style={{
239-
marginRight: i < 4 ? "1px" : "0",
240-
}}
241428
/>
242-
))}
243-
</div>
244-
<span className="text-sm">
245-
{account.percentage.toFixed(2)}%
246-
</span>
247-
</div>
248-
</TableCell>
249-
<TableCell className="text-right">
250-
<span className="font-medium">
251-
{formatCurrency(account.absoluteBalance)}
252-
</span>
253-
</TableCell>
254-
</TableRow>
255-
))}
429+
</button>
430+
</TableCell>
431+
<TableCell>
432+
<span className="font-medium">
433+
{account.accountName}
434+
</span>
435+
</TableCell>
436+
<TableCell>
437+
<div className="flex items-center gap-2">
438+
<div className="w-16 h-2 flex">
439+
{Array.from({ length: 5 }).map((_, i) => (
440+
<div
441+
key={i}
442+
className={`flex-1 h-full ${
443+
i < Math.floor(account.percentage / 20)
444+
? account.color
445+
: "bg-gray-200"
446+
}`}
447+
style={{
448+
marginRight: i < 4 ? "1px" : "0",
449+
}}
450+
/>
451+
))}
452+
</div>
453+
<span className="text-sm">
454+
{account.percentage.toFixed(2)}%
455+
</span>
456+
</div>
457+
</TableCell>
458+
<TableCell className="text-right">
459+
<span className="font-medium">
460+
{formatCurrency(account.absoluteBalance)}
461+
</span>
462+
</TableCell>
463+
</TableRow>
464+
{isExpanded && (
465+
<AccountTransactions accountId={account.account_id} />
466+
)}
467+
</Fragment>
468+
);
469+
})}
256470
</TableBody>
257471
</Table>
258472
</div>

0 commit comments

Comments
 (0)