mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user