Ctrl K
ComunidadGuías…

src/components/courses/course-dialog.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { useCallback } from "react";
import { useDialogApiError } from "@/hooks/useDialogApiError";
import { NativeSelect } from "@/components/ui/native-select";
import { FormApiError } from "@/components/ui/form-api-error";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { courseSchema, CourseInput } from "@/lib/validations/course";
import {
  coursesAPI,
  Course,
  COURSE_STATUSES,
  DEFAULT_COURSE_STATUS,
} from "@/lib/api/courses";
import { courseStatusLabel } from "@/lib/course";
import { joinCourseInstructor, splitCourseInstructor } from "@/lib/people";
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,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
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 { useEffectOnChange } from "@/hooks/useEffectOnChange";
import { PeopleMultiSelect } from "@/components/people/people-multi-select";
import { TrainingFields } from "./training-fields";

interface CourseDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  // Pass an existing course to edit it; omit to create a new one.
  course?: Course | null;
  // Called with the created/updated course so the caller can refresh whatever
  // list it's showing - and, in the pickers, select it straight away.
  onSaved: (course: Course) => void;
}

// The one create/edit form for a course, used by the courses list and detail
// pages, the header's quick-create menu, and the course pickers on the dive form
// and in the certification dialog.
//
// A dialog rather than `new`/`edit` pages, following trips and certifications:
// the flow that matters most is adding a course from inside a half-filled dive
// form or an open certification dialog, and navigating away would mean either
// losing that form or building draft-persistence for it. Link management stays
// out of here for the same reason it stays out of `TripDialog` - a dive picks
// its course on the dive form, a certification on its own.
export function CourseDialog({
  open,
  onOpenChange,
  course,
  onSaved,
}: CourseDialogProps) {
  const [apiError, setApiError, handleOpenChange] = useDialogApiError(
    open,
    onOpenChange,
  );
  const isEdit = !!course;

  const form = useForm<CourseInput>({
    resolver: zodResolver(courseSchema),
    defaultValues: {
      name: "",
      // No agency chosen: a diver who never opens the picker stores nothing
      // rather than a fabricated PADI.
      agency: null,
      agency_other: "",
      status: DEFAULT_COURSE_STATUS,
      start_date: "",
      end_date: "",
      instructor_uuid: null,
      instructor_number: "",
      contact_uuid: null,
      people: [],
      notes: "",
    },
  });

  const instructorUuid = useWatch({
    control: form.control,
    name: "instructor_uuid",
  });
  // The pickers rank by last use on a dive at or before the course's start.
  const until =
    useWatch({ control: form.control, name: "start_date" }) || undefined;

  // Reload the form whenever the dialog opens, so it shows the course being
  // edited rather than whatever the previous invocation left behind.
  const { reset, getValues, setValue } = form;
  useEffectOnChange(() => {
    if (!open) return;
    const { instructorUuid, others } = splitCourseInstructor(course?.people);
    reset({
      name: course?.name ?? "",
      agency: course?.agency ?? null,
      agency_other: course?.agency_other ?? "",
      status: course?.status ?? DEFAULT_COURSE_STATUS,
      start_date: course?.start_date ?? "",
      end_date: course?.end_date ?? "",
      instructor_uuid: instructorUuid,
      instructor_number: course?.instructor_number ?? "",
      contact_uuid: course?.contact_uuid ?? null,
      people: others,
      notes: course?.notes ?? "",
    });
    // Same deliberate reset-on-open pattern as `certification-dialog.tsx`;
    // clearing a stale value when the dialog reopens is exactly the "sync to a
    // prop change" case this rule can't distinguish from a cascading render.
  }, [open, course, reset]);

  // A person made the instructor leaves the list below, where they would
  // otherwise be named twice - once as the instructor and once as a student.
  const pickInstructor = useCallback(
    (uuid: string | null) => {
      setValue("instructor_uuid", uuid, { shouldDirty: true });
      if (!uuid) return;
      const people = getValues("people") ?? [];
      if (people.some((reference) => reference.person_uuid === uuid)) {
        setValue(
          "people",
          people.filter((reference) => reference.person_uuid !== uuid),
          { shouldDirty: true },
        );
      }
    },
    [getValues, setValue],
  );

  const onSubmit = async (data: CourseInput) => {
    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; the API's `CourseUpdate` keeps exactly
      // these fields off `NON_NULLABLE_FIELDS` for that. On create the same nulls
      // simply store nothing.
      //
      // `agency` is one of them: a course need not name one, and the picker's
      // own "not set" is already `null`, so clearing it here clears the stored
      // value rather than being ignored.
      //
      // `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 = {
        name: data.name.trim(),
        agency: data.agency,
        agency_other:
          data.agency === "other" ? data.agency_other || null : null,
        status: data.status,
        start_date: data.start_date || null,
        end_date: data.end_date || null,
        instructor_number: data.instructor_number || null,
        // Sent as `null` when cleared, which is what unlinks it on an update.
        contact_uuid: data.contact_uuid ?? null,
        // The whole list on every save, the instructor first - the API replaces
        // it, so a cleared instructor is simply one no longer on it.
        people: joinCourseInstructor(data.instructor_uuid, data.people ?? []),
        notes: data.notes || "",
      };

      if (course) {
        // The API answers a PATCH with a status message only, so the updated
        // course is assembled here for the caller.
        await coursesAPI.updateCourse(course.uuid, shared);
        onSaved({ ...course, ...shared });
      } else {
        const created = await coursesAPI.createCourse({
          ...shared,
        });
        onSaved(created);
      }

      onOpenChange(false);
    } catch (error) {
      setApiError(
        getApiErrorMessage(
          error,
          `Failed to ${isEdit ? "update" : "create"} course. Please try again.`,
        ),
      );
    }
  };

  return (
    <Dialog open={open} onOpenChange={handleOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{isEdit ? "Edit Course" : "New Course"}</DialogTitle>
        </DialogHeader>

        <Form {...form}>
          {/* `dialogFormSubmit` keeps this submit from bubbling into the form
              this dialog can be opened from - the dive form, or the
              certification dialog's own form. See `lib/dialog-form.ts`. */}
          <form
            onSubmit={dialogFormSubmit(form.handleSubmit(onSubmit))}
            className="space-y-4"
          >
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Course *</FormLabel>
                  <FormControl
                    render={
                      <Input
                        placeholder="e.g. Advanced Nitrox + Decompression Procedures"
                        autoFocus
                        {...field}
                      />
                    }
                  />
                  <FormMessage />
                </FormItem>
              )}
            />

            <TrainingFields
              agencyRequired={false}
              besideAgency={
                <FormField
                  control={form.control}
                  name="status"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Status *</FormLabel>
                      <FormControl
                        render={
                          <NativeSelect {...field}>
                            {COURSE_STATUSES.map((value) => (
                              <option key={value} value={value}>
                                {courseStatusLabel(value)}
                              </option>
                            ))}
                          </NativeSelect>
                        }
                      />
                      <FormMessage />
                    </FormItem>
                  )}
                />
              }
              dates={[
                { name: "start_date", label: "Start date" },
                { name: "end_date", label: "End date" },
              ]}
              until={until}
              onInstructorChange={pickInstructor}
            />

            <FormField
              control={form.control}
              name="people"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>People</FormLabel>
                  {/* A student until the diver says otherwise: on a course,
                      the people beside the diver are mostly the rest of the
                      class. The instructor has the field above. */}
                  <FormControl
                    render={
                      <PeopleMultiSelect
                        value={field.value ?? []}
                        onChange={field.onChange}
                        defaultRole="student"
                        until={until}
                        excludeIds={
                          instructorUuid ? [instructorUuid] : undefined
                        }
                      />
                    }
                  />
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={form.control}
              name="notes"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Notes</FormLabel>
                  <FormControl
                    render={
                      <Textarea
                        placeholder="Skills covered, conditions, 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 course" />
            </DialogFooter>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  );
}