mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(visits): record procedures and surgeries with a body site
A visit can be filed as a procedure or surgery (new EncounterKind PROCEDURE) and carry a body site in the person's own words, encrypted at rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit switches to the new kind by an ordinary edit, keeping its date, reason, outcome and links. - migration 0349: enum value, laterality type, two nullable columns - GET /api/encounters/procedures: the DONE procedures, newest first, searched after the decrypt over site, side and reason, with body-site choices and a total computed over the whole history - create and edit accept bodySite and laterality on any kind - /checkups gains a Procedures tab with a body-site filter; the visit form offers the site for a procedure and keeps it in view on any visit that carries one - backup and restore carry both fields; the body site is registered as ciphertext and rotates with the key - MCP get_visits narrows by kind and reads the whole record for procedures when no window is named; the Coach visit block names a procedure's site - i18n in all seven locales, OpenAPI regenerated Refs #1025
This commit is contained in:
1 parent
ee23409b77
commit
ad0d50fa28
43 files changed
+2486
-61
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",
|
||||
|
||||
@@ -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,178 @@
|
||||
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("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 {
|
||||
@@ -70,6 +74,8 @@ export function VisitCard({
|
||||
const heading =
|
||||
encounter.practitioner?.name ??
|
||||
encounterKindText(t, encounter.kind as EncounterKind);
|
||||
// Where on the body, with the side — what the person wrote, so foreground.
|
||||
const site = bodySiteText(t, encounter.bodySite, encounter.laterality);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -103,6 +109,15 @@ export function VisitCard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{site ? (
|
||||
<p
|
||||
className="text-foreground line-clamp-1 text-sm"
|
||||
data-slot="visit-card-body-site"
|
||||
>
|
||||
{site}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isNoteworthy(encounter.status as EncounterStatus) ||
|
||||
counts.length ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -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