mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
/checkups gains a second section rather than a nav entry. The page already owns the right sentence — when do I have to do what, where — and is deliberately not module gated, which is the same reason a visit carries no module key of its own: what a visit may point AT follows the modules that own those things, so the gate sits on the three link pickers and blanks the block rather than rendering an empty one. A visit saves with a date and nothing else. No required practitioner, no required kind, no required link, and the only thing that can disable Save is a date that will not parse. The status is derived from the date on a new visit and only becomes a field on an existing one, because somebody booking an appointment should not also have to tell the app that it is in the future. The address book is record content, not configuration, so it sits under /checkups rather than in Settings — and it inherits that page's route-family classification with it. Deleting an entry says in one sentence that the old visits stay, because a person deleting a doctor has every reason to fear otherwise. Two defects closed on the way: - /checkups carried sharedRecord: true and appeared in neither route family, so a scoped grant could not present it at all. It was invisible while a whole-record grant admitted every path; the visits section makes it load bearing, and the page is now listed under both domains whose content it shows. Deduplicated, or a grant holding both would offer the page twice. - the address-book search read the name only, so looking a practice up by the name on the door returned nothing and read as "not in the book". The practitioner search description in the OpenAPI module moved with it.
206 lines
5.9 KiB
TypeScript
206 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Add or correct one address-book entry.
|
|
*
|
|
* Six plaintext-ish fields and one encrypted note. `name` is the only required
|
|
* one, for the same reason the visit only requires a date: a person typing a
|
|
* practice into a form after a doctor's appointment should not be stopped by a
|
|
* phone number they do not have to hand.
|
|
*
|
|
* Reachable two ways — from the address-book page and nested inside the visit
|
|
* form's picker — so it owns no navigation of its own and reports what it
|
|
* created back to whoever opened it.
|
|
*/
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { FieldGroup } from "@/components/ui/field-group";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import {
|
|
usePractitionerMutations,
|
|
type Practitioner,
|
|
type PractitionerWriteBody,
|
|
} from "@/hooks/use-practitioners";
|
|
|
|
interface Draft {
|
|
name: string;
|
|
specialty: string;
|
|
practice: string;
|
|
location: string;
|
|
phone: string;
|
|
note: string;
|
|
}
|
|
|
|
const EMPTY: Draft = {
|
|
name: "",
|
|
specialty: "",
|
|
practice: "",
|
|
location: "",
|
|
phone: "",
|
|
note: "",
|
|
};
|
|
|
|
function draftFrom(row: Practitioner | null): Draft {
|
|
if (!row) return EMPTY;
|
|
return {
|
|
name: row.name,
|
|
specialty: row.specialty ?? "",
|
|
practice: row.practice ?? "",
|
|
location: row.location ?? "",
|
|
phone: row.phone ?? "",
|
|
note: row.note ?? "",
|
|
};
|
|
}
|
|
|
|
/** An empty field clears the column rather than writing an empty string. */
|
|
function orNull(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
export function PractitionerSheet({
|
|
open,
|
|
onOpenChange,
|
|
practitioner,
|
|
onSaved,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** `null` creates; a row edits it. */
|
|
practitioner: Practitioner | null;
|
|
onSaved?: (row: Practitioner) => void;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const { create, update } = usePractitionerMutations();
|
|
// Seeded once, at mount. The caller varies this component's `key` on every
|
|
// open, so a second edit never shows the first one's values and a create
|
|
// after an edit starts empty — without an effect that copies props into
|
|
// state and briefly renders the stale pair.
|
|
const [draft, setDraft] = useState<Draft>(() => draftFrom(practitioner));
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const pending = create.isPending || update.isPending;
|
|
const nameGiven = draft.name.trim().length > 0;
|
|
|
|
const submit = async () => {
|
|
if (!nameGiven) return;
|
|
const body: PractitionerWriteBody = {
|
|
name: draft.name.trim(),
|
|
specialty: orNull(draft.specialty),
|
|
practice: orNull(draft.practice),
|
|
location: orNull(draft.location),
|
|
phone: orNull(draft.phone),
|
|
note: orNull(draft.note),
|
|
};
|
|
try {
|
|
const saved = practitioner
|
|
? await update.mutateAsync({ id: practitioner.id, body })
|
|
: await create.mutateAsync(body);
|
|
onSaved?.(saved);
|
|
onOpenChange(false);
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : t("practitioners.saveFailed");
|
|
setError(message);
|
|
toast.error(message);
|
|
}
|
|
};
|
|
|
|
const field = (key: keyof Draft) => ({
|
|
value: draft[key],
|
|
onChange: (
|
|
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
) => setDraft((prev) => ({ ...prev, [key]: event.target.value })),
|
|
});
|
|
|
|
return (
|
|
<ResponsiveSheet
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={t(
|
|
practitioner ? "practitioners.editTitle" : "practitioners.createTitle",
|
|
)}
|
|
description={t("practitioners.formDescription")}
|
|
footer={
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={!nameGiven || pending}
|
|
onClick={() => void submit()}
|
|
data-slot="practitioner-save"
|
|
className="min-h-11"
|
|
>
|
|
{t("common.save")}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4" data-slot="practitioner-form">
|
|
<FieldGroup htmlFor="practitioner-name" label={t("practitioners.name")}>
|
|
<Input
|
|
id="practitioner-name"
|
|
required
|
|
aria-required="true"
|
|
autoComplete="off"
|
|
{...field("name")}
|
|
/>
|
|
</FieldGroup>
|
|
|
|
<FieldGroup
|
|
htmlFor="practitioner-specialty"
|
|
label={t("practitioners.specialty")}
|
|
>
|
|
<Input id="practitioner-specialty" {...field("specialty")} />
|
|
</FieldGroup>
|
|
|
|
<FieldGroup
|
|
htmlFor="practitioner-practice"
|
|
label={t("practitioners.practice")}
|
|
>
|
|
<Input id="practitioner-practice" {...field("practice")} />
|
|
</FieldGroup>
|
|
|
|
<FieldGroup
|
|
htmlFor="practitioner-location"
|
|
label={t("practitioners.location")}
|
|
>
|
|
<Input id="practitioner-location" {...field("location")} />
|
|
</FieldGroup>
|
|
|
|
<FieldGroup
|
|
htmlFor="practitioner-phone"
|
|
label={t("practitioners.phone")}
|
|
>
|
|
<Input id="practitioner-phone" type="tel" {...field("phone")} />
|
|
</FieldGroup>
|
|
|
|
<FieldGroup
|
|
htmlFor="practitioner-note"
|
|
label={t("practitioners.note")}
|
|
hint={t("practitioners.noteHint")}
|
|
>
|
|
<Textarea id="practitioner-note" rows={3} {...field("note")} />
|
|
</FieldGroup>
|
|
|
|
{error ? (
|
|
<p role="alert" className="text-destructive text-sm">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</ResponsiveSheet>
|
|
);
|
|
}
|