"use client" import { useEffect, useRef, useState } from "react" import { toast } from "sonner" import { AlertTriangle, Camera, ChevronLeft, ChevronRight, CheckCircle2, Image as ImageIcon, Loader2, Mic, StickyNote, Trash2, Video, X, } from "lucide-react" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/shared/components/ui/dialog" import { Alert, AlertDescription, AlertTitle } from "@/shared/components/ui/alert" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Textarea } from "@/shared/components/ui/textarea" import { Label } from "@/shared/components/ui/label" import { useAuthApi } from "@/shared/useApi" import { CheckpointMediaLightbox, type LightboxItem } from "@/modules/inspections/checkpoint-media-lightbox" export type Severity = "good" | "attention" | "critical" | "na" | "not_inspected" export type RecordType = "capture_photo" | "record_video" | "record_audio" | "record_conditions" | "wire_frame" | "none" export type CheckpointMedia = { id: number url: string | null media_type: "photo" | "video" | "audio" | "document" sort_order: number caption?: string | null } export type Checkpoint = { id: number name: string description?: string | null severity?: Severity | null status?: string | null technician_notes?: string | null section_name?: string | null record_type?: RecordType | null condition_rate?: number | null media?: CheckpointMedia[] attachments?: CheckpointMedia[] } const SEVERITY: { value: Severity; label: string; cls: string }[] = [ { value: "good", label: "Good", cls: "bg-emerald-100 text-emerald-800 hover:bg-emerald-200 border-emerald-200" }, { value: "attention", label: "Attention", cls: "bg-amber-100 text-amber-800 hover:bg-amber-200 border-amber-200" }, { value: "critical", label: "Critical", cls: "bg-rose-100 text-rose-800 hover:bg-rose-200 border-rose-200" }, { value: "na", label: "N/A", cls: "bg-slate-100 text-slate-700 hover:bg-slate-200 border-slate-200" }, ] /** A checkpoint is "complete" once severity is set AND the required recording (if any) is captured. */ export function isCheckpointComplete(cp: Checkpoint): boolean { const sev = (cp.severity as Severity) ?? "not_inspected" if (sev === "not_inspected") return false if (sev === "na") return true const rt = (cp.record_type as RecordType) ?? "none" const items = cp.attachments ?? cp.media ?? [] if (rt === "capture_photo") return items.some((m) => m.media_type === "photo") if (rt === "record_video") return items.some((m) => m.media_type === "video") if (rt === "record_audio") return items.some((m) => m.media_type === "audio") if (rt === "record_conditions") return cp.condition_rate != null return true } type Props = { open: boolean onOpenChange: (o: boolean) => void /** Full ordered list of checkpoints (typically section-flattened from the page). */ checkpoints: Checkpoint[] /** Index into `checkpoints`. null = dialog closed. */ activeIndex: number | null onIndexChange: (i: number) => void onSaved: () => void /** Optimistic patch back to the parent so the row updates instantly. */ onPatch?: (checkpointId: number, patch: Partial) => void } export function CheckpointFillDialog({ open, onOpenChange, checkpoints, activeIndex, onIndexChange, onSaved, onPatch, }: Props) { const api = useAuthApi() const photoInputRef = useRef(null) const videoInputRef = useRef(null) const audioInputRef = useRef(null) // Local state — seeded only when the active checkpoint changes // (not on every parent refetch, so unsaved notes don't get clobbered). const [severity, setSeverity] = useState("not_inspected") const [notes, setNotes] = useState("") const [conditionRate, setConditionRate] = useState(50) const [media, setMedia] = useState([]) const [savingNote, setSavingNote] = useState(false) const [savingCondition, setSavingCondition] = useState(false) const [uploading, setUploading] = useState(false) const [lightbox, setLightbox] = useState(null) const checkpoint = activeIndex != null ? checkpoints[activeIndex] : null const checkpointId = checkpoint?.id ?? null // Reseed only when the *checkpoint identity* changes (open or step nav). useEffect(() => { if (checkpoint) { setSeverity((checkpoint.severity as Severity) ?? "not_inspected") setNotes(checkpoint.technician_notes ?? "") setConditionRate(checkpoint.condition_rate ?? 50) setMedia(checkpoint.attachments ?? checkpoint.media ?? []) } // Intentionally only depends on the id — not on the whole prop object. // eslint-disable-next-line react-hooks/exhaustive-deps }, [checkpointId, open]) if (!checkpoint) return null const recordType = (checkpoint.record_type as RecordType) ?? "none" const skipsMedia = severity === "na" || recordType === "none" || recordType === "wire_frame" const needsSeverity = severity === "not_inspected" const needsPhoto = !skipsMedia && recordType === "capture_photo" && !media.some((m) => m.media_type === "photo") const needsVideo = !skipsMedia && recordType === "record_video" && !media.some((m) => m.media_type === "video") const needsAudio = !skipsMedia && recordType === "record_audio" && !media.some((m) => m.media_type === "audio") const needsCondition = !skipsMedia && recordType === "record_conditions" && checkpoint.condition_rate == null const allDone = !needsSeverity && !needsPhoto && !needsVideo && !needsAudio && !needsCondition const hasUnsavedNote = notes !== (checkpoint.technician_notes ?? "") const isFirst = activeIndex === 0 const isLast = activeIndex === checkpoints.length - 1 // All field saves rely on the optimistic `onPatch` to keep the parent // page in sync. We intentionally do NOT call `onSaved()` here, because a // full inspection refetch on every chip click was causing the dialog tree // to re-render in a way that flickered the modal closed/open. `onSaved` // is reserved for the explicit page-level "load" callsite (initial mount // + nav). The server is authoritative — if anything ever diverges, the // user can reopen the dialog or refresh. const updateSeverity = async (s: Severity) => { setSeverity(s) onPatch?.(checkpoint.id, { severity: s }) try { await api.inspections.updateCheckpoint(String(checkpoint.id), { severity: s } as any) } catch (e: any) { toast.error(e?.payload?.message ?? "Failed to save finding") } } const saveNotes = async () => { if (!hasUnsavedNote) return setSavingNote(true) try { await api.inspections.updateCheckpoint(String(checkpoint.id), { technician_notes: notes } as any) onPatch?.(checkpoint.id, { technician_notes: notes }) toast.success("Note saved") } catch (e: any) { toast.error(e?.payload?.message ?? "Failed to save note") } finally { setSavingNote(false) } } const saveCondition = async (v: number) => { setConditionRate(v) onPatch?.(checkpoint.id, { condition_rate: v }) setSavingCondition(true) try { await api.inspections.updateCheckpoint(String(checkpoint.id), { condition_rate: v } as any) } catch (e: any) { toast.error(e?.payload?.message ?? "Failed to save condition") } finally { setSavingCondition(false) } } const handleFiles = async (filesList: FileList | null) => { if (!filesList || filesList.length === 0) return const files = Array.from(filesList).slice(0, 10) setUploading(true) try { const res = await api.inspections.uploadCheckpointAttachments(checkpoint.id, files) const added = (res.data as any[]) ?? [] const nextMedia = [...media, ...added] setMedia(nextMedia) onPatch?.(checkpoint.id, { attachments: nextMedia, media: nextMedia } as any) toast.success("Saved") } catch (e: any) { toast.error(e?.payload?.message ?? "Upload failed") } finally { setUploading(false) if (photoInputRef.current) photoInputRef.current.value = "" if (videoInputRef.current) videoInputRef.current.value = "" if (audioInputRef.current) audioInputRef.current.value = "" } } const deleteAttachment = async (mediaId: number) => { try { await api.inspections.destroyCheckpointAttachment(checkpoint.id, mediaId) const nextMedia = media.filter((x) => x.id !== mediaId) setMedia(nextMedia) onPatch?.(checkpoint.id, { attachments: nextMedia, media: nextMedia } as any) } catch (e: any) { toast.error(e?.payload?.message ?? "Failed to remove") } } const goNext = async () => { // Belt-and-suspenders — the button is also `disabled={!allDone}`. if (!allDone) return if (hasUnsavedNote) await saveNotes() if (isLast) { onOpenChange(false) } else if (activeIndex != null) { onIndexChange(activeIndex + 1) } } const goPrev = async () => { if (hasUnsavedNote) await saveNotes() if (activeIndex != null && activeIndex > 0) onIndexChange(activeIndex - 1) } return ( <>
{checkpoint.name} {allDone && } {checkpoint.section_name && ( {checkpoint.section_name} · {(activeIndex ?? 0) + 1} of {checkpoints.length} )}
{!allDone && ( Still required
    {needsSeverity &&
  • Pick a finding
  • } {needsPhoto &&
  • Add at least one photo
  • } {needsVideo &&
  • Add at least one video
  • } {needsAudio &&
  • Add at least one audio recording
  • } {needsCondition &&
  • Set the condition rating
  • }
)}
{/* Finding */}
{!needsSeverity && }
{SEVERITY.map((opt) => ( ))}
{/* Condition rating */} {recordType === "record_conditions" && severity !== "na" && (
{!needsCondition && }
setConditionRate(Number(e.target.value))} onMouseUp={(e) => saveCondition(Number((e.target as HTMLInputElement).value))} onTouchEnd={(e) => saveCondition(Number((e.target as HTMLInputElement).value))} className="flex-1 accent-foreground" disabled={savingCondition} /> {conditionRate}%

0 = worn / broken · 100 = like new.

)} {/* Media — only the relevant kind for this record_type */} {!skipsMedia && ( { if (kind === "photo") photoInputRef.current?.click() else if (kind === "video") videoInputRef.current?.click() else audioInputRef.current?.click() }} onDelete={deleteAttachment} onPreview={(item) => setLightbox(item)} /> )} handleFiles(e.target.files)} className="hidden" /> handleFiles(e.target.files)} className="hidden" /> handleFiles(e.target.files)} className="hidden" /> {/* Notes */}
{hasUnsavedNote && ( Unsaved )}