mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
306 lines
9.3 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|