mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The visit form.
|
|
*
|
|
* **A visit saves with a date and nothing else.** No required practitioner, no
|
|
* required kind, no required link, and no Save button disabled until something
|
|
* is picked. That is the whole product constraint this feature exists to hold:
|
|
* a required field on a form somebody fills after a doctor's appointment is the
|
|
* bureaucracy it was built to avoid. The only thing that can block Save is a
|
|
* missing or unparseable date, because a visit with no instant is not a record
|
|
* of anything.
|
|
*
|
|
* Status is DERIVED from the date on a new visit, never asked: a future instant
|
|
* means PLANNED and a past one means DONE. A person booking an appointment
|
|
* should not have to also tell the app that it is in the future. On an existing
|
|
* visit the status becomes editable, because moving a planned visit to done,
|
|
* cancelled or a no-show is the transition that closes a checkup.
|
|
*
|
|
* The three link pickers are gated by the modules that own what they point at,
|
|
* and the gate BLANKS the block rather than post-filtering it — an empty
|
|
* picker for a module the account turned off advertises a feature that is not
|
|
* there.
|
|
*/
|
|
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";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
|
|
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";
|
|
|
|
export interface EncounterDraft {
|
|
/** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */
|
|
occurredAt: string;
|
|
status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
|
kind: string;
|
|
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[];
|
|
}
|
|
|
|
function localValue(instant: Date): string {
|
|
const offset = instant.getTimezoneOffset();
|
|
return new Date(instant.getTime() - offset * 60_000)
|
|
.toISOString()
|
|
.slice(0, 16);
|
|
}
|
|
|
|
export function nowLocalValue(): string {
|
|
return localValue(new Date());
|
|
}
|
|
|
|
/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */
|
|
export function draftInstant(value: string): string | null {
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
}
|
|
|
|
export function emptyDraft(
|
|
overrides?: Partial<EncounterDraft>,
|
|
): EncounterDraft {
|
|
return {
|
|
occurredAt: nowLocalValue(),
|
|
status: "DONE",
|
|
kind: "OTHER",
|
|
practitioner: null,
|
|
reason: "",
|
|
outcome: "",
|
|
bodySite: "",
|
|
laterality: null,
|
|
documentIds: [],
|
|
labResultIds: [],
|
|
episodeIds: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export function draftFromEncounter(row: Encounter): EncounterDraft {
|
|
return {
|
|
occurredAt: localValue(new Date(row.occurredAt)),
|
|
status: row.status,
|
|
kind: row.kind,
|
|
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) ?? [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The status a NEW visit carries, read off its own date.
|
|
*
|
|
* Exported so the sheet and its tests agree on the derivation rather than each
|
|
* spelling out the comparison.
|
|
*/
|
|
export function derivedStatus(
|
|
occurredAt: string,
|
|
now = Date.now(),
|
|
): "PLANNED" | "DONE" {
|
|
const parsed = new Date(occurredAt).getTime();
|
|
return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE";
|
|
}
|
|
|
|
/** The body a draft becomes, with the empty strings turned into absences. */
|
|
export function draftToBody(
|
|
draft: EncounterDraft,
|
|
options: { isEdit: boolean; reminderId?: string | null },
|
|
): EncounterWriteBody | null {
|
|
const occurredAt = draftInstant(draft.occurredAt);
|
|
if (!occurredAt) return null;
|
|
return {
|
|
occurredAt,
|
|
status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt),
|
|
kind: draft.kind,
|
|
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,
|
|
episodeIds: draft.episodeIds,
|
|
};
|
|
}
|
|
|
|
export function EncounterForm({
|
|
draft,
|
|
onChange,
|
|
isEdit,
|
|
}: {
|
|
draft: EncounterDraft;
|
|
onChange: (next: EncounterDraft) => void;
|
|
isEdit: boolean;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const { user } = useAuth();
|
|
const patch = (part: Partial<EncounterDraft>) =>
|
|
onChange({ ...draft, ...part });
|
|
|
|
const planned = useMemo(
|
|
() =>
|
|
isEdit
|
|
? draft.status === "PLANNED"
|
|
: derivedStatus(draft.occurredAt) === "PLANNED",
|
|
[isEdit, draft.status, draft.occurredAt],
|
|
);
|
|
|
|
// The outcome field starts collapsed on a planned visit: there is nothing to
|
|
// write about a thing that has not happened. Derived rather than
|
|
// synchronised — a visit that already carries an outcome is never collapsed
|
|
// over it, and expressing that as an effect would be a second source of
|
|
// truth for something the draft already knows.
|
|
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
|
|
htmlFor="encounter-occurred-at"
|
|
label={t("encounters.form.date")}
|
|
hint={isEdit ? undefined : t("encounters.form.dateDerivesStatusHint")}
|
|
>
|
|
<DateTimeField
|
|
id="encounter-occurred-at"
|
|
value={draft.occurredAt}
|
|
onChange={(value) => patch({ occurredAt: value })}
|
|
required
|
|
aria-required="true"
|
|
data-testid="encounter-occurred-at"
|
|
/>
|
|
</FieldGroup>
|
|
|
|
{isEdit ? (
|
|
<FieldGroup
|
|
htmlFor="encounter-status"
|
|
label={t("encounters.form.status")}
|
|
>
|
|
<NativeSelect
|
|
id="encounter-status"
|
|
value={draft.status}
|
|
onChange={(event) =>
|
|
patch({
|
|
status: event.target.value as EncounterDraft["status"],
|
|
})
|
|
}
|
|
>
|
|
{ENCOUNTER_STATUSES.map((status) => (
|
|
<option key={status} value={status}>
|
|
{encounterStatusText(t, status)}
|
|
</option>
|
|
))}
|
|
</NativeSelect>
|
|
</FieldGroup>
|
|
) : null}
|
|
|
|
<FieldGroup
|
|
htmlFor="encounter-practitioner"
|
|
label={t("encounters.form.practitioner")}
|
|
hint={t("encounters.form.practitionerHint")}
|
|
>
|
|
<PractitionerCombobox
|
|
value={draft.practitioner?.id ?? null}
|
|
selected={draft.practitioner}
|
|
onChange={(next) => patch({ practitioner: next })}
|
|
/>
|
|
</FieldGroup>
|
|
|
|
<FieldGroup htmlFor="encounter-kind" label={t("encounters.form.kind")}>
|
|
<NativeSelect
|
|
id="encounter-kind"
|
|
value={draft.kind}
|
|
onChange={(event) => patch({ kind: event.target.value })}
|
|
>
|
|
{ENCOUNTER_KINDS.map((kind) => (
|
|
<option key={kind} value={kind}>
|
|
{encounterKindText(t, kind)}
|
|
</option>
|
|
))}
|
|
</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")}
|
|
>
|
|
<Textarea
|
|
id="encounter-reason"
|
|
rows={2}
|
|
value={draft.reason}
|
|
onChange={(event) => patch({ reason: event.target.value })}
|
|
/>
|
|
</FieldGroup>
|
|
|
|
{outcomeOpen ? (
|
|
<FieldGroup
|
|
htmlFor="encounter-outcome"
|
|
label={t("encounters.form.outcome")}
|
|
>
|
|
<Textarea
|
|
id="encounter-outcome"
|
|
rows={3}
|
|
value={draft.outcome}
|
|
onChange={(event) => patch({ outcome: event.target.value })}
|
|
/>
|
|
</FieldGroup>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11"
|
|
data-slot="encounter-outcome-reveal"
|
|
onClick={() => setRevealed(true)}
|
|
>
|
|
{t("encounters.form.outcomeReveal")}
|
|
</Button>
|
|
)}
|
|
|
|
<EncounterLinkPickers
|
|
modules={user?.modules}
|
|
anchor={draft.occurredAt || null}
|
|
documentIds={draft.documentIds}
|
|
labResultIds={draft.labResultIds}
|
|
episodeIds={draft.episodeIds}
|
|
onChange={patch}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|