mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'feat/procedure-history' into release/v1.39.1
This commit is contained in:
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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user