Files
HealthLog/src/components/encounters/procedures-section.tsx
T
Marc-André Bombeck 3cf67d05f9 fix(ui): let the read-failure primitives decide by the error
About forty compact rows and seventy cards still offered a retry on a
spent read budget and drew an error card on a refusal, while a third
primitive (ReadFailureRow) did the right thing at eight places.

QueryErrorRow and QueryErrorCard now take the query's `error` and read
it through readFailureKind, which moves to src/lib/api next to
ApiError: a refusal (403) draws the surface's refused state or nothing,
a spent budget (429) says so without a retry, an ended session (401)
says so and offers sign-in instead of going blank, and only a real
failure offers the retry. ReadFailureRow is gone and every site passes
its error; a guard test fails on a site that does not.

The coach read strip keeps its card in the error state and holds one
body height across loading, failure and a one-line read.
2026-10-20 03:38:57 +02:00

237 lines
8.7 KiB
TypeScript

"use client";
/**
* The procedure and surgery history — the third tab of the checkups page.
*
* It answers the intake-form question "what surgeries have you had" at a
* glance, and "what was done on the left knee" with one tap. A procedure is a
* visit of kind PROCEDURE, so there is no second record type to keep in step:
* the rows here are the same rows the visits tab lists, opened into the same
* sheet, and a visit filed before this view existed joins it by switching its
* kind.
*
* The search runs on the server. The body site is encrypted at rest, so the
* server decrypts the account's own procedures and matches there; this view
* sends the words and renders the answer. The body-site choices and the total
* come back with every answer, computed over the whole history, so picking one
* never makes the others disappear.
*/
import { Plus, Scissors, Search } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useProcedures, type Encounter } from "@/hooks/use-encounters";
import { useTranslations } from "@/lib/i18n/context";
import type { BodySiteFacetDTO } from "@/lib/encounters/dto";
import { bodySiteText } from "./encounter-labels";
import { EncounterSheet } from "./encounter-sheet";
import { VisitCard } from "./visit-card";
type Side = "LEFT" | "RIGHT" | "BOTH" | null;
export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
// Same grant questions as the visits tab: a procedure is a visit.
const canAdd = canWriteDomain("profile");
const canManage = canManageDomain("profile");
const [query, setQuery] = useState("");
const [side, setSide] = useState<Side>(null);
const q = useDebouncedValue(query.trim(), 250);
const list = useProcedures(enabled, { q, laterality: side });
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
const [session, setSession] = useState(0);
const openSheet = (target: Encounter | "new") => {
setEditing(target);
setSession((n) => n + 1);
};
const procedures = list.data?.procedures ?? [];
const bodySites = list.data?.bodySites ?? [];
const total = list.data?.total ?? 0;
const filtered = query.trim().length > 0 || side !== null;
const pick = (facet: BodySiteFacetDTO | null) => {
setQuery(facet?.bodySite ?? "");
setSide(facet?.laterality ?? null);
};
const isPicked = (facet: BodySiteFacetDTO) =>
query.trim() === facet.bodySite && side === facet.laterality;
const addButton = canAdd ? (
<Button
type="button"
className="min-h-11"
data-slot="procedures-add"
onClick={() => openSheet("new")}
>
<Plus className="size-4" aria-hidden />
{t("encounters.procedures.add")}
</Button>
) : null;
return (
<section aria-labelledby="procedures-section-title" className="space-y-4">
<PageHeader
titleId="procedures-section-title"
title={t("encounters.procedures.title")}
description={t("encounters.procedures.description")}
// The empty state carries the add action while there is none.
actions={
!list.isPending && !list.isError && total === 0 && !filtered
? null
: addButton
}
/>
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("encounters.procedures.loadError")}
onRetry={() => void list.refetch()}
/>
) : list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-20 w-full rounded-xl" />
))}
</div>
) : total === 0 ? (
<EmptyState
icon={<Scissors className="size-6" aria-hidden />}
title={t("encounters.procedures.emptyTitle")}
description={t("encounters.procedures.emptyDescription")}
action={addButton ?? undefined}
/>
) : (
<div className="space-y-4">
<div className="space-y-3" data-slot="procedures-filter">
<FieldGroup
htmlFor="procedures-search"
label={t("encounters.procedures.searchLabel")}
>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
id="procedures-search"
type="search"
value={query}
autoComplete="off"
maxLength={200}
placeholder={t("encounters.procedures.searchPlaceholder")}
className="pl-9"
onChange={(event) => {
setQuery(event.target.value);
// Typing starts a new question; a side picked from a chip
// belonged to the old one.
setSide(null);
}}
/>
</div>
</FieldGroup>
{bodySites.length > 0 ? (
<div
role="group"
aria-label={t("encounters.procedures.sitesLabel")}
className="flex flex-wrap gap-2"
>
<Button
type="button"
size="sm"
variant={filtered ? "outline" : "secondary"}
aria-pressed={!filtered}
data-slot="procedures-site-all"
className="min-h-11 rounded-full sm:min-h-9"
onClick={() => pick(null)}
>
{t("encounters.procedures.allSites")}
</Button>
{bodySites.map((facet) => {
const picked = isPicked(facet);
return (
<Button
key={`${facet.bodySite}|${facet.laterality ?? ""}`}
type="button"
size="sm"
variant={picked ? "secondary" : "outline"}
aria-pressed={picked}
data-slot="procedures-site"
className="min-h-11 rounded-full sm:min-h-9"
onClick={() => pick(picked ? null : facet)}
>
{bodySiteText(t, facet.bodySite, facet.laterality)}
<span className="text-muted-foreground tabular-nums">
{facet.count}
</span>
</Button>
);
})}
</div>
) : null}
</div>
<p className="text-muted-foreground text-xs" aria-live="polite">
{t("encounters.procedures.count", {
shown: String(procedures.length),
total: String(total),
})}
</p>
{procedures.length === 0 ? (
<EmptyState
icon={<Search className="size-6" aria-hidden />}
title={t("encounters.procedures.noMatchTitle")}
action={
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => pick(null)}
>
{t("encounters.procedures.clearFilter")}
</Button>
}
/>
) : (
<div className="space-y-2" data-slot="procedures-list">
{procedures.map((encounter) => (
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={canManage ? openSheet : undefined}
/>
))}
</div>
)}
</div>
)}
{/* Remounted per open, like the visits tab's sheet, so a new procedure
starts as a procedure and an edit starts from its own row. */}
<EncounterSheet
key={session}
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
encounter={editing === "new" ? null : editing}
kind="PROCEDURE"
/>
</section>
);
}