Skip to content

Commit a1382b6

Browse files
authored
feat: introduce pagination on rules (#100)
1 parent 9c371fb commit a1382b6

File tree

15 files changed

+817
-107
lines changed

15 files changed

+817
-107
lines changed

frontend/components/custom/CategoryAnalytics/CategoryAnalytics.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from "@/components/ui/table";
1212
import { CategoryAnalyticsResponse } from "@/lib/models/analytics";
1313
import { formatCurrency } from "@/lib/utils";
14-
import { ChevronRight, Plus, Tag } from "lucide-react";
14+
import { ChevronRight, FileQuestion, Plus, Tag } from "lucide-react";
1515
import { useState } from "react";
1616

1717
interface CategoryAnalyticsProps {
@@ -96,10 +96,14 @@ export function CategoryAnalytics({ data }: CategoryAnalyticsProps) {
9696
const absoluteAmount = Math.abs(category.total_amount);
9797
const percentage =
9898
totalAmount > 0 ? (absoluteAmount / totalAmount) * 100 : 0;
99+
const isUncategorized = category.category_id === -1;
99100
return {
100101
...category,
101102
percentage,
102-
color: categoryColors[index % categoryColors.length],
103+
color: isUncategorized
104+
? "bg-gray-400"
105+
: categoryColors[index % categoryColors.length],
106+
isUncategorized,
103107
};
104108
})
105109
.sort((a, b) => b.percentage - a.percentage); // Sort by percentage descending
@@ -150,7 +154,11 @@ export function CategoryAnalytics({ data }: CategoryAnalyticsProps) {
150154
key={category.category_id}
151155
className="flex items-center gap-2"
152156
>
153-
<div className={`w-3 h-3 rounded-full ${category.color}`} />
157+
{category.isUncategorized ? (
158+
<FileQuestion className="w-3 h-3 text-gray-500" />
159+
) : (
160+
<div className={`w-3 h-3 rounded-full ${category.color}`} />
161+
)}
154162
<span className="text-muted-foreground">
155163
{category.category_name}:
156164
</span>
@@ -199,9 +207,14 @@ export function CategoryAnalytics({ data }: CategoryAnalyticsProps) {
199207
</button>
200208
</TableCell>
201209
<TableCell>
202-
<span className="font-medium">
203-
{category.category_name}
204-
</span>
210+
<div className="flex items-center gap-2">
211+
{category.isUncategorized && (
212+
<FileQuestion className="w-4 h-4 text-gray-500" />
213+
)}
214+
<span className="font-medium">
215+
{category.category_name}
216+
</span>
217+
</div>
205218
</TableCell>
206219
<TableCell>
207220
<div className="flex items-center gap-2">

frontend/components/custom/Modal/Rule/ViewRulesModal.tsx

Lines changed: 145 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,18 @@ import {
1010
DialogHeader,
1111
DialogTitle,
1212
} from "@/components/ui/dialog";
13+
import { Input } from "@/components/ui/input";
14+
import {
15+
Pagination,
16+
PaginationContent,
17+
PaginationItem,
18+
PaginationLink,
19+
PaginationNext,
20+
PaginationPrevious,
21+
} from "@/components/ui/pagination";
1322
import type { Rule } from "@/lib/models/rule";
14-
import { BookOpen, Trash2 } from "lucide-react";
15-
import { useState } from "react";
23+
import { BookOpen, Search, Trash2 } from "lucide-react";
24+
import { useCallback, useEffect, useMemo, useState } from "react";
1625

1726
interface ViewRulesModalProps {
1827
isOpen: boolean;
@@ -23,14 +32,47 @@ export const ViewRulesModal = ({
2332
isOpen,
2433
onOpenChange,
2534
}: ViewRulesModalProps) => {
26-
const { data: rules = [], isLoading, refetch } = useRules();
35+
const [currentPage, setCurrentPage] = useState(1);
36+
const [searchTerm, setSearchTerm] = useState("");
37+
const [debouncedSearch, setDebouncedSearch] = useState("");
2738
const [editRuleId, setEditRuleId] = useState<number | null>(null);
2839
const [isAddRuleModalOpen, setIsAddRuleModalOpen] = useState(false);
2940
const [loadingId, setLoadingId] = useState<number | null>(null);
3041
const [confirmDeleteRule, setConfirmDeleteRule] = useState<Rule | null>(null);
3142
const [confirmLoading, setConfirmLoading] = useState(false);
3243
const deleteRuleMutation = useDeleteRule();
3344

45+
const pageSize = 5;
46+
47+
// Debounce search term
48+
useEffect(() => {
49+
const timer = setTimeout(() => {
50+
setDebouncedSearch(searchTerm);
51+
setCurrentPage(1); // Reset to first page on search
52+
}, 300);
53+
54+
return () => clearTimeout(timer);
55+
}, [searchTerm]);
56+
57+
const queryParams = useMemo(
58+
() => ({
59+
page: currentPage,
60+
page_size: pageSize,
61+
search: debouncedSearch || undefined,
62+
}),
63+
[currentPage, pageSize, debouncedSearch]
64+
);
65+
66+
const { data: response, isLoading, refetch } = useRules(queryParams);
67+
68+
const rules = response?.rules || [];
69+
const totalItems = response?.total || 0;
70+
const totalPages = Math.ceil(totalItems / pageSize);
71+
72+
const handlePageChange = useCallback((page: number) => {
73+
setCurrentPage(page);
74+
}, []);
75+
3476
const openDeleteDialog = (rule: Rule) => {
3577
setConfirmDeleteRule(rule);
3678
setConfirmLoading(false);
@@ -50,57 +92,121 @@ export const ViewRulesModal = ({
5092
return (
5193
<>
5294
<Dialog open={isOpen} onOpenChange={onOpenChange}>
53-
<DialogContent className="sm:max-w-[425px]">
95+
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
5496
<DialogHeader>
5597
<DialogTitle className="flex items-center gap-2">
5698
<BookOpen className="h-5 w-5" />
5799
View Rules
58100
</DialogTitle>
59101
</DialogHeader>
60102
<div className="grid gap-4 py-4">
103+
{/* Search Input */}
104+
<div className="relative">
105+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
106+
<Input
107+
placeholder="Search rules by name or description..."
108+
value={searchTerm}
109+
onChange={(e) => setSearchTerm(e.target.value)}
110+
className="pl-10"
111+
/>
112+
</div>
113+
114+
{/* Rules List */}
61115
{isLoading ? (
62116
<RuleListSkeleton count={3} />
63117
) : rules.length === 0 ? (
64-
<div className="text-muted-foreground text-center">
65-
No rules to display yet.
118+
<div className="text-muted-foreground text-center py-8">
119+
{debouncedSearch
120+
? "No rules match your search."
121+
: "No rules to display yet."}
66122
</div>
67123
) : (
68-
<div className="grid gap-4">
69-
{rules.map((rule) => (
70-
<div
71-
key={rule.id}
72-
className="flex items-center justify-between p-4 rounded-lg border border-border"
73-
>
74-
<div>
75-
<div className="font-medium">{rule.name}</div>
76-
{rule.description && (
77-
<div className="text-sm text-muted-foreground">
78-
{rule.description}
79-
</div>
80-
)}
81-
</div>
82-
<div className="flex gap-2">
83-
<Button
84-
variant="outline"
85-
size="sm"
86-
onClick={() => setEditRuleId(rule.id)}
87-
>
88-
Edit
89-
</Button>
90-
<Button
91-
variant="destructive"
92-
size="sm"
93-
disabled={loadingId === rule.id}
94-
onClick={() => openDeleteDialog(rule)}
95-
>
96-
<Trash2 className="h-4 w-4" />
97-
<span className="sr-only">Delete</span>
98-
</Button>
124+
<>
125+
<div className="grid gap-4">
126+
{rules.map((rule) => (
127+
<div
128+
key={rule.id}
129+
className="flex items-center justify-between p-4 rounded-lg border border-border"
130+
>
131+
<div className="flex-1 min-w-0">
132+
<div className="font-medium truncate">{rule.name}</div>
133+
{rule.description && (
134+
<div className="text-sm text-muted-foreground truncate">
135+
{rule.description}
136+
</div>
137+
)}
138+
</div>
139+
<div className="flex gap-2 ml-4">
140+
<Button
141+
variant="outline"
142+
size="sm"
143+
onClick={() => setEditRuleId(rule.id)}
144+
>
145+
Edit
146+
</Button>
147+
<Button
148+
variant="destructive"
149+
size="sm"
150+
disabled={loadingId === rule.id}
151+
onClick={() => openDeleteDialog(rule)}
152+
>
153+
<Trash2 className="h-4 w-4" />
154+
<span className="sr-only">Delete</span>
155+
</Button>
156+
</div>
99157
</div>
158+
))}
159+
</div>
160+
161+
{/* Pagination */}
162+
{totalPages > 1 && (
163+
<div className="flex items-center justify-between text-sm text-muted-foreground">
164+
<Pagination>
165+
<PaginationContent>
166+
<PaginationItem>
167+
<PaginationPrevious
168+
onClick={() => handlePageChange(currentPage - 1)}
169+
className={
170+
currentPage <= 1
171+
? "pointer-events-none opacity-50"
172+
: "cursor-pointer"
173+
}
174+
/>
175+
</PaginationItem>
176+
177+
{Array.from(
178+
{ length: totalPages },
179+
(_, i) => i + 1
180+
).map((page) => (
181+
<PaginationItem key={page}>
182+
<PaginationLink
183+
onClick={() => handlePageChange(page)}
184+
isActive={currentPage === page}
185+
className="cursor-pointer"
186+
>
187+
{page}
188+
</PaginationLink>
189+
</PaginationItem>
190+
))}
191+
192+
<PaginationItem>
193+
<PaginationNext
194+
onClick={() => handlePageChange(currentPage + 1)}
195+
className={
196+
currentPage >= totalPages
197+
? "pointer-events-none opacity-50"
198+
: "cursor-pointer"
199+
}
200+
/>
201+
</PaginationItem>
202+
</PaginationContent>
203+
</Pagination>
100204
</div>
101-
))}
102-
</div>
205+
)}
206+
</>
103207
)}
208+
209+
{/* Add Rule Button */}
104210
<Button
105211
onClick={() => setIsAddRuleModalOpen(true)}
106212
className="w-full"

frontend/components/hooks/useRules.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import {
55
listRules,
66
updateRule,
77
} from "@/lib/api/rule";
8-
import { CreateRuleInput, Rule, UpdateRuleInput } from "@/lib/models/rule";
8+
import {
9+
CreateRuleInput,
10+
PaginatedRulesResponse,
11+
RuleListQuery,
12+
UpdateRuleInput,
13+
} from "@/lib/models/rule";
914
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1015
import { toast } from "sonner";
1116

12-
export function useRules() {
13-
return useQuery<Rule[]>({
14-
queryKey: ["rules"],
15-
queryFn: listRules,
17+
export function useRules(query?: RuleListQuery) {
18+
return useQuery<PaginatedRulesResponse>({
19+
queryKey: ["rules", query],
20+
queryFn: () => listRules(query),
1621
staleTime: 5 * 60 * 1000, // 5 minutes
1722
});
1823
}
@@ -42,13 +47,8 @@ export function useUpdateRule() {
4247
return useMutation({
4348
mutationFn: ({ id, input }: { id: number; input: UpdateRuleInput }) =>
4449
updateRule(id, input),
45-
onSuccess: (updatedRule) => {
46-
queryClient.setQueryData<Rule[]>(["rules"], (old) => {
47-
if (!old) return [updatedRule];
48-
return old.map((rule) =>
49-
rule.id === updatedRule.id ? updatedRule : rule
50-
);
51-
});
50+
onSuccess: () => {
51+
queryClient.invalidateQueries({ queryKey: ["rules"] });
5252
},
5353
});
5454
}

frontend/lib/api/rule.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,38 @@ import {
66
CreateRuleInput,
77
DescribeRuleResponse,
88
ExecuteRulesResponse,
9+
PaginatedRulesResponse,
910
Rule,
1011
RuleAction,
1112
RuleCondition,
13+
RuleListQuery,
1214
UpdateRuleInput,
1315
} from "@/lib/models/rule";
1416

15-
// List all rules
16-
export async function listRules(): Promise<Rule[]> {
17-
return apiRequest<Rule[]>(
18-
`${API_BASE_URL}/rule`,
17+
// List rules with optional pagination and search
18+
export async function listRules(
19+
query?: RuleListQuery
20+
): Promise<PaginatedRulesResponse> {
21+
const params = new URLSearchParams();
22+
23+
if (query?.page) {
24+
params.append("page", query.page.toString());
25+
}
26+
if (query?.page_size) {
27+
params.append("page_size", query.page_size.toString());
28+
}
29+
if (query?.search) {
30+
params.append("search", query.search);
31+
}
32+
33+
const url = `${API_BASE_URL}/rule${params.toString() ? `?${params.toString()}` : ""}`;
34+
35+
return apiRequest<PaginatedRulesResponse>(
36+
url,
1937
{
2038
credentials: "include",
2139
},
22-
"rule",
40+
"data",
2341
[],
2442
"Failed to fetch rules"
2543
);

frontend/lib/models/rule.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,16 @@ export interface SkippedResult {
9595
transaction_id: number;
9696
reason: string;
9797
}
98+
99+
export interface PaginatedRulesResponse {
100+
rules: Rule[];
101+
total: number;
102+
page: number;
103+
page_size: number;
104+
}
105+
106+
export interface RuleListQuery {
107+
page?: number;
108+
page_size?: number;
109+
search?: string;
110+
}

0 commit comments

Comments
 (0)