From dc5f55f87dbe428280940f822cbffd10b665c688 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 29 Jun 2026 21:00:18 +0300 Subject: [PATCH] frontend: paginate the Activity and Invoices pages Extract the Patients pagination into a reusable ListPagination component (components/ui/list-pagination.tsx, carrying the pageWindow helper) and use it on the Activity feed and the Invoices list (10/page, search resets to page 1). Patients is refactored onto the same component, removing the duplicated block. Co-Authored-By: Claude Opus 4.8 --- .../components/activity/activity-view.tsx | 26 +++- .../components/invoices/invoices-view.tsx | 28 +++- .../components/patients/patients-view.tsx | 102 ++------------ frontend/components/ui/list-pagination.tsx | 125 ++++++++++++++++++ 4 files changed, 184 insertions(+), 97 deletions(-) create mode 100644 frontend/components/ui/list-pagination.tsx diff --git a/frontend/components/activity/activity-view.tsx b/frontend/components/activity/activity-view.tsx index aa7a422..76a55f2 100644 --- a/frontend/components/activity/activity-view.tsx +++ b/frontend/components/activity/activity-view.tsx @@ -28,6 +28,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { ListPagination } from "@/components/ui/list-pagination"; import { type ActivityEntityType, type ActivityEntry, @@ -108,10 +109,14 @@ function DetailRow({ label, value }: { label: string; value: string }) { ); } +// Entries shown per page in the activity feed before paginating. +const PAGE_SIZE = 10; + export function ActivityView() { const { t } = useTranslation(); const [entries, setEntries] = useState([]); const [selected, setSelected] = useState(null); + const [page, setPage] = useState(1); useEffect(() => { let active = true; @@ -153,6 +158,15 @@ export function ActivityView() { ]; }, [entries, t]); + // Client-side pagination over the feed (10/page). `page` is clamped at render + // so a shrinking feed never leaves us past the last page. + const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages); + const pageRows = entries.slice( + (safePage - 1) * PAGE_SIZE, + safePage * PAGE_SIZE, + ); + return (
@@ -173,10 +187,11 @@ export function ActivityView() { {t("activity.empty")}
) : ( +
    - {entries.map((entry, i) => { + {pageRows.map((entry, i) => { const Icon = entityIcon[entry.entityType] ?? FileText; - const isLast = i === entries.length - 1; + const isLast = i === pageRows.length - 1; const context = [ entry.actorName, entry.patientName && @@ -226,6 +241,13 @@ export function ActivityView() { ); })}
+ +
)} ([]); const [query, setQuery] = useState(""); + const [page, setPage] = useState(1); const [loadError, setLoadError] = useState(null); const [selected, setSelected] = useState(null); @@ -70,6 +75,15 @@ export function InvoicesView() { ); }, [list, search]); + // Client-side pagination over the filtered list (10/page); clamp at render so a + // shrinking list never leaves us past the last page. + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages); + const pageRows = filtered.slice( + (safePage - 1) * PAGE_SIZE, + safePage * PAGE_SIZE, + ); + const kpis = useMemo(() => { const unpaid = list .filter((i) => i.status === "draft" || i.status === "sent") @@ -124,7 +138,10 @@ export function InvoicesView() { setQuery(event.target.value)} + onChange={(event) => { + setQuery(event.target.value); + setPage(1); + }} placeholder={t("invoices.searchPlaceholder")} value={query} /> @@ -161,7 +178,7 @@ export function InvoicesView() {
- {filtered.map((inv) => ( + {pageRows.map((inv) => (
- {!loading && !loadError && patients.length > PAGE_SIZE ? ( -
-

- {t("patients.pagination.summary", { - from: (safePage - 1) * PAGE_SIZE + 1, - to: Math.min(safePage * PAGE_SIZE, patients.length), - total: patients.length, - })} -

- - - - - - {pageWindow(safePage, totalPages).map((p, i) => - p === null ? ( - - - - ) : ( - - - - ) - )} - - - - - -
+ {!loading && !loadError ? ( + ) : null} i + 1); + } + const pages: (number | null)[] = [1]; + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + if (start > 2) pages.push(null); + for (let p = start; p <= end; p++) pages.push(p); + if (end < total - 1) pages.push(null); + pages.push(total); + return pages; +} + +type ListPaginationProps = { + /** Current (already-clamped) page, 1-based. */ + page: number; + pageSize: number; + /** Total number of items across all pages. */ + total: number; + onPageChange: (page: number) => void; +}; + +// Shared client-side pagination control (summary line + prev/page/next nav). +// Renders nothing when everything fits on one page. Used by the Patients, +// Activity and Invoices lists. +export function ListPagination({ + page, + pageSize, + total, + onPageChange, +}: ListPaginationProps) { + const { t } = useTranslation(); + if (total <= pageSize) return null; + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + + return ( +
+

+ {t("common.pagination.summary", { + from: (safePage - 1) * pageSize + 1, + to: Math.min(safePage * pageSize, total), + total, + })} +

+ + + + + + {pageWindow(safePage, totalPages).map((p, i) => + p === null ? ( + + + + ) : ( + + + + ) + )} + + + + + +
+ ); +}