Merge branch 'feat/procedure-history' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 08:23:34 +02:00
commit c327ae81d5
59 files changed
+2959 -79

No files matched your search

@@ -801,6 +801,8 @@ describe("a visit row's edit sheet", () => {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
@@ -46,6 +46,7 @@ import { EmergencySection, PatientIdentitySection } from "./identity-sections";
import {
IllnessSection,
ImmunizationsSection,
SurgicalHistorySection,
LabResultsSection,
VisitsSection,
} from "./history-sections";
@@ -256,6 +257,12 @@ export function ClinicianView({
scope={scope}
fmtDate={fmtDate}
/>
<SurgicalHistorySection
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
/>
<ImmunizationsSection
t={t}
report={report}
+53 -3
View File
@@ -1,6 +1,6 @@
/**
* The `labs` and `history` groups on the clinician view: lab results, illness
* episodes, visits and the immunization record.
* episodes, visits, the surgical history and the immunization record.
*
* All four were selectable on a share link and none of them reached the page,
* while the PDF download from the same link printed every one. What each
@@ -12,8 +12,11 @@
* A pure server component: no client hooks, no session, no markdown — every
* value renders as escaped React text.
*/
import type { EncounterKind } from "@/generated/prisma/client";
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
import {
encounterKindLabelKey,
lateralityLabelKey,
} from "@/lib/encounters/kind-label";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
@@ -221,6 +224,53 @@ export function VisitsSection({
);
}
/**
* The surgical history. Reference data, not window-bounded, like the
* immunization record below: every procedure that happened, oldest first. What
* was done is the row's label; the date, the site with its side and the
* outcome compose onto the value side, the shape the immunization rows use.
*/
export function SurgicalHistorySection({
t,
report,
scope,
fmtDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
}) {
const procedures = report.surgicalHistory ?? [];
const site = (row: (typeof procedures)[number]) => {
if (!row.bodySite) return null;
if (!row.laterality) return row.bodySite;
return t("encounters.bodySiteWithSide", {
site: row.bodySite,
side: t(lateralityLabelKey(row.laterality as Laterality)),
});
};
return (
<LeafSection
t={t}
scope={scope}
leaves={["SURGICAL_HISTORY"]}
title={t("doctorReport.surgicalHistoryTitle")}
empty={procedures.length === 0}
>
{procedures.map((row, index) => (
<StatRow
key={`${row.occurredAt}-${index}`}
label={row.procedure ?? "—"}
value={compose([fmtDate(row.occurredAt), site(row), row.outcome])}
/>
))}
</LeafSection>
);
}
/**
* The immunization record. Reference data, not window-bounded: an Impfpass is
* a lifetime document and the whole of it rides when the leaf and the module
@@ -132,6 +132,8 @@ describe("editing never unfiles what the visit already collected", () => {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
links: {
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
@@ -216,3 +218,96 @@ describe("the link pickers follow the modules that own their targets", () => {
expect(html).toContain('data-slot="encounter-link-pickers"');
});
});
/**
* v1.39.1 — the body site. Shown for a procedure, where it means something,
* and kept visible on any visit that already carries one, so switching the
* kind away never hides text the person typed. Never required.
*
* Mutation checks (each run, each seen red):
* - show the field only for PROCEDURE → "keeps a typed site visible after
* the kind moves away" goes red;
* - send `bodySite` untrimmed → the whitespace case goes red.
*/
describe("the body site", () => {
function renderDraft(over: Parameters<typeof emptyDraft>[0]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<EncounterForm
draft={emptyDraft({ occurredAt: "2026-08-08T10:00", ...over })}
onChange={() => {}}
isEdit
/>
</I18nProvider>
</QueryClientProvider>,
);
}
it("offers the site and the side for a procedure", () => {
const html = renderDraft({ kind: "PROCEDURE" });
expect(html).toContain('id="encounter-body-site"');
expect(html).toContain('id="encounter-laterality"');
expect(html).toContain("Body site");
expect(html).toContain("Not stated");
});
it("keeps the fields off an ordinary visit", () => {
const html = renderDraft({ kind: "ROUTINE" });
expect(html).not.toContain('id="encounter-body-site"');
});
it("keeps a typed site visible after the kind moves away", () => {
const html = renderDraft({ kind: "HOSPITAL", bodySite: "Knee" });
expect(html).toContain('id="encounter-body-site"');
});
it("offers Procedure or surgery among the kinds", () => {
const html = renderDraft({ kind: "OTHER" });
expect(html).toContain('value="PROCEDURE"');
expect(html).toContain("Procedure or surgery");
});
it("sends the site trimmed and the side, and null for an empty site", () => {
const withSite = draftToBody(
emptyDraft({
occurredAt: "2026-08-08T10:00",
kind: "PROCEDURE",
bodySite: " Knee ",
laterality: "LEFT",
}),
{ isEdit: false },
);
expect(withSite?.bodySite).toBe("Knee");
expect(withSite?.laterality).toBe("LEFT");
const empty = draftToBody(
emptyDraft({ occurredAt: "2026-08-08T10:00", bodySite: " " }),
{ isEdit: false },
);
expect(empty?.bodySite).toBeNull();
expect(empty?.laterality).toBeNull();
});
it("seeds the site and the side from the row being edited", () => {
const draft = draftFromEncounter({
id: "e1",
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "PROCEDURE",
practitioner: null,
reason: null,
outcome: null,
bodySite: "Knee",
laterality: "RIGHT",
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
});
expect(draft.bodySite).toBe("Knee");
expect(draft.laterality).toBe("RIGHT");
});
});
@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The procedure history's designed states, pinned by single-pass static
* renders (the repository's component-test convention; typing into the filter
* is driven in the browser check, not here).
*
* - an account with no procedure gets an empty state that says how to make
* one — including switching a visit already on file — and one action;
* - a history lists what the server sent, in the server's order, each row
* naming its body site with the side;
* - the body sites are offered as filter choices, with "All" pressed while
* nothing is filtered;
* - a failed read is an error with a retry, never the empty state.
*
* Mutation checks (each run, each seen red):
* - render `procedures.length === 0` as the account-empty state instead of
* keying it on `total` → "keeps the filter when nothing matches" goes red;
* - drop the side from `bodySiteText` → "names the site with its side" goes
* red.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: {} },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canWrite: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO, ProcedureListDTO } from "@/lib/encounters/dto";
import { ProceduresSection } from "../procedures-section";
function procedure(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
return {
occurredAt: "2011-03-14T09:00:00.000Z",
status: "DONE",
kind: "PROCEDURE",
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2011-03-14T09:00:00.000Z",
updatedAt: "2011-03-14T09:00:00.000Z",
...over,
};
}
function render(data: ProcedureListDTO | "error") {
const queryClient = new QueryClient({
// `retryOnMount: false` so a seeded error stays an error through the
// render instead of being reported as a fresh fetch.
defaultOptions: { queries: { retry: false, retryOnMount: false } },
});
const key = queryKeys.encounterProcedures("", null);
if (data === "error") {
queryClient
.getQueryCache()
.build(queryClient, { queryKey: key })
.setState({
status: "error",
error: new Error("boom"),
fetchStatus: "idle",
});
} else {
queryClient.setQueryData(key, data);
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<ProceduresSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ProceduresSection>", () => {
it("explains how to get a first procedure, including switching a visit", () => {
const html = render({ procedures: [], bodySites: [], total: 0 });
expect(html).toContain("No procedure recorded yet");
expect(html).toContain("switch an existing visit to it");
expect(html).toContain('data-slot="procedures-add"');
expect(html).not.toContain('data-slot="procedures-filter"');
expect(html).not.toContain("Procedures could not be loaded.");
});
it("lists the history in the server's order and names the site with its side", () => {
const html = render({
procedures: [
procedure({
id: "newer",
occurredAt: "2019-07-02T09:00:00.000Z",
reason: "Cholecystectomy",
bodySite: "Gallbladder",
}),
procedure({
id: "older",
reason: "Meniscus repair",
bodySite: "Knee",
laterality: "LEFT",
}),
],
bodySites: [
{ bodySite: "Gallbladder", laterality: null, count: 1 },
{ bodySite: "Knee", laterality: "LEFT", count: 1 },
],
total: 2,
});
expect(html.indexOf('data-encounter-id="newer"')).toBeLessThan(
html.indexOf('data-encounter-id="older"'),
);
expect(html).toContain("Meniscus repair");
expect(html).toContain("Knee · Left");
expect(html).toContain("2 of 2");
});
it("heads a procedure with what was done, and keeps the practice on its own line", () => {
const html = render({
procedures: [
procedure({
id: "p1",
reason: "Meniscus repair",
practitioner: {
id: "pr1",
name: "Sample clinic",
specialty: null,
practice: null,
location: null,
phone: null,
note: null,
createdAt: "2011-01-01T00:00:00.000Z",
updatedAt: "2011-01-01T00:00:00.000Z",
},
}),
],
bodySites: [],
total: 1,
});
expect(html).toMatch(/font-medium">Meniscus repair</);
expect(html).toContain("Sample clinic");
expect(html.match(/Meniscus repair/g)).toHaveLength(1);
});
it("offers every body site as a filter, with All pressed while nothing is filtered", () => {
const html = render({
procedures: [
procedure({ id: "p1", bodySite: "Knee", laterality: "LEFT" }),
],
bodySites: [{ bodySite: "Knee", laterality: "LEFT", count: 1 }],
total: 1,
});
expect(html).toContain('data-slot="procedures-filter"');
expect(html).toMatch(
/data-slot="procedures-site-all"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="procedures-site-all"/,
);
expect(html).toMatch(
/aria-pressed="false"[^>]*>Knee · Left|data-slot="procedures-site"[^>]*aria-pressed="false"/,
);
});
it("keeps the filter when nothing matches, and offers to clear it", () => {
const html = render({
procedures: [],
bodySites: [{ bodySite: "Knee", laterality: null, count: 1 }],
total: 1,
});
expect(html).toContain("Nothing matches this filter");
expect(html).toContain('data-slot="procedures-filter"');
expect(html).not.toContain("No procedure recorded yet");
});
it("paints an error with a retry, never the empty state", () => {
const html = render("error");
expect(html).toContain("Procedures could not be loaded.");
expect(html).not.toContain("No procedure recorded yet");
});
});
@@ -60,6 +60,8 @@ function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
@@ -27,6 +27,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/use-auth";
@@ -36,8 +37,10 @@ import type { Practitioner } from "@/hooks/use-practitioners";
import {
ENCOUNTER_KINDS,
ENCOUNTER_STATUSES,
LATERALITIES,
encounterKindText,
encounterStatusText,
lateralityText,
} from "./encounter-labels";
import { PractitionerCombobox } from "./practitioner-combobox";
import { EncounterLinkPickers } from "./encounter-link-pickers";
@@ -50,6 +53,9 @@ export interface EncounterDraft {
practitioner: Practitioner | null;
reason: string;
outcome: string;
/** Where on the body; free text, empty when not stated. */
bodySite: string;
laterality: "LEFT" | "RIGHT" | "BOTH" | null;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
@@ -82,6 +88,8 @@ export function emptyDraft(
practitioner: null,
reason: "",
outcome: "",
bodySite: "",
laterality: null,
documentIds: [],
labResultIds: [],
episodeIds: [],
@@ -97,6 +105,8 @@ export function draftFromEncounter(row: Encounter): EncounterDraft {
practitioner: row.practitioner,
reason: row.reason ?? "",
outcome: row.outcome ?? "",
bodySite: row.bodySite ?? "",
laterality: row.laterality,
documentIds: row.links?.documents.map((link) => link.id) ?? [],
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
@@ -131,6 +141,8 @@ export function draftToBody(
practitionerId: draft.practitioner?.id ?? null,
reason: draft.reason.trim() || null,
outcome: draft.outcome.trim() || null,
bodySite: draft.bodySite.trim() || null,
laterality: draft.laterality,
...(options.reminderId ? { reminderId: options.reminderId } : {}),
documentIds: draft.documentIds,
labResultIds: draft.labResultIds,
@@ -168,6 +180,15 @@ export function EncounterForm({
const [revealed, setRevealed] = useState(false);
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
// The body site belongs to a procedure, so that is where it is offered. A
// visit that already carries one keeps the fields in view whatever its kind:
// switching the kind away must not hide text the person typed, and the
// server keeps it either way.
const showBodySite =
draft.kind === "PROCEDURE" ||
draft.bodySite.trim().length > 0 ||
draft.laterality !== null;
return (
<div className="space-y-4" data-slot="encounter-form">
<FieldGroup
@@ -234,6 +255,47 @@ export function EncounterForm({
</NativeSelect>
</FieldGroup>
{showBodySite ? (
<div className="grid gap-4 sm:grid-cols-[2fr_1fr]">
<FieldGroup
htmlFor="encounter-body-site"
label={t("encounters.form.bodySite")}
hint={t("encounters.form.bodySiteHint")}
>
<Input
id="encounter-body-site"
value={draft.bodySite}
maxLength={200}
autoComplete="off"
onChange={(event) => patch({ bodySite: event.target.value })}
/>
</FieldGroup>
<FieldGroup
htmlFor="encounter-laterality"
label={t("encounters.form.laterality")}
>
<NativeSelect
id="encounter-laterality"
value={draft.laterality ?? ""}
onChange={(event) =>
patch({
laterality:
(event.target.value as EncounterDraft["laterality"]) ||
null,
})
}
>
<option value="">{t("encounters.laterality.none")}</option>
{LATERALITIES.map((side) => (
<option key={side} value={side}>
{lateralityText(t, side)}
</option>
))}
</NativeSelect>
</FieldGroup>
</div>
) : null}
<FieldGroup
htmlFor="encounter-reason"
label={t("encounters.form.reason")}
+42 -2
View File
@@ -12,9 +12,13 @@
* An interpolated key is invisible to `i18n-call-site-coverage`, so a renamed
* or missing label would ship as raw dot notation instead of failing CI.
*/
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
import type {
EncounterKind,
EncounterStatus,
Laterality,
} from "@/generated/prisma/client";
type Translate = (key: string) => string;
type Translate = (key: string, values?: Record<string, string>) => string;
/** Every kind, in the order the picker offers them. */
export const ENCOUNTER_KINDS: EncounterKind[] = [
@@ -25,9 +29,13 @@ export const ENCOUNTER_KINDS: EncounterKind[] = [
"EMERGENCY",
"HOSPITAL",
"THERAPY",
"PROCEDURE",
"OTHER",
];
/** Every side a body site can carry; "not stated" is the absence, not a value. */
export const LATERALITIES: Laterality[] = ["LEFT", "RIGHT", "BOTH"];
/** Every status a visit can be edited into. */
export const ENCOUNTER_STATUSES: EncounterStatus[] = [
"PLANNED",
@@ -52,6 +60,8 @@ export function encounterKindText(t: Translate, kind: EncounterKind): string {
return t("encounters.kind.hospital");
case "THERAPY":
return t("encounters.kind.therapy");
case "PROCEDURE":
return t("encounters.kind.procedure");
default:
return t("encounters.kind.other");
}
@@ -72,3 +82,33 @@ export function encounterStatusText(
return t("encounters.status.noShow");
}
}
export function lateralityText(t: Translate, laterality: Laterality): string {
switch (laterality) {
case "LEFT":
return t("encounters.laterality.left");
case "RIGHT":
return t("encounters.laterality.right");
default:
return t("encounters.laterality.both");
}
}
/**
* The body site as one line: the person's own words, with the side after it
* when one is stated. Null when the visit names no site, so a caller renders
* nothing rather than a lone side.
*/
export function bodySiteText(
t: Translate,
bodySite: string | null,
laterality: Laterality | null,
): string | null {
const site = bodySite?.trim();
if (!site) return null;
if (!laterality) return site;
return t("encounters.bodySiteWithSide", {
site,
side: lateralityText(t, laterality),
});
}
@@ -67,6 +67,7 @@ export function EncounterSheet({
encounter,
reminderId,
episodeId,
kind,
onSaved,
}: {
open: boolean;
@@ -77,6 +78,8 @@ export function EncounterSheet({
reminderId?: string | null;
/** A condition to pre-link, when the sheet was opened from an episode. */
episodeId?: string | null;
/** The kind a new visit starts as — PROCEDURE from the procedure history. */
kind?: EncounterDraft["kind"];
onSaved?: (saved: Encounter) => void;
}) {
const { t } = useTranslations();
@@ -92,6 +95,7 @@ export function EncounterSheet({
: emptyDraft({
occurredAt: nowLocalValue(),
episodeIds: episodeId ? [episodeId] : [],
...(kind ? { kind } : {}),
}),
);
const [error, setError] = useState<string | null>(null);
@@ -0,0 +1,231 @@
"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")}
actions={addButton}
/>
{list.isError ? (
<QueryErrorCard
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}
ctaSize="lg"
/>
) : (
<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>
);
}
+34 -3
View File
@@ -23,7 +23,11 @@ import { Card, CardContent } from "@/components/ui/card";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Encounter } from "@/hooks/use-encounters";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
import { encounterKindText, encounterStatusText } from "./encounter-labels";
import {
bodySiteText,
encounterKindText,
encounterStatusText,
} from "./encounter-labels";
/** The two statuses worth calling out on a row; the rest the section implies. */
function isNoteworthy(status: EncounterStatus): boolean {
@@ -67,9 +71,23 @@ export function VisitCard({
// The practice, or the kind when there is none. A visit with neither is not
// nameless — the kind always resolves, because it defaults to one.
//
// A procedure leads with what was done instead: in a surgical history the
// operation is the thing being scanned for, and a column of rows headed
// "Procedure or surgery" beside a badge saying the same would say nothing.
// The practice then moves to its own line, still foreground.
const isProcedure = encounter.kind === "PROCEDURE";
const procedureHeading = isProcedure ? encounter.reason : null;
const heading =
procedureHeading ??
encounter.practitioner?.name ??
encounterKindText(t, encounter.kind as EncounterKind);
const reasonLine = procedureHeading ? null : encounter.reason;
const practiceLine = procedureHeading
? (encounter.practitioner?.name ?? null)
: null;
// Where on the body, with the side — what the person wrote, so foreground.
const site = bodySiteText(t, encounter.bodySite, encounter.laterality);
return (
<Card
@@ -97,9 +115,22 @@ export function VisitCard({
</Badge>
</div>
{encounter.reason ? (
{reasonLine ? (
<p className="text-foreground line-clamp-1 text-sm">{reasonLine}</p>
) : null}
{site ? (
<p
className="text-foreground line-clamp-1 text-sm"
data-slot="visit-card-body-site"
>
{site}
</p>
) : null}
{practiceLine ? (
<p className="text-foreground line-clamp-1 text-sm">
{encounter.reason}
{practiceLine}
</p>
) : null}
@@ -192,8 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "body")).toBe("0/12");
expect(groupChip(html, "labs")).toBe("0/1");
expect(groupChip(html, "medications")).toBe("0/4");
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
expect(groupChip(html, "history")).toBe("0/4");
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
expect(groupChip(html, "history")).toBe("0/5");
expect(groupChip(html, "cardio")).toBe("0/14");
expect(groupChip(html, "activity")).toBe("0/8");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
@@ -44,6 +44,8 @@ function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
practitioner: null,
reason: null,
outcome: null,
bodySite: null,
laterality: null,
reminderNextDueAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),