feat(encounters): the lab moment, the condition side, and closing a checkup

A lab panel files itself against the visit it came from, and never has to. The
OCR review and the manual form both offer the suggestion through the one shared
field, anchored on the draw date rather than on now, so backdating a reading
moves the offer with it. The commit writes one link PER RESULT ROW, which is
why the join is m:n: a marker re-run on a different day belongs to its own
visit. Best-effort by design — the readings are the write that matters and must
not be lost to a link that could not be made.

The condition side gets a Visits about this card with an action that pre-links
the episode. From the episode side only, and that is a decision: an episode
spans weeks and a visit is a point, so matching the other way would be wrong
often enough to teach a person to ignore every suggestion in the product.

A due checkup gains a second action beside erledigt. Filing the visit as done
satisfies the checkup through the same forward-only, idempotent primitive the
checkbox uses, so doing both is one satisfaction and no error. The two actions
answer different questions — one says it happened, the other says where.

An appointment now appears where a person looks, and NEITHER surface relaxes
the reminder-table exclusion that keeps it out of the preventive-care lists.
Both read the visit table: a quiet line under the dashboard checkup block for
anything inside two weeks, and one rail item for today or tomorrow. A diff in
either that touches origin is the wrong fix.

A visit write now also refreshes the reminder reads. Closing a checkup moves
its next-due date on three surfaces that would otherwise keep showing it due
until their own poll.

OpenAPI regenerated in the same commit.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 14:08:09 +02:00
1 parent be9c79974f
commit c47122ccfe
36 files changed
+924 -192

No files matched your search

@@ -1041,9 +1041,8 @@ export function DocumentDetailSheet({
doc.encounterLinks.length > 0 && pickedEncounterId === null
}
disabled={!canManage}
pickedId={pickedEncounterId}
onPick={(id) => setSuggestedEncounter(id)}
onUndo={() => setSuggestedEncounter(null)}
value={pickedEncounterId}
onChange={setSuggestedEncounter}
/>
{/* "Belongs to visit" — read-only here on purpose. The filing
@@ -1,147 +1,43 @@
"use client";
/**
* "Gehört zu Termin …" — the incidental link, offered at the moment a document
* is being looked at rather than as a filing chore afterwards.
* The document moment of the visit suggestion.
*
* The rule is not implemented here. It lives in `suggest-window.ts` and is
* resolved server-side, so this surface, the lab review and the manual lab form
* cannot answer the same question differently. What this component owns is only
* how the three verdicts LOOK:
* The rule and the rendering both live elsewhere — `suggest-window.ts` on the
* server and `EncounterSuggestionField` in the browser — so this file is only
* the wiring: which date to anchor on, and where the chosen id goes.
*
* one candidate → pre-selected, visibly, with one undo;
* two or more → a picker with NOTHING pre-selected;
* none → nothing at all. No empty picker, no "create a visit?" nudge.
* The anchor is `reportDate ?? documentDate`. `reportDate` is what the model
* transcribed off the letter itself and is never inferred, so it is the truer
* of the two; `documentDate` is the person's own filing date and is the
* fallback.
*
* It never blocks anything, it is never behind an AI call — the match is a
* date-window query over the person's own rows and resolves on an account with
* no provider configured — and it renders nothing once the document already
* names a visit: an offer to file something already filed is noise.
*
* The row is a content sentence, not meta, so it is `text-foreground`.
* It renders nothing once the document already names a visit that this session
* did not choose: an offer to file something already filed is noise.
*/
import { CalendarClock, Check, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useEncounterSuggestion } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { EncounterKind } from "@/generated/prisma/client";
import type { EncounterSuggestion } from "@/lib/encounters/suggest-window";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
export function DocumentEncounterSuggestion({
anchor,
alreadyLinked,
disabled,
onPick,
pickedId,
onUndo,
value,
onChange,
}: {
/** `reportDate ?? documentDate`, ISO-8601, or null when the document has none. */
anchor: string | null;
/** The document already names a visit — there is nothing to offer. */
alreadyLinked: boolean;
disabled?: boolean;
onPick: (encounterId: string) => void;
/** The visit this surface has just filed against, for the undo affordance. */
pickedId: string | null;
onUndo: () => void;
value: string | null;
onChange: (encounterId: string | null) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const suggestion = useEncounterSuggestion(
anchor,
!alreadyLinked && !disabled,
);
if (alreadyLinked || disabled) return null;
const result = suggestion.data;
if (!result || result.kind === "none") return null;
const label = (candidate: EncounterSuggestion) =>
`${
candidate.practitionerName ??
encounterKindText(t, candidate.kind as EncounterKind)
} · ${format.date(candidate.occurredAt)}`;
// ── One candidate: pre-selected, and undoable in one tap ────────────────
if (result.kind === "one") {
const candidate = result.encounter;
const applied = pickedId === candidate.id;
return (
<div
className="space-y-1.5"
data-slot="document-encounter-suggestion"
data-verdict="one"
>
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t("documents.detail.visitSuggestion")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
variant={applied ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={applied}
data-slot="document-encounter-suggestion-apply"
onClick={() => onPick(candidate.id)}
>
<Check
className={cn("size-4", applied ? "opacity-100" : "opacity-0")}
aria-hidden
/>
{label(candidate)}
</Button>
{applied ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11"
onClick={onUndo}
>
<Undo2 className="size-4" aria-hidden />
{t("common.undo")}
</Button>
) : null}
</div>
</div>
);
}
// ── Two or more: a picker with nothing pre-selected ─────────────────────
if (alreadyLinked) return null;
return (
<div
className="space-y-1.5"
data-slot="document-encounter-suggestion"
data-verdict="many"
>
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t("documents.detail.visitSuggestionChoose")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{result.encounters.map((candidate) => {
const applied = pickedId === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={applied ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={applied}
data-slot="document-encounter-suggestion-option"
onClick={() => (applied ? onUndo() : onPick(candidate.id))}
>
{label(candidate)}
</Button>
);
})}
</div>
</div>
<EncounterSuggestionField
anchor={anchor}
value={value}
onChange={onChange}
disabled={disabled}
slot="document-encounter-suggestion"
/>
);
}