frontend: paginate the Patients table and colour status badges

Add the COSS Pagination primitive and page the patients list at 10 rows/page
(search resets to page 1; the page is clamped at render so a shrinking list
never strands you past the last page). Replace the flat "secondary" status
badge with semantic colours: active → success, inpatient → info, discharged →
outline. Includes the i18n keys for pagination, the AI setup notice, and the
patient record-history / PDF export features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-27 21:33:18 +03:00
parent 8309e1e82e
commit 869038477a
4 changed files with 288 additions and 9 deletions
+10 -3
View File
@@ -29,7 +29,14 @@ import { Sparkline } from "@/components/chat/sparkline";
import { cn } from "@/lib/utils";
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
type BadgeVariant =
| "default"
| "secondary"
| "destructive"
| "outline"
| "success"
| "info"
| "warning";
type PatientResultProps = {
status: "loading" | "ready" | "not-found";
@@ -55,8 +62,8 @@ const labFlagVariant: Record<LabFlag, BadgeVariant> = {
};
const statusVariant: Record<Patient["status"], BadgeVariant> = {
active: "secondary",
inpatient: "destructive",
active: "success",
inpatient: "info",
discharged: "outline",
};
+124 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { Plus, Search, Smartphone } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Search, Smartphone } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -12,13 +12,44 @@ 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,
PaginationLink,
} from "@/components/ui/pagination";
import { listPatients, type Patient } from "@/lib/patients";
import { cn } from "@/lib/utils";
type BadgeVariant = "secondary" | "destructive" | "outline";
// 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
// (green), admitted inpatients as info (blue, draws the eye), and discharged as
// a muted outline.
const statusVariant: Record<Patient["status"], BadgeVariant> = {
active: "secondary",
inpatient: "destructive",
active: "success",
inpatient: "info",
discharged: "outline",
};
@@ -66,6 +97,17 @@ export function PatientsView() {
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q)
);
// Client-side pagination over the filtered list (10/page). Searching resets to
// the first page (done in the search handler); `page` is clamped at render so a
// shrinking list (filter/refresh) never leaves us past the last page.
const [page, setPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(patients.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pageRows = patients.slice(
(safePage - 1) * PAGE_SIZE,
safePage * PAGE_SIZE
);
const open = (fileNumber: string) => {
setSelected(fileNumber);
setSheetOpen(true);
@@ -100,7 +142,10 @@ export function PatientsView() {
<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);
}}
onKeyDown={(event) => {
// Enter opens the top match's record, like picking it from the table.
if (event.key === "Enter" && patients.length > 0) {
@@ -181,7 +226,7 @@ export function PatientsView() {
</td>
</tr>
) : (
patients.map((p) => (
pageRows.map((p) => (
<tr
className="cursor-pointer border-border/50 border-b transition-colors last:border-0 hover:bg-accent/50"
key={p.fileNumber}
@@ -230,6 +275,79 @@ 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>
<PaginationLink
aria-disabled={safePage === 1}
aria-label={t("patients.pagination.previous")}
className={cn(
"gap-1 px-2.5",
safePage === 1 && "pointer-events-none opacity-50"
)}
onClick={() => setPage(Math.max(1, safePage - 1))}
render={<button type="button" />}
size="default"
>
<ChevronLeft className="size-4" />
<span className="max-sm:hidden">
{t("patients.pagination.previous")}
</span>
</PaginationLink>
</PaginationItem>
{pageWindow(safePage, totalPages).map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
aria-label={t("patients.pagination.page", { page: p })}
isActive={p === safePage}
onClick={() => setPage(p)}
render={<button type="button" />}
>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationLink
aria-disabled={safePage === totalPages}
aria-label={t("patients.pagination.next")}
className={cn(
"gap-1 px-2.5",
safePage === totalPages && "pointer-events-none opacity-50"
)}
onClick={() => setPage(Math.min(totalPages, safePage + 1))}
render={<button type="button" />}
size="default"
>
<span className="max-sm:hidden">
{t("patients.pagination.next")}
</span>
<ChevronRight className="size-4" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
) : null}
<PatientFormDialog
key={addKey}
mode="create"
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { type Button, buttonVariants } from "@/components/ui/button";
export function Pagination({
className,
...props
}: React.ComponentProps<"nav">): React.ReactElement {
return (
<nav
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
data-slot="pagination"
{...props}
/>
);
}
export function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">): React.ReactElement {
return (
<ul
className={cn("flex flex-row items-center gap-1", className)}
data-slot="pagination-content"
{...props}
/>
);
}
export function PaginationItem({
...props
}: React.ComponentProps<"li">): React.ReactElement {
return <li data-slot="pagination-item" {...props} />;
}
export type PaginationLinkProps = {
isActive?: boolean;
size?: React.ComponentProps<typeof Button>["size"];
} & useRender.ComponentProps<"a">;
export function PaginationLink({
className,
isActive,
size = "icon",
render,
...props
}: PaginationLinkProps): React.ReactElement {
const defaultProps = {
"aria-current": isActive ? ("page" as const) : undefined,
className: render
? className
: cn(
buttonVariants({
size,
variant: isActive ? "outline" : "ghost",
}),
className,
),
"data-active": isActive,
"data-slot": "pagination-link",
};
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(defaultProps, props),
render,
});
}
export function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>): React.ReactElement {
return (
<PaginationLink
aria-label="Go to previous page"
className={cn("max-sm:aspect-square max-sm:p-0", className)}
size="default"
{...props}
>
<ChevronLeftIcon className="sm:-ms-1" />
<span className="max-sm:hidden">Previous</span>
</PaginationLink>
);
}
export function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>): React.ReactElement {
return (
<PaginationLink
aria-label="Go to next page"
className={cn("max-sm:aspect-square max-sm:p-0", className)}
size="default"
{...props}
>
<span className="max-sm:hidden">Next</span>
<ChevronRightIcon className="sm:-me-1" />
</PaginationLink>
);
}
export function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">): React.ReactElement {
return (
<span
aria-hidden
className={cn("flex min-w-7 justify-center", className)}
data-slot="pagination-ellipsis"
{...props}
>
<MoreHorizontalIcon className="size-5 sm:size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
@@ -222,6 +222,13 @@
"loading": "Loading patients…",
"empty": "No patients found.",
"loadError": "Failed to load patients.",
"pagination": {
"label": "Patient pages",
"previous": "Previous",
"next": "Next",
"page": "Page {{page}}",
"summary": "Showing {{from}}{{to}} of {{total}}"
},
"columns": {
"name": "Name",
"mrn": "MRN",
@@ -1024,6 +1031,12 @@
},
"chat": {
"heading": "Which patient would you like to look up?",
"setupNotice": {
"title": "Connect an AI model to get started",
"body": "No AI provider is set up yet. Add an API key or point temetro at a local Ollama model so the assistant can answer.",
"action": "Open AI settings",
"dismiss": "Dismiss"
},
"input": {
"placeholder": "Ask anything, or type /patient 10293",
"message": "Message",
@@ -1311,7 +1324,18 @@
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",
"edit": "Edit",
"exportPdf": "Download summary",
"clickForMore": "Click for more",
"pdf": {
"title": "Clinical summary",
"mrn": "MRN",
"generated": "Generated {{date}}"
},
"history": {
"title": "Record history",
"empty": "No recorded changes yet.",
"loadError": "Couldn't load the record history."
},
"sex": {
"F": "Female",
"M": "Male"