Ctrl K
ComunidadGuías…

src/components/certifications/certification-view-dialog.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import Link from "next/link";
import { Download } from "lucide-react";
import { coursesAPI, Course } from "@/lib/api/courses";
import { contactsAPI } from "@/lib/api/contacts";
import { peopleAPI } from "@/lib/api/people";
import { useRecordByUuid } from "@/hooks/useRecordByUuid";
import { useWithReturnTo } from "@/hooks/useReturnTo";
import {
  certificationFilePath,
  certificationsAPI,
  certificationAgencyLabel,
  certificationFile,
  Certification,
  CertificationSide,
  CERTIFICATION_SIDES,
  CERTIFICATION_SIDE_LABELS,
} from "@/lib/api/certifications";
import {
  certificationExpiryBadgeVariant,
  certificationExpiryLabel,
  certificationExpiryStatus,
} from "@/lib/certification";
import { formatDateOnly } from "@/lib/date-time";
import { getApiErrorMessage } from "@/lib/api/error";
import { saveApiDownload } from "@/lib/download";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ButtonSpinner } from "@/components/ui/button-spinner";
import { useToast } from "@/components/ui/use-toast";
import { CertificationCardImage } from "./certification-card-image";
import { useEffect, useState } from "react";

interface CertificationViewDialogProps {
  certification: Certification | null;
  onOpenChange: (open: boolean) => void;
}

function DetailRow({
  label,
  value,
}: {
  label: string;
  value: string | null | undefined;
}) {
  if (!value) return null;
  return (
    <div>
      <dt className="text-xs text-muted-foreground">{label}</dt>
      <dd className="text-sm">{value}</dd>
    </div>
  );
}

// The course this card came out of, as a link to it.
//
// The certification carries the course's uuid and nothing else, so the name has
// to be fetched - and the row only appears once it has arrived. A failed lookup
// is non-fatal and leaves the row out, exactly as the dive page's trip link does:
// the rest of the dialog is what the diver opened it for.
function CourseRow({ courseUuid }: { courseUuid: string }) {
  const [course, setCourse] = useState<Course | null>(null);
  const withReturnTo = useWithReturnTo();

  useEffect(() => {
    let cancelled = false;

    coursesAPI
      .getCourse(courseUuid)
      .then((data) => {
        if (!cancelled) setCourse(data);
      })
      .catch((error) => console.error("Failed to fetch course:", error));

    return () => {
      cancelled = true;
    };
  }, [courseUuid]);

  if (!course) return null;

  return (
    <div>
      <dt className="text-xs text-muted-foreground">Course</dt>
      <dd className="text-sm">
        <Link
          href={withReturnTo(`/courses/${course.uuid}`)}
          className="relative hover:underline touch:tap-target"
        >
          {course.name}
        </Link>
      </dd>
    </div>
  );
}

// Who ran the course the card came out of, on `CourseRow`'s terms: the
// certification carries the contact's uuid and nothing else, so the row appears
// once the name has arrived, and a failed lookup leaves it out.
function ContactRow({ contactUuid }: { contactUuid: string }) {
  const contact = useRecordByUuid(contactsAPI.getContact, contactUuid);
  if (!contact) return null;

  return (
    <div>
      <dt className="text-xs text-muted-foreground">Dive center</dt>
      <dd className="text-sm">{contact.name}</dd>
    </div>
  );
}

// Who signed the card, on the same terms: a person, by uuid, named once the
// record has arrived and linked to their page.
function InstructorRow({ personUuid }: { personUuid: string }) {
  const person = useRecordByUuid(peopleAPI.getPerson, personUuid);
  const withReturnTo = useWithReturnTo();
  if (!person) return null;

  return (
    <div>
      <dt className="text-xs text-muted-foreground">Instructor</dt>
      <dd className="text-sm">
        <Link
          href={withReturnTo(`/people/${person.uuid}`)}
          className="relative hover:underline touch:tap-target"
        >
          {person.name}
        </Link>
      </dd>
    </div>
  );
}

// Read-only view of a certification and its card images - what a diver opens at
// a dive shop counter. Large images, details underneath.
export function CertificationViewDialog({
  certification,
  onOpenChange,
}: CertificationViewDialogProps) {
  const { toast } = useToast();
  const [downloadingSide, setDownloadingSide] =
    useState<CertificationSide | null>(null);

  if (!certification) return null;

  const expiry = certificationExpiryStatus(certification.expires_on);

  // Downloading goes through `saveApiDownload` for the same reason rendering goes
  // through the API client: the endpoint needs an `Authorization` header, so a
  // plain `<a href>` to it would 401. See `lib/download.ts`.
  const handleDownload = async (side: CertificationSide) => {
    const file = certificationFile(certification, side);
    if (!file) return;

    try {
      setDownloadingSide(side);
      await saveApiDownload(
        certificationFilePath(certification.uuid, side),
        async () => ({
          blob: await certificationsAPI.getCertificationFileBlob(
            certification.uuid,
            side,
          ),
          filename: file.original_filename,
        }),
      );
    } catch (error) {
      toast({
        title: "Error",
        description: getApiErrorMessage(
          error,
          "Failed to download the file. Please try again.",
        ),
        variant: "destructive",
      });
    } finally {
      setDownloadingSide(null);
    }
  };

  return (
    <Dialog open={!!certification} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-2xl">
        <DialogHeader>
          <DialogTitle className="flex flex-wrap items-center gap-2">
            <span>{certification.name}</span>
            <Badge variant="outline">
              {certificationAgencyLabel(
                certification.agency,
                certification.agency_other,
              )}
            </Badge>
            {expiry && (
              <Badge variant={certificationExpiryBadgeVariant(expiry)}>
                {certificationExpiryLabel(expiry)}
              </Badge>
            )}
          </DialogTitle>
        </DialogHeader>

        <div className="space-y-6">
          <div className="grid gap-4 sm:grid-cols-2">
            {CERTIFICATION_SIDES.map((side) => {
              const file = certificationFile(certification, side);
              return (
                <div key={side} className="space-y-2">
                  <p className="text-sm font-medium">
                    {CERTIFICATION_SIDE_LABELS[side]}
                  </p>
                  <CertificationCardImage
                    certificationUuid={certification.uuid}
                    side={side}
                    file={file}
                  />
                  {file && (
                    <Button
                      type="button"
                      variant="outline"
                      size="sm"
                      disabled={downloadingSide === side}
                      onClick={() => handleDownload(side)}
                    >
                      {downloadingSide === side ? (
                        <>
                          <ButtonSpinner className="mr-2" />
                          Preparing...
                        </>
                      ) : (
                        <>
                          <Download className="h-4 w-4 mr-2" />
                          Download
                        </>
                      )}
                    </Button>
                  )}
                </div>
              );
            })}
          </div>

          <dl className="grid gap-4 sm:grid-cols-2">
            <DetailRow
              label="Certification number"
              value={certification.certification_number}
            />
            {certification.course_uuid && (
              <CourseRow courseUuid={certification.course_uuid} />
            )}
            <DetailRow
              label="Certified on"
              value={
                certification.certified_on
                  ? formatDateOnly(certification.certified_on)
                  : null
              }
            />
            <DetailRow
              label="Expires on"
              value={
                certification.expires_on
                  ? formatDateOnly(certification.expires_on)
                  : null
              }
            />
            {certification.contact_uuid && (
              <ContactRow contactUuid={certification.contact_uuid} />
            )}
            {certification.instructor_uuid && (
              <InstructorRow personUuid={certification.instructor_uuid} />
            )}
            <DetailRow
              label="Instructor number"
              value={certification.instructor_number}
            />
          </dl>

          {certification.notes && (
            <div>
              <p className="text-xs text-muted-foreground">Notes</p>
              <p className="text-sm whitespace-pre-wrap">
                {certification.notes}
              </p>
            </div>
          )}
        </div>
      </DialogContent>
    </Dialog>
  );
}