Files
HealthLog/src/components/labs/lab-form.tsx
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

306 lines
9.3 KiB
TypeScript

"use client";
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { DateTimeInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import type {
BiomarkerDto,
BiomarkerListResponse,
LabResultDto,
} from "./types";
const NOTE_MAX_LENGTH = 2000;
const DEFINE_NEW = "__define_new__";
function defaultTakenAtValue() {
const now = new Date();
const offset = now.getTimezoneOffset();
const local = new Date(now.getTime() - offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}
/** Parse a free-text decimal that may use a comma separator. */
function parseDecimal(raw: string): number | null {
const trimmed = raw.trim().replace(",", ".");
if (trimmed === "") return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
interface LabFormProps {
/** When set, the biomarker is pre-selected and locked (add-from-detail). */
lockedBiomarkerId?: string;
onSuccess?: (created: LabResultDto) => void;
onCancel?: () => void;
/**
* When mounted inside a `<ResponsiveSheet>` the caller passes the sheet's
* footer slot element here. The Cancel / Save action row is portalled into
* it so the bottom-sheet branch can sticky-pin it above the keyboard; the
* Save button stays tied to the `<form>` via the HTML `form` attribute so
* submit-on-Enter and portalled-click both still submit.
*/
footerSlot?: HTMLElement | null;
}
/**
* v1.18.1 — structured lab-result entry.
*
* The error-prone free-text path is gone: the user PICKS a biomarker from the
* catalog, then enters only the value against its known unit + reference range
* (resolved server-side). A "+ define new" row opens the marker-definition
* sheet inline and returns with it selected. Panel / unit / range are NOT
* re-entered per reading — they live on the biomarker. Only the per-reading
* note stays optional here.
*/
export function LabForm({
lockedBiomarkerId,
onSuccess,
onCancel,
footerSlot,
}: LabFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const { data: catalog, isLoading: catalogLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
});
const [biomarkerId, setBiomarkerId] = useState<string>(
lockedBiomarkerId ?? "",
);
const [value, setValue] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [defineOpen, setDefineOpen] = useState(false);
const [defineFooterEl, setDefineFooterEl] = useState<HTMLDivElement | null>(
null,
);
const markers = catalog?.biomarkers ?? [];
const selected = markers.find((m) => m.id === biomarkerId);
function handleSelect(next: string) {
if (next === DEFINE_NEW) {
setDefineOpen(true);
return;
}
setBiomarkerId(next);
setError(null);
}
function afterDefine(created: BiomarkerDto) {
setDefineOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
setBiomarkerId(created.id);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const numericValue = parseDecimal(value);
if (!biomarkerId) {
setError(t("labs.form.pickBiomarkerError"));
return;
}
if (numericValue === null) {
setError(t("labs.form.requiredError"));
return;
}
setSubmitting(true);
try {
const created = await apiPost<LabResultDto>("/api/labs", {
biomarkerId,
value: numericValue,
takenAt: new Date(takenAt).toISOString(),
...(note.trim() ? { note: note.trim() } : {}),
});
toast.success(t("labs.form.savedToast"));
onSuccess?.(created);
} catch (err) {
const message =
err instanceof ApiError ? err.message : t("labs.form.saveError");
setError(message);
} finally {
setSubmitting(false);
}
}
const referenceText = selected
? formatReferenceRange(
selected.lowerBound,
selected.upperBound,
formatLabValue,
)
: "";
const footerNode = (
<>
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
</Button>
</>
);
return (
<>
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
<Select
value={biomarkerId || undefined}
onValueChange={handleSelect}
disabled={!!lockedBiomarkerId || catalogLoading}
>
<SelectTrigger id="lab-biomarker" className="w-full">
<SelectValue placeholder={t("labs.form.biomarkerPlaceholder")} />
</SelectTrigger>
<SelectContent>
{markers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} · {m.unit}
</SelectItem>
))}
{!lockedBiomarkerId ? (
<SelectItem value={DEFINE_NEW}>
{t("labs.form.defineNew")}
</SelectItem>
) : null}
</SelectContent>
</Select>
{markers.length === 0 && !catalogLoading ? (
<p className="text-muted-foreground text-xs">
{t("labs.form.noBiomarkersHint")}
</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-value">
{t("labs.form.value")}
{selected ? (
<span className="text-muted-foreground font-normal">
{" "}
({selected.unit})
</span>
) : null}
</Label>
<Input
id="lab-value"
inputMode="decimal"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.0"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
id="lab-takenAt"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
max={defaultTakenAtValue()}
required
/>
</div>
</div>
{selected && referenceText ? (
<p className="text-muted-foreground text-xs">
{t("labs.referenceLabel")} {referenceText} {selected.unit}
</p>
) : null}
<div className="space-y-1.5">
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
<Textarea
id="lab-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("labs.form.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
rows={2}
/>
</div>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
{/* When no footer slot is supplied (rare — e.g. a non-sheet host) the
action row renders inline; inside a sheet it portals into the
sticky footer. */}
{footerSlot ? null : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
{footerSlot ? createPortal(footerNode, footerSlot) : null}
<ResponsiveSheet
open={defineOpen}
onOpenChange={setDefineOpen}
title={t("labs.biomarker.defineTitle")}
description={t("labs.biomarker.defineDescription")}
footer={
<div
ref={setDefineFooterEl}
className="flex w-full justify-end gap-2"
/>
}
>
<BiomarkerForm
footerSlot={defineFooterEl}
onSuccess={afterDefine}
onCancel={() => setDefineOpen(false)}
/>
</ResponsiveSheet>
</>
);
}