Ctrl K
ComunidadGuías…

src/components/courses/course-certifications-card.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useCallback, useEffect, useState } from "react";
import { BadgeCheck, Plus } from "lucide-react";
import {
  certificationsAPI,
  certificationAgencyLabel,
  Certification,
} from "@/lib/api/certifications";
import type { Course } from "@/lib/api/courses";
import { formatDateOnly } from "@/lib/date-time";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRowsSkeleton } from "@/components/ui/skeleton";
import { CertificationDialog } from "@/components/certifications/certification-dialog";
import { CertificationViewDialog } from "@/components/certifications/certification-view-dialog";
import { useSavedElsewhere } from "@/hooks/useSavedElsewhere";

// How many of a course's cards to show. A course issues one or two in practice
// (TDI's Advanced Nitrox + Decompression Procedures is the archetype); this is a
// ceiling, not a page size, and the card deliberately does not paginate.
const CERTIFICATIONS_LIMIT = 50;

interface CourseCertificationsCardProps {
  // The whole course, not just its uuid: adding a certification opens a dialog
  // pre-linked to it *and* prefilled from its contact, instructor and agency
  // where it names one, and the page has already loaded every one of those.
  course: Course;
  /**
   * Opened by the page's sidebar button as well as by this card's own empty
   * state, so the page holds the flag and the card is told about it.
   */
  isAdding: boolean;
  onAddingChange: (adding: boolean) => void;
}

/**
 * The certifications one course issued, on that course's page.
 *
 * Certifications have no detail route of their own by design, so each row opens
 * the same read-only view dialog the certifications list opens - which is what
 * makes a card reachable from here rather than merely listed.
 *
 * It owns the dialogs the create flow needs, rather than the page owning them
 * and pushing a refresh signal down: the list this card fetches is the thing a
 * new certification has to appear in, and keeping both here means the refetch
 * is a call rather than a prop contract to keep in step. Only the "is the
 * create dialog open" flag lives above it, because the sidebar opens it too.
 */
export function CourseCertificationsCard({
  course,
  isAdding,
  onAddingChange,
}: CourseCertificationsCardProps) {
  const [certifications, setCertifications] = useState<Certification[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [viewing, setViewing] = useState<Certification | null>(null);

  const courseUuid = course.uuid;

  // Keyed on the uuid rather than on `course`, so a page re-render handing down
  // an equal-but-new object doesn't re-fetch the list.
  const fetchCertifications = useCallback(
    () =>
      certificationsAPI
        .getCertifications(1, CERTIFICATIONS_LIMIT, { courseUuid })
        .then((response) => response.data)
        // Supplementary to the page, like Home's own cards: a failed
        // fetch leaves this empty rather than turning the course page into an
        // error.
        .catch((error) => {
          console.error("Failed to fetch the course's certifications:", error);
          return null;
        }),
    [courseUuid],
  );

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

    fetchCertifications()
      .then((data) => {
        if (cancelled || !data) return;
        setCertifications(data);
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false);
      });

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

  const refresh = useCallback(async () => {
    const data = await fetchCertifications();
    if (data) setCertifications(data);
  }, [fetchCertifications]);

  useSavedElsewhere("certification", () => void refresh());

  const isEmpty = !isLoading && certifications.length === 0;
  // A course with no agency - a private instructor's - usually issues no card,
  // so the card waits for one rather than inviting it. The dialogs below stay
  // mounted either way: the sidebar's button still opens the create one.
  const isHidden = !course.agency && certifications.length === 0;

  return (
    <>
      {!isHidden && (
        <Card>
          {/* Title and description as direct children, with no wrapper: this
            card carries no header control, and a `<div>` around the
            pair would eat `CardHeader`'s own 6px gap. With nothing to list,
            the header drops to its hidden heading, as the list pages' cards
            do - the empty state says what the card is for, and carries the
            description's hint along with it. */}
          <CardHeader className={cn(isEmpty && "p-0")}>
            {isEmpty ? (
              <CardTitle as="h2" className="sr-only">
                Certifications from This Course
              </CardTitle>
            ) : (
              <>
                <CardTitle as="h2" className="flex items-center gap-2">
                  <BadgeCheck className="h-5 w-5" />
                  Certifications from This Course
                </CardTitle>
                {/* The sidebar's button is not the only way in: an existing card can
                  name this course from its own form, which is how a card logged
                  before the course was. */}
                <CardDescription>
                  The cards this training issued. An existing card can name this
                  course from its own form too.
                </CardDescription>
              </>
            )}
          </CardHeader>
          <CardContent>
            {isLoading ? (
              <ListRowsSkeleton rows={2} />
            ) : isEmpty ? (
              <EmptyState
                icon={BadgeCheck}
                title="No certifications from this course yet"
                description="Add the card this training issued, or name this course from an existing card's form."
                action={
                  <Button onClick={() => onAddingChange(true)}>
                    <Plus className="h-4 w-4 mr-2" />
                    Add the first certification
                  </Button>
                }
              />
            ) : (
              <ul className="space-y-3">
                {certifications.map((certification) => (
                  <li key={certification.uuid}>
                    <button
                      type="button"
                      className="w-full text-left p-3 rounded-lg border hover:bg-muted transition-colors"
                      onClick={() => setViewing(certification)}
                    >
                      <div className="font-medium text-foreground">
                        {certification.name}
                      </div>
                      <div className="text-sm text-muted-foreground">
                        {certificationAgencyLabel(
                          certification.agency,
                          certification.agency_other,
                        )}
                        {certification.certified_on && (
                          <>
                            {" · "}
                            {formatDateOnly(certification.certified_on)}
                          </>
                        )}
                      </div>
                    </button>
                  </li>
                ))}
              </ul>
            )}
          </CardContent>
        </Card>
      )}

      <CertificationDialog
        open={isAdding}
        onOpenChange={onAddingChange}
        initialCourse={course}
        // The card images were picked in that dialog too, so a refetch is the
        // whole of what happens here now.
        onSaved={() => refresh()}
      />

      <CertificationViewDialog
        certification={viewing}
        onOpenChange={(open) => !open && setViewing(null)}
      />
    </>
  );
}