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 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-29 21:00:18 +03:00
parent a4970df334
commit dc5f55f87d
4 changed files with 184 additions and 97 deletions
+24 -2
View File
@@ -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<ActivityEntry[]>([]);
const [selected, setSelected] = useState<ActivityEntry | null>(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 (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-6 py-10">
<div>
@@ -173,10 +187,11 @@ export function ActivityView() {
{t("activity.empty")}
</div>
) : (
<div>
<ol className="flex flex-col">
{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() {
);
})}
</ol>
<ListPagination
onPageChange={setPage}
page={safePage}
pageSize={PAGE_SIZE}
total={entries.length}
/>
</div>
)}
<Dialog
+26 -2
View File
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ListPagination } from "@/components/ui/list-pagination";
import {
formatInvoiceDate,
formatMoney,
@@ -30,10 +31,14 @@ const statusVariant: Record<
void: "destructive",
};
// Invoices shown per page before paginating.
const PAGE_SIZE = 10;
export function InvoicesView() {
const { t } = useTranslation();
const [list, setList] = useState<Invoice[]>([]);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [loadError, setLoadError] = useState<string | null>(null);
const [selected, setSelected] = useState<Invoice | null>(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() {
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => 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() {
</div>
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{filtered.map((inv) => (
{pageRows.map((inv) => (
<button
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
key={inv.id}
@@ -199,6 +216,13 @@ export function InvoicesView() {
)}
</div>
<ListPagination
onPageChange={setPage}
page={safePage}
pageSize={PAGE_SIZE}
total={filtered.length}
/>
<InvoiceFormDialog
invoice={editing ?? undefined}
mode={formMode}
+9 -93
View File
@@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight, Plus, Search, Smartphone } from "lucide-react";
import { Plus, Search, Smartphone } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -12,34 +12,12 @@ import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/components/ui/pagination";
import { ListPagination } from "@/components/ui/list-pagination";
import { listPatients, type Patient } from "@/lib/patients";
// Rows shown per page on the patients table before paginating.
const PAGE_SIZE = 10;
// Page numbers to render, with `null` marking an ellipsis gap. Keeps the first,
// last, and a small window around the current page so the control stays compact
// even with many pages.
function pageWindow(current: number, total: number): (number | null)[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => 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 BadgeVariant = "success" | "info" | "outline";
// Colour the status for at-a-glance scanning: active patients read as success
@@ -273,75 +251,13 @@ export function PatientsView() {
</table>
</div>
{!loading && !loadError && patients.length > PAGE_SIZE ? (
<div className="mt-4 flex flex-col items-center justify-between gap-3 sm:flex-row">
<p className="text-xs text-muted-foreground">
{t("patients.pagination.summary", {
from: (safePage - 1) * PAGE_SIZE + 1,
to: Math.min(safePage * PAGE_SIZE, patients.length),
total: patients.length,
})}
</p>
<Pagination
aria-label={t("patients.pagination.label")}
className="mx-0 w-auto justify-end"
>
<PaginationContent>
<PaginationItem>
<Button
aria-label={t("patients.pagination.previous")}
className="gap-1"
disabled={safePage === 1}
onClick={() => setPage(Math.max(1, safePage - 1))}
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
<span className="max-sm:hidden">
{t("patients.pagination.previous")}
</span>
</Button>
</PaginationItem>
{pageWindow(safePage, totalPages).map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<Button
aria-current={p === safePage ? "page" : undefined}
aria-label={t("patients.pagination.page", { page: p })}
onClick={() => setPage(p)}
size="icon-sm"
type="button"
variant={p === safePage ? "outline" : "ghost"}
>
{p}
</Button>
</PaginationItem>
)
)}
<PaginationItem>
<Button
aria-label={t("patients.pagination.next")}
className="gap-1"
disabled={safePage === totalPages}
onClick={() => setPage(Math.min(totalPages, safePage + 1))}
size="sm"
type="button"
variant="ghost"
>
<span className="max-sm:hidden">
{t("patients.pagination.next")}
</span>
<ChevronRight className="size-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
{!loading && !loadError ? (
<ListPagination
onPageChange={setPage}
page={safePage}
pageSize={PAGE_SIZE}
total={patients.length}
/>
) : null}
<PatientFormDialog
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/components/ui/pagination";
// Page numbers to render, with `null` marking an ellipsis gap. Keeps the first,
// last, and a small window around the current page so the control stays compact
// even with many pages.
function pageWindow(current: number, total: number): (number | null)[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => 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 (
<div className="mt-4 flex flex-col items-center justify-between gap-3 sm:flex-row">
<p className="text-xs text-muted-foreground">
{t("common.pagination.summary", {
from: (safePage - 1) * pageSize + 1,
to: Math.min(safePage * pageSize, total),
total,
})}
</p>
<Pagination
aria-label={t("common.pagination.label")}
className="mx-0 w-auto justify-end"
>
<PaginationContent>
<PaginationItem>
<Button
aria-label={t("common.pagination.previous")}
className="gap-1"
disabled={safePage === 1}
onClick={() => onPageChange(Math.max(1, safePage - 1))}
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
<span className="max-sm:hidden">
{t("common.pagination.previous")}
</span>
</Button>
</PaginationItem>
{pageWindow(safePage, totalPages).map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<Button
aria-current={p === safePage ? "page" : undefined}
aria-label={t("common.pagination.page", { page: p })}
onClick={() => onPageChange(p)}
size="icon-sm"
type="button"
variant={p === safePage ? "outline" : "ghost"}
>
{p}
</Button>
</PaginationItem>
)
)}
<PaginationItem>
<Button
aria-label={t("common.pagination.next")}
className="gap-1"
disabled={safePage === totalPages}
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
size="sm"
type="button"
variant="ghost"
>
<span className="max-sm:hidden">
{t("common.pagination.next")}
</span>
<ChevronRight className="size-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}