Skip to content

Commit 5cfdc48

Browse files
authored
feat: introduce pagination for categories (#101)
1 parent 6930fb1 commit 5cfdc48

File tree

1 file changed

+99
-8
lines changed

1 file changed

+99
-8
lines changed

frontend/components/custom/Modal/Category/ViewCategoriesModal.tsx

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,18 @@ import {
1313
DialogTitle,
1414
} from "@/components/ui/dialog";
1515
import { Icon, IconName } from "@/components/ui/icon-picker";
16+
import { Input } from "@/components/ui/input";
17+
import {
18+
Pagination,
19+
PaginationContent,
20+
PaginationItem,
21+
PaginationLink,
22+
PaginationNext,
23+
PaginationPrevious,
24+
} from "@/components/ui/pagination";
1625
import { Category } from "@/lib/models/category";
17-
import { Tag, Trash2 } from "lucide-react";
18-
import { useState } from "react";
26+
import { Search, Tag, Trash2 } from "lucide-react";
27+
import { useEffect, useMemo, useState } from "react";
1928

2029
interface ViewCategoriesModalProps {
2130
isOpen: boolean;
@@ -36,6 +45,29 @@ export function ViewCategoriesModal({
3645
const [confirmDeleteCategory, setConfirmDeleteCategory] =
3746
useState<Category | null>(null);
3847

48+
// Frontend-only search + pagination
49+
const [searchTerm, setSearchTerm] = useState("");
50+
const [debouncedSearch, setDebouncedSearch] = useState("");
51+
const [currentPage, setCurrentPage] = useState(1);
52+
const pageSize = 5;
53+
54+
useEffect(() => {
55+
const t = setTimeout(() => setDebouncedSearch(searchTerm.trim()), 300);
56+
return () => clearTimeout(t);
57+
}, [searchTerm]);
58+
59+
const filtered = useMemo(() => {
60+
if (!debouncedSearch) return categories;
61+
const s = debouncedSearch.toLowerCase();
62+
return categories.filter((c) => c.name.toLowerCase().includes(s));
63+
}, [categories, debouncedSearch]);
64+
65+
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
66+
const pagedCategories = useMemo(() => {
67+
const start = (currentPage - 1) * pageSize;
68+
return filtered.slice(start, start + pageSize);
69+
}, [filtered, currentPage]);
70+
3971
const handleDeleteCategory = async (category: Category) => {
4072
deleteCategoryMutation.mutate(category.id, {
4173
onSuccess: () => {
@@ -47,14 +79,26 @@ export function ViewCategoriesModal({
4779
return (
4880
<>
4981
<Dialog open={isOpen} onOpenChange={onOpenChange}>
50-
<DialogContent className="sm:max-w-[425px] max-h-[80vh] overflow-y-auto">
82+
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
5183
<DialogHeader>
5284
<DialogTitle className="flex items-center gap-2">
5385
<Tag className="h-5 w-5" />
5486
Categories
5587
</DialogTitle>
5688
</DialogHeader>
57-
<div className="space-y-4">
89+
<div className="space-y-4 py-2">
90+
<div className="relative">
91+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
92+
<Input aria-label="Search categories"
93+
placeholder="Search categories by name..."
94+
value={searchTerm}
95+
onChange={(e) => {
96+
setSearchTerm(e.target.value);
97+
setCurrentPage(1);
98+
}}
99+
className="pl-10"
100+
/>
101+
</div>
58102
{isLoading ? (
59103
<div className="space-y-2">
60104
{[...Array(3)].map((_, i) => (
@@ -64,9 +108,15 @@ export function ViewCategoriesModal({
64108
/>
65109
))}
66110
</div>
111+
) : pagedCategories.length === 0 ? (
112+
<div className="text-center py-8 text-muted-foreground">
113+
{debouncedSearch
114+
? "No categories match your search."
115+
: "No categories found. Add your first category to get started."}
116+
</div>
67117
) : (
68118
<div className="space-y-2">
69-
{categories.map((category) => (
119+
{pagedCategories.map((category) => (
70120
<div
71121
key={category.id}
72122
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
@@ -97,9 +147,50 @@ export function ViewCategoriesModal({
97147
</div>
98148
</div>
99149
))}
100-
{categories.length === 0 && (
101-
<div className="text-center py-8 text-muted-foreground">
102-
No categories found. Add your first category to get started.
150+
{totalPages > 1 && (
151+
<div className="flex items-center justify-between text-sm text-muted-foreground pt-2">
152+
<Pagination>
153+
<PaginationContent>
154+
<PaginationItem>
155+
<PaginationPrevious
156+
onClick={() =>
157+
setCurrentPage((p) => Math.max(1, p - 1))
158+
}
159+
className={
160+
currentPage <= 1
161+
? "pointer-events-none opacity-50"
162+
: "cursor-pointer"
163+
}
164+
/>
165+
</PaginationItem>
166+
{Array.from(
167+
{ length: totalPages },
168+
(_, i) => i + 1
169+
).map((page) => (
170+
<PaginationItem key={page}>
171+
<PaginationLink
172+
onClick={() => setCurrentPage(page)}
173+
isActive={currentPage === page}
174+
className="cursor-pointer"
175+
>
176+
{page}
177+
</PaginationLink>
178+
</PaginationItem>
179+
))}
180+
<PaginationItem>
181+
<PaginationNext
182+
onClick={() =>
183+
setCurrentPage((p) => Math.min(totalPages, p + 1))
184+
}
185+
className={
186+
currentPage >= totalPages
187+
? "pointer-events-none opacity-50"
188+
: "cursor-pointer"
189+
}
190+
/>
191+
</PaginationItem>
192+
</PaginationContent>
193+
</Pagination>
103194
</div>
104195
)}
105196
</div>

0 commit comments

Comments
 (0)