"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([]); const [isLoading, setIsLoading] = useState(true); const [viewing, setViewing] = useState(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 && ( {/* Title and description as direct children, with no wrapper: this card carries no header control, and a `
` 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. */} {isEmpty ? ( Certifications from This Course ) : ( <> Certifications from This Course {/* 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. */} The cards this training issued. An existing card can name this course from its own form too. )} {isLoading ? ( ) : isEmpty ? ( onAddingChange(true)}> Add the first certification } /> ) : (
    {certifications.map((certification) => (
  • ))}
)}
)} refresh()} /> !open && setViewing(null)} /> ); }