src/components/certifications/certification-dialog.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useCallback, useRef, useState } from "react";
import { useDialogApiError } from "@/hooks/useDialogApiError";
import { FormApiError } from "@/components/ui/form-api-error";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Save } from "lucide-react";
import {
certificationSchema,
CertificationInput,
} from "@/lib/validations/certification";
import {
certificationsAPI,
Certification,
CertificationAgency,
CertificationSide,
CERTIFICATION_SIDE_LABELS,
DEFAULT_CERTIFICATION_AGENCY,
} from "@/lib/api/certifications";
import {
applyCertificationCardEdits,
type CertificationCardEdits,
} from "@/lib/certification-card-edits";
import type { Course } from "@/lib/api/courses";
import { getApiErrorMessage } from "@/lib/api/error";
import { dialogFormSubmit } from "@/lib/dialog-form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { CourseCombobox } from "@/components/courses/course-combobox";
import { TrainingFields } from "@/components/courses/training-fields";
import { splitCourseInstructor } from "@/lib/people";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { DialogSubmitButton } from "@/components/ui/dialog-submit-button";
import { useToast } from "@/components/ui/use-toast";
import { CertificationCardFiles } from "./certification-card-files";
import { useEffectOnChange } from "@/hooks/useEffectOnChange";
// The certification fields a linked course can fill in, in the shape the form
// holds them: `null` and absent both arrive as `""`, which is this form's "not
// set" everywhere else - except the contact and the instructor, pickers whose
// "not set" is `null`.
//
// `notes` is deliberately not among them: a course's notes describe the
// training and a card's describe the card, so it is the one field here whose
// course value would be plausible-but-wrong saved unread.
//
// `name` is among them, and it is the field the guard below matters most for -
// a course name ("TDI Advanced Nitrox + Decompression Procedures") is often
// longer than the level printed on the card, and one course can issue two
// differently-named cards. A diver who types over it keeps what they typed.
interface CertificationFieldValues {
name: string;
agency: CertificationAgency;
agency_other: string;
contact_uuid: string | null;
instructor_uuid: string | null;
instructor_number: string;
}
// The same set as a course holds them. A course need not name an agency and a
// certification must, so that half is nullable here - and a course without one
// contributes nothing to the pair rather than emptying the required field.
type CourseFieldValues = Omit<CertificationFieldValues, "agency"> & {
agency: CertificationAgency | null;
};
// What a course puts in those fields.
function courseFieldValues(course: Course): CourseFieldValues {
return {
name: course.name,
agency: course.agency ?? null,
agency_other: course.agency_other ?? "",
contact_uuid: course.contact_uuid ?? null,
// The course's first instructor: a card is signed by one person.
instructor_uuid: splitCourseInstructor(course.people).instructorUuid,
instructor_number: course.instructor_number ?? "",
};
}
// A prefill is not the diver's own edit, so it must not make the form read as
// dirty - nothing here gates on that today, and an unsaved-changes guard added
// later would otherwise fire on a form nobody typed into.
const AUTOFILL = { shouldDirty: false } as const;
interface CertificationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
// Pass an existing certification to edit it; omit to create a new one.
certification?: Certification | null;
// Opens a *create* dialog already linked to this course and prefilled from
// it - how the course page's sidebar "Add a certification" hands it over.
// Ignored alongside `certification`: editing a card is never a prefill.
initialCourse?: Course;
onSaved: (certification: Certification) => void;
}
// Create/edit dialog for a certification: its details and its card images, in one
// form and one Save.
//
// The details save first, because `PUT /certification/{uuid}/file/{side}` needs a
// uuid a card being created has only once `createCertification` resolves;
// `applyCertificationCardEdits` then sends what the diver picked or struck off.
//
// A dialog rather than `new`/`edit` pages, following the gear precedent: these
// are a handful of fields typed off a card the diver is holding, not a
// multi-section form like a dive.
export function CertificationDialog({
open,
onOpenChange,
certification,
initialCourse,
onSaved,
}: CertificationDialogProps) {
const { toast } = useToast();
const [apiError, setApiError, handleOpenChange] = useDialogApiError(
open,
onOpenChange,
);
// What saving will do to each side's stored image, collected but not sent - see
// `CertificationCardFiles`.
const [cardEdits, setCardEdits] = useState<CertificationCardEdits>({});
const isEdit = !!certification;
const form = useForm<CertificationInput>({
resolver: zodResolver(certificationSchema),
defaultValues: {
agency: DEFAULT_CERTIFICATION_AGENCY,
agency_other: "",
name: "",
certification_number: "",
certified_on: "",
expires_on: "",
instructor_uuid: null,
instructor_number: "",
contact_uuid: null,
notes: "",
course_uuid: null,
},
});
// The pickers rank by last use on a dive at or before the card's date.
const until =
useWatch({ control: form.control, name: "certified_on" }) || undefined;
// What this dialog last put in those fields itself: the values
// it opened with, and then whatever each course selection wrote. A field still
// holding that value is one nobody has typed into, so the next course may
// replace it; anything else is the diver's own and is never overwritten.
//
// Deliberately *not* react-hook-form's `dirtyFields`, which is the obvious
// mechanism and does not survive contact with this form - see DECISIONS.md,
// "A silently prefilled field is not a clean field".
const autofilledRef = useRef<CertificationFieldValues>({
name: "",
agency: DEFAULT_CERTIFICATION_AGENCY,
agency_other: "",
contact_uuid: null,
instructor_uuid: null,
instructor_number: "",
});
// Reload the form whenever the dialog opens, so it shows the certification
// being edited rather than whatever the previous invocation left behind.
const { reset, setValue, getValues } = form;
useEffectOnChange(() => {
if (!open) return;
// A create dialog opened from a course page starts on that course, with its
// fields already filled in. An edit dialog ignores it outright: its values
// are a pure function of the card being edited.
const seed = certification ? undefined : initialCourse;
const from: CourseFieldValues = seed
? courseFieldValues(seed)
: {
name: certification?.name ?? "",
agency: certification?.agency ?? DEFAULT_CERTIFICATION_AGENCY,
agency_other: certification?.agency_other ?? "",
contact_uuid: certification?.contact_uuid ?? null,
instructor_uuid: certification?.instructor_uuid ?? null,
instructor_number: certification?.instructor_number ?? "",
};
// A certification's agency is required, so a seed course that names none
// hands over no pair and the form opens on its own default - this is the
// one path that could otherwise open with a required field unset.
const opening: CertificationFieldValues = {
...from,
agency: from.agency ?? DEFAULT_CERTIFICATION_AGENCY,
agency_other: from.agency ? from.agency_other : "",
};
autofilledRef.current = { ...opening };
// Reopening the dialog must not carry a previous invocation's picked image
// onto whichever card is being edited now.
setCardEdits({});
reset({
...opening,
certification_number: certification?.certification_number ?? "",
certified_on: certification?.certified_on ?? "",
expires_on: certification?.expires_on ?? "",
notes: certification?.notes ?? "",
course_uuid: certification?.course_uuid ?? seed?.uuid ?? null,
});
// Same deliberate reset-on-open pattern as `gear-item-dialog.tsx`; clearing a
// stale error when the dialog reopens is exactly the "sync to a prop change"
// case this rule can't distinguish from a cascading render.
}, [open, certification, initialCourse, reset]);
// Picking a course copies its name, agency, contact and instructor across, so
// the diver types them once. Create only: the edit dialog
// seeds itself from the stored card, which would make every settled field look
// untouched and hand the whole card over to whichever course was picked.
// Relinking on edit corrects the link, not the card.
const prefillFromCourse = useCallback(
(course: Course) => {
if (isEdit) return;
const autofilled = autofilledRef.current;
const next = courseFieldValues(course);
if (getValues("name") === autofilled.name) {
setValue("name", next.name, AUTOFILL);
autofilled.name = next.name;
}
// The agency pair is considered together and written together: the API
// rejects a named agency carrying an `agency_other`, and "other" without
// one. A course that names no agency has no pair to hand over, so both
// halves are left as they stand - blanking them would empty a required
// field on a value the diver never chose.
if (next.agency) {
if (getValues("agency") === autofilled.agency) {
setValue("agency", next.agency, AUTOFILL);
autofilled.agency = next.agency;
}
if ((getValues("agency_other") ?? "") === autofilled.agency_other) {
setValue("agency_other", next.agency_other, AUTOFILL);
autofilled.agency_other = next.agency_other;
}
}
// The references, copied like the text beside them: the card names the
// course's contact and its first instructor until the diver picks another,
// and a course that names none takes back only one an earlier course put
// there.
if ((getValues("contact_uuid") ?? null) === autofilled.contact_uuid) {
setValue("contact_uuid", next.contact_uuid, AUTOFILL);
autofilled.contact_uuid = next.contact_uuid;
}
if (
(getValues("instructor_uuid") ?? null) === autofilled.instructor_uuid
) {
setValue("instructor_uuid", next.instructor_uuid, AUTOFILL);
autofilled.instructor_uuid = next.instructor_uuid;
}
if (
(getValues("instructor_number") ?? "") === autofilled.instructor_number
) {
setValue("instructor_number", next.instructor_number, AUTOFILL);
autofilled.instructor_number = next.instructor_number;
}
},
[isEdit, getValues, setValue],
);
// The card images, once the details are safely stored. This is the step the API
// shape forces: a card being created has no uuid until `createCertification`
// resolves, and `PUT .../file/{side}` needs one.
//
// A failed image does not fail the save. The details are already written, and
// making the diver fill the form in again to retry an upload costs more than the
// picture does - the dive form's attach step reached the same answer. Each
// failure gets its own toast naming the side.
//
// The returned certification is re-read whenever anything was sent, including
// after a partial failure: the embedded `files` metadata the list and the
// check-in sheet render from is stale either way, and only the API knows which
// sides actually landed.
const saveCardImages = async (
saved: Certification,
): Promise<Certification> => {
if (Object.keys(cardEdits).length === 0) return saved;
const failures = await applyCertificationCardEdits(saved.uuid, cardEdits);
for (const { side, error } of failures) {
console.error(`Failed to save the ${side} card image:`, error);
toast({
title: `Saved, but the ${CERTIFICATION_SIDE_LABELS[
side as CertificationSide
].toLowerCase()} image did not`,
description: getApiErrorMessage(error, "Try picking the image again."),
variant: "destructive",
});
}
try {
return await certificationsAPI.getCertification(saved.uuid);
} catch (error) {
// Non-fatal: the card itself is saved, and the list re-reads on its next
// load. Returning what we have keeps the row on screen rather than
// unwinding a save that succeeded.
console.error("Failed to re-read the saved certification:", error);
return saved;
}
};
const onSubmit = async (data: CertificationInput) => {
setApiError(null);
try {
// "" is the form's "not set" state for every optional field. On update they
// go as an explicit null so clearing one actually clears it rather than
// being ignored as an omitted key; on create they are simply left off.
//
// `agency_other` is the exception: the API rejects a non-null value unless
// the agency is "other", so switching away from "other" must send null
// rather than the stale name still sitting in the form state.
const shared = {
agency: data.agency as CertificationAgency,
agency_other:
data.agency === "other" ? data.agency_other || null : null,
name: data.name.trim(),
certification_number: data.certification_number || null,
certified_on: data.certified_on || null,
expires_on: data.expires_on || null,
instructor_number: data.instructor_number || null,
notes: data.notes || "",
// The picker's own empty state is already `null` rather than `""`, so
// this needs no mapping - but it is sent on every save either way, which
// is what makes clearing it clear the link. There is no
// `buildCertificationUpdate` helper to hold that rule instead: this
// dialog shows every field and submits all of them.
course_uuid: data.course_uuid ?? null,
// Pickers too, sent on every save for the same reason.
contact_uuid: data.contact_uuid ?? null,
instructor_uuid: data.instructor_uuid ?? null,
};
let saved: Certification;
if (certification) {
await certificationsAPI.updateCertification(certification.uuid, shared);
saved = { ...certification, ...shared };
} else {
saved = await certificationsAPI.createCertification({ ...shared });
}
onSaved(await saveCardImages(saved));
onOpenChange(false);
} catch (error) {
setApiError(
getApiErrorMessage(
error,
`Failed to ${isEdit ? "update" : "create"} certification. Please try again.`,
),
);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
{/* Wider than the default `max-w-lg`: the card slots below sit two to a row
from `sm:` up, and two credit-card shapes in a `lg` dialog are thumbnails
rather than a look at the picture being saved. */}
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{isEdit ? "Edit Certification" : "New Certification"}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={dialogFormSubmit(form.handleSubmit(onSubmit))}
className="space-y-4"
>
{/* First field on the form, above everything it fills in: source
before targets, so the diver picks the course and watches the
boxes below populate rather than typing them and wondering why
they changed. It also means the inline "Add course..." flow
prefills a form that is still empty. */}
<FormField
control={form.control}
name="course_uuid"
render={({ field }) => (
<FormItem>
<FormLabel>Course</FormLabel>
{/* Opens its own `CourseDialog` on "Add course...", which
puts a dialog on top of this one. `dialogFormSubmit`
keeps that inner submit out of this form - see
DECISIONS.md. */}
<FormControl
render={
<CourseCombobox
value={field.value}
onChange={field.onChange}
onCourseSelected={prefillFromCourse}
until={until}
/>
}
/>
<FormDescription>
The training this card came out of, if you logged it.
{/* Only true of a create dialog - relinking an existing
card changes the link and nothing else. */}
{!isEdit &&
" Picking one fills in the certification, dive center and instructor below, and the agency if the course names one."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* What the card is called leads, the course above it aside: it is the
line a diver reads off the card first, and the one thing here that
a course cannot fill in for them. */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Certification *</FormLabel>
<FormControl
render={
<Input
placeholder="e.g. Advanced Open Water Diver"
autoFocus
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
{/* Who issued it and what they numbered it: the pair a desk quotes
together, and two short values that would each waste a row alone. */}
<TrainingFields
agencyRequired
besideAgency={
<FormField
control={form.control}
name="certification_number"
render={({ field }) => (
<FormItem>
<FormLabel>Certification number</FormLabel>
<FormControl
render={
<Input
placeholder="As printed on the card"
{...field}
value={field.value ?? ""}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
}
dates={[
{ name: "certified_on", label: "Certified on" },
{ name: "expires_on", label: "Expires on" },
]}
until={until}
/>
{/* After every field typed off the card in the diver's hand: a
two-column block of pictures in the middle of a field stack breaks
the rhythm of filling one in. */}
<div className="space-y-2">
<p className="text-sm font-medium">Card images</p>
<CertificationCardFiles
certification={certification}
edits={cardEdits}
onChange={setCardEdits}
disabled={form.formState.isSubmitting}
/>
</div>
{/* Last, being the only thing here the card does not say: everything
above is read off it or photographed from it, and this is what the
diver has to add. */}
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl
render={
<Textarea
placeholder="Specialties covered, restrictions, anything worth remembering..."
className="min-h-[80px]"
{...field}
value={field.value ?? ""}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
<FormApiError error={apiError} />
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<DialogSubmitButton
isEdit={isEdit}
createLabel="Create certification"
/>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}