mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(labs): add "save & add another" to the manual result form
A real lab report is 10-20 analytes sharing one blood-draw date, usually in the past; the add form closed on every save, resetting takenAt to now and clearing the biomarker pick, so entering a full panel meant re-setting the date away from now on every single row. The new button keeps the sheet open, clears the reading, and deliberately preserves takenAt (and the biomarker pick when not locked to one detail page) so the next analyte in the same panel just needs a pick + a value.
This commit is contained in:
1 parent
b00450aec8
commit
a2f926576e
9 files changed
+106
-8
No files matched your search
@@ -7397,6 +7397,7 @@
|
||||
"notePlaceholder": "Optionaler Kontext zu diesem Wert",
|
||||
"requiredError": "Bitte einen Wert eingeben.",
|
||||
"save": "Speichern",
|
||||
"saveAndAddAnother": "Speichern & nächsten Wert",
|
||||
"savedToast": "Wert gespeichert",
|
||||
"saveError": "Wert konnte nicht gespeichert werden",
|
||||
"biomarker": "Biomarker",
|
||||
|
||||
@@ -7397,6 +7397,7 @@
|
||||
"notePlaceholder": "Optional context for this result",
|
||||
"requiredError": "Enter a value.",
|
||||
"save": "Save",
|
||||
"saveAndAddAnother": "Save & add another",
|
||||
"savedToast": "Result saved",
|
||||
"saveError": "Could not save the result",
|
||||
"biomarker": "Biomarker",
|
||||
|
||||
@@ -7397,6 +7397,7 @@
|
||||
"notePlaceholder": "Contexto opcional para este resultado",
|
||||
"requiredError": "Introduce un valor.",
|
||||
"save": "Guardar",
|
||||
"saveAndAddAnother": "Guardar y añadir otro",
|
||||
"savedToast": "Resultado guardado",
|
||||
"saveError": "No se pudo guardar el resultado",
|
||||
"biomarker": "Biomarcador",
|
||||
|
||||
@@ -7397,6 +7397,7 @@
|
||||
"notePlaceholder": "Contexte facultatif pour ce résultat",
|
||||
"requiredError": "Saisissez une valeur.",
|
||||
"save": "Enregistrer",
|
||||
"saveAndAddAnother": "Enregistrer et ajouter un autre",
|
||||
"savedToast": "Résultat enregistré",
|
||||
"saveError": "Impossible d'enregistrer le résultat",
|
||||
"biomarker": "Biomarqueur",
|
||||
|
||||
@@ -7397,6 +7397,7 @@
|
||||
"notePlaceholder": "Contesto facoltativo per questo risultato",
|
||||
"requiredError": "Inserisci un valore.",
|
||||
"save": "Salva",
|
||||
"saveAndAddAnother": "Salva e aggiungi un altro",
|
||||
"savedToast": "Risultato salvato",
|
||||
"saveError": "Impossibile salvare il risultato",
|
||||
"biomarker": "Biomarcatore",
|
||||
|
||||
@@ -7397,6 +7397,7 @@
|
||||
"notePlaceholder": "Opcjonalny kontekst tego wyniku",
|
||||
"requiredError": "Podaj wartość.",
|
||||
"save": "Zapisz",
|
||||
"saveAndAddAnother": "Zapisz i dodaj kolejny",
|
||||
"savedToast": "Wynik zapisany",
|
||||
"saveError": "Nie udało się zapisać wyniku",
|
||||
"biomarker": "Biomarker",
|
||||
|
||||
@@ -177,6 +177,15 @@ export default function LabsPage() {
|
||||
queryKey: queryKeys.labResults(),
|
||||
});
|
||||
}}
|
||||
// v1.30.1 H3 — "Save & add another" keeps the sheet open; the
|
||||
// form clears the reading itself, this callback just refreshes
|
||||
// the list underneath so the just-saved row is there once the
|
||||
// user is done and closes the sheet.
|
||||
onSavedKeepOpen={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.labResults(),
|
||||
});
|
||||
}}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
@@ -230,6 +230,13 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
}
|
||||
|
||||
// v1.30.1 H3 — "Save & add another": same invalidation as `afterAdd`,
|
||||
// minus the close. Useful here too — logging several past readings for
|
||||
// this one biomarker back-to-back without re-opening the sheet each time.
|
||||
function afterAddKeepOpen() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
}
|
||||
|
||||
function afterEditMarker() {
|
||||
setEditOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
@@ -482,6 +489,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
lockedBiomarkerId={biomarkerId}
|
||||
footerSlot={addFooterEl}
|
||||
onSuccess={afterAdd}
|
||||
onSavedKeepOpen={afterAddKeepOpen}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -55,6 +55,17 @@ interface LabFormProps {
|
||||
lockedBiomarkerId?: string;
|
||||
onSuccess?: (created: LabResultDto) => void;
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* v1.30.1 (H3 QoL fix) — "Save & add another": the sheet stays open, the
|
||||
* reading is cleared for the next entry, and `takenAt` is PRESERVED (a
|
||||
* real lab report is 10-20 analytes sharing one blood-draw date, usually
|
||||
* in the past — re-picking that date for every row was the pain point).
|
||||
* Optional and additive: the second button only renders when the caller
|
||||
* wires this up, so a consumer that never passes it keeps the single-Save
|
||||
* form unchanged. The caller's only job is invalidating the list read —
|
||||
* the form itself, not the caller, owns the "stay open + reset" behaviour.
|
||||
*/
|
||||
onSavedKeepOpen?: (created: LabResultDto) => void;
|
||||
/**
|
||||
* When mounted inside a `<ResponsiveSheet>` the caller passes the sheet's
|
||||
* footer slot element here. The Cancel / Save action row is portalled into
|
||||
@@ -79,6 +90,7 @@ export function LabForm({
|
||||
lockedBiomarkerId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onSavedKeepOpen,
|
||||
footerSlot,
|
||||
}: LabFormProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -103,12 +115,19 @@ export function LabForm({
|
||||
const [valueText, setValueText] = useState("");
|
||||
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// v1.30.1 H3 — which submit button is in flight, so the OTHER one doesn't
|
||||
// also show a spinner. `!== null` replaces the old plain `submitting`
|
||||
// boolean for every disabled check below.
|
||||
const [pendingAction, setPendingAction] = useState<
|
||||
"save" | "saveAndAddAnother" | null
|
||||
>(null);
|
||||
const submitting = pendingAction !== null;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [defineOpen, setDefineOpen] = useState(false);
|
||||
const [defineFooterEl, setDefineFooterEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
const biomarkerTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const allMarkers = catalog?.biomarkers ?? [];
|
||||
const selected = allMarkers.find((m) => m.id === biomarkerId);
|
||||
@@ -132,10 +151,20 @@ export function LabForm({
|
||||
setBiomarkerId(created.id);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// v1.30.1 H3 — both Save and Save & add another are `type="submit"`
|
||||
// buttons tied to this one `<form>` via the HTML `form` attribute (they
|
||||
// render portalled into the sheet's footer, outside the `<form>` tag
|
||||
// itself); `submitter` distinguishes which one fired this submit.
|
||||
const submitter = (e.nativeEvent as SubmitEvent)
|
||||
.submitter as HTMLButtonElement | null;
|
||||
const keepOpen =
|
||||
onSavedKeepOpen != null &&
|
||||
submitter?.dataset.action === "save-and-add-another";
|
||||
|
||||
if (!biomarkerId) {
|
||||
setError(t("labs.form.pickBiomarkerError"));
|
||||
return;
|
||||
@@ -159,7 +188,7 @@ export function LabForm({
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setPendingAction(keepOpen ? "saveAndAddAnother" : "save");
|
||||
try {
|
||||
const created = await apiPost<LabResultDto>("/api/labs", {
|
||||
biomarkerId,
|
||||
@@ -170,13 +199,38 @@ export function LabForm({
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
});
|
||||
toast.success(t("labs.form.savedToast"));
|
||||
onSuccess?.(created);
|
||||
if (keepOpen) {
|
||||
// A real lab report shares one blood-draw date across every
|
||||
// analyte — clear the reading but deliberately KEEP `takenAt` so
|
||||
// the next row doesn't need it re-set away from "now" again.
|
||||
// The biomarker resets too (unless locked to one detail page)
|
||||
// since the whole point is entering the NEXT analyte.
|
||||
if (!lockedBiomarkerId) setBiomarkerId("");
|
||||
setValue("");
|
||||
setValueText("");
|
||||
setNote("");
|
||||
setError(null);
|
||||
onSavedKeepOpen?.(created);
|
||||
// Return focus to wherever the next entry starts so the flow
|
||||
// stays keyboard/screen-reader friendly across repeated saves.
|
||||
if (!lockedBiomarkerId) {
|
||||
biomarkerTriggerRef.current?.focus();
|
||||
} else {
|
||||
document
|
||||
.getElementById(
|
||||
resultType === "numeric" ? "lab-value" : "lab-valueText",
|
||||
)
|
||||
?.focus();
|
||||
}
|
||||
} else {
|
||||
onSuccess?.(created);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : t("labs.form.saveError");
|
||||
setError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +254,25 @@ export function LabForm({
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
{/* v1.30.1 H3 — additive: only renders when the caller wires up
|
||||
`onSavedKeepOpen`. `data-action` is how `handleSubmit` tells this
|
||||
button apart from the plain Save below via `event.submitter`. */}
|
||||
{onSavedKeepOpen ? (
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="outline"
|
||||
data-action="save-and-add-another"
|
||||
disabled={submitting}
|
||||
>
|
||||
{pendingAction === "saveAndAddAnother" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("labs.form.saveAndAddAnother")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" form={formId} disabled={submitting}>
|
||||
{submitting ? (
|
||||
{pendingAction === "save" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("labs.form.save")}
|
||||
@@ -219,7 +290,11 @@ export function LabForm({
|
||||
onValueChange={handleSelect}
|
||||
disabled={!!lockedBiomarkerId || catalogLoading}
|
||||
>
|
||||
<SelectTrigger id="lab-biomarker" className="w-full">
|
||||
<SelectTrigger
|
||||
id="lab-biomarker"
|
||||
ref={biomarkerTriggerRef}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder={t("labs.form.biomarkerPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
Reference in new issue
Block a user