- Implemented TemplateCheckpointEditDialog for creating and editing inspection checkpoints. - Added VendorActions component for managing vendor actions including edit, activate/deactivate, and delete. - Created VendorContext for managing vendor state across components. - Developed VendorGeneralInfo component to display detailed vendor information. - Introduced AedSymbol and Money components for consistent currency representation. - Added PromptDialog for user input prompts throughout the application. - Implemented RelationLink component for unified related-data display in CRUD tables. - Created InspectionTemplatesClient for API interactions related to inspection templates.
525 lines
25 KiB
TypeScript
525 lines
25 KiB
TypeScript
"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<Checkpoint>) => void
|
|
}
|
|
|
|
export function CheckpointFillDialog({
|
|
open,
|
|
onOpenChange,
|
|
checkpoints,
|
|
activeIndex,
|
|
onIndexChange,
|
|
onSaved,
|
|
onPatch,
|
|
}: Props) {
|
|
const api = useAuthApi()
|
|
const photoInputRef = useRef<HTMLInputElement>(null)
|
|
const videoInputRef = useRef<HTMLInputElement>(null)
|
|
const audioInputRef = useRef<HTMLInputElement>(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<Severity>("not_inspected")
|
|
const [notes, setNotes] = useState("")
|
|
const [conditionRate, setConditionRate] = useState<number>(50)
|
|
const [media, setMedia] = useState<CheckpointMedia[]>([])
|
|
const [savingNote, setSavingNote] = useState(false)
|
|
const [savingCondition, setSavingCondition] = useState(false)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [lightbox, setLightbox] = useState<LightboxItem | null>(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 (
|
|
<>
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<span className="truncate">{checkpoint.name}</span>
|
|
{allDone && <CheckCircle2 className="size-4 text-emerald-600 shrink-0" />}
|
|
</DialogTitle>
|
|
{checkpoint.section_name && (
|
|
<DialogDescription>
|
|
{checkpoint.section_name} · {(activeIndex ?? 0) + 1} of {checkpoints.length}
|
|
</DialogDescription>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{!allDone && (
|
|
<Alert variant="default" className="border-amber-200 bg-amber-50/60">
|
|
<AlertTriangle className="text-amber-700" />
|
|
<AlertTitle className="text-amber-900">Still required</AlertTitle>
|
|
<AlertDescription className="text-amber-900/90">
|
|
<ul className="list-disc list-inside space-y-0.5">
|
|
{needsSeverity && <li>Pick a finding</li>}
|
|
{needsPhoto && <li>Add at least one photo</li>}
|
|
{needsVideo && <li>Add at least one video</li>}
|
|
{needsAudio && <li>Add at least one audio recording</li>}
|
|
{needsCondition && <li>Set the condition rating</li>}
|
|
</ul>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="space-y-5">
|
|
{/* Finding */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<Label className="flex items-center gap-2">
|
|
Finding <Badge variant="secondary" className="text-[10px]">required</Badge>
|
|
</Label>
|
|
{!needsSeverity && <CheckCircle2 className="size-4 text-emerald-600" />}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{SEVERITY.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => updateSeverity(opt.value)}
|
|
className={`rounded-full px-4 py-1.5 text-sm font-medium border transition ${opt.cls} ${
|
|
severity === opt.value ? "ring-2 ring-offset-1 ring-foreground/30" : "opacity-70 hover:opacity-100"
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Condition rating */}
|
|
{recordType === "record_conditions" && severity !== "na" && (
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<Label className="flex items-center gap-2">
|
|
Condition rating <Badge variant="secondary" className="text-[10px]">required</Badge>
|
|
</Label>
|
|
{!needsCondition && <CheckCircle2 className="size-4 text-emerald-600" />}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
step={5}
|
|
value={conditionRate}
|
|
onChange={(e) => 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}
|
|
/>
|
|
<span className="w-12 text-right text-sm tabular-nums font-medium">{conditionRate}%</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">0 = worn / broken · 100 = like new.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Media — only the relevant kind for this record_type */}
|
|
{!skipsMedia && (
|
|
<MediaSection
|
|
recordType={recordType}
|
|
media={media}
|
|
uploading={uploading}
|
|
required={needsPhoto || needsVideo || needsAudio}
|
|
satisfied={
|
|
(recordType === "capture_photo" && !needsPhoto) ||
|
|
(recordType === "record_video" && !needsVideo) ||
|
|
(recordType === "record_audio" && !needsAudio)
|
|
}
|
|
onPick={(kind) => {
|
|
if (kind === "photo") photoInputRef.current?.click()
|
|
else if (kind === "video") videoInputRef.current?.click()
|
|
else audioInputRef.current?.click()
|
|
}}
|
|
onDelete={deleteAttachment}
|
|
onPreview={(item) => setLightbox(item)}
|
|
/>
|
|
)}
|
|
|
|
<input ref={photoInputRef} type="file" accept="image/*" capture="environment" multiple onChange={(e) => handleFiles(e.target.files)} className="hidden" />
|
|
<input ref={videoInputRef} type="file" accept="video/*" capture="environment" multiple onChange={(e) => handleFiles(e.target.files)} className="hidden" />
|
|
<input ref={audioInputRef} type="file" accept="audio/*" capture multiple onChange={(e) => handleFiles(e.target.files)} className="hidden" />
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<Label htmlFor="cp-notes" className="flex items-center gap-2">
|
|
<StickyNote className="size-3.5" /> Technician notes
|
|
<Badge variant="outline" className="text-[10px]">optional</Badge>
|
|
</Label>
|
|
{hasUnsavedNote && (
|
|
<span className="text-[11px] text-amber-700">Unsaved</span>
|
|
)}
|
|
</div>
|
|
<Textarea
|
|
id="cp-notes"
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={3}
|
|
placeholder="e.g. Pads at 3mm, suggest replacement before next service"
|
|
/>
|
|
<div className="mt-2 flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={hasUnsavedNote ? "default" : "outline"}
|
|
onClick={saveNotes}
|
|
disabled={savingNote || !hasUnsavedNote}
|
|
>
|
|
{savingNote ? "Saving…" : "Update note"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="!justify-between items-center gap-2 flex-wrap">
|
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
|
<X className="size-4" /> Close
|
|
</Button>
|
|
<div className="flex flex-col items-end gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={goPrev} disabled={isFirst}>
|
|
<ChevronLeft className="size-4" /> Previous
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={goNext}
|
|
disabled={!allDone}
|
|
title={!allDone ? "Complete the required fields to continue" : undefined}
|
|
>
|
|
{isLast ? "Done" : (hasUnsavedNote ? "Save & next" : "Next")}
|
|
{!isLast && <ChevronRight className="size-4" />}
|
|
</Button>
|
|
</div>
|
|
{!allDone && (
|
|
<span className="text-[11px] text-muted-foreground">
|
|
Complete the required fields to continue.
|
|
</span>
|
|
)}
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<CheckpointMediaLightbox
|
|
open={!!lightbox}
|
|
onOpenChange={(o) => { if (!o) setLightbox(null) }}
|
|
item={lightbox}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function MediaSection({
|
|
recordType,
|
|
media,
|
|
uploading,
|
|
required,
|
|
satisfied,
|
|
onPick,
|
|
onDelete,
|
|
onPreview,
|
|
}: {
|
|
recordType: RecordType
|
|
media: CheckpointMedia[]
|
|
uploading: boolean
|
|
required: boolean
|
|
satisfied: boolean
|
|
onPick: (kind: "photo" | "video" | "audio") => void
|
|
onDelete: (id: number) => void
|
|
onPreview: (item: LightboxItem) => void
|
|
}) {
|
|
let label = "Recording"
|
|
let icon = <Camera className="size-3.5" />
|
|
let cta = "Take / pick file"
|
|
let kind: "photo" | "video" | "audio" = "photo"
|
|
|
|
if (recordType === "capture_photo") { label = "Photo"; cta = "Take or pick photo"; icon = <Camera className="size-3.5" />; kind = "photo" }
|
|
if (recordType === "record_video") { label = "Video"; cta = "Record or pick video"; icon = <Video className="size-3.5" />; kind = "video" }
|
|
if (recordType === "record_audio") { label = "Audio"; cta = "Record or pick audio"; icon = <Mic className="size-3.5" />; kind = "audio" }
|
|
|
|
const matching = media.filter((m) => {
|
|
if (recordType === "capture_photo") return m.media_type === "photo"
|
|
if (recordType === "record_video") return m.media_type === "video"
|
|
if (recordType === "record_audio") return m.media_type === "audio"
|
|
return true
|
|
})
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<Label className="flex items-center gap-2">
|
|
{label}
|
|
{required && <Badge variant="secondary" className="text-[10px]">required</Badge>}
|
|
{satisfied && <CheckCircle2 className="size-4 text-emerald-600" />}
|
|
</Label>
|
|
<Button type="button" size="sm" variant="outline" onClick={() => onPick(kind)} disabled={uploading}>
|
|
{uploading ? <Loader2 className="size-3.5 animate-spin" /> : icon}
|
|
{uploading ? "Uploading…" : cta}
|
|
</Button>
|
|
</div>
|
|
{matching.length === 0 ? (
|
|
<div className="rounded-md border border-dashed bg-muted/40 p-6 text-center text-xs text-muted-foreground flex flex-col items-center gap-1.5">
|
|
<ImageIcon className="size-5" />
|
|
{required ? `Tap the button above to add a ${label.toLowerCase()}` : `No ${label.toLowerCase()} yet`}
|
|
</div>
|
|
) : (
|
|
<div className={kind === "audio" ? "space-y-2" : "grid grid-cols-3 sm:grid-cols-4 gap-2"}>
|
|
{matching.map((m) => (
|
|
<div key={m.id} className="relative group rounded-md overflow-hidden border bg-muted">
|
|
<button
|
|
type="button"
|
|
onClick={() => onPreview(m as LightboxItem)}
|
|
className="block w-full"
|
|
>
|
|
{m.media_type === "photo" && m.url && (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={m.url} alt={m.caption ?? ""} className="block w-full aspect-square object-cover" />
|
|
)}
|
|
{m.media_type === "video" && m.url && (
|
|
<div className="relative w-full aspect-square">
|
|
<video src={m.url} className="block w-full h-full object-cover" />
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30 text-white">
|
|
<Video className="size-6" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{m.media_type === "audio" && m.url && (
|
|
<div className="p-2 w-full">
|
|
<audio src={m.url} controls className="w-full" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
<Badge variant="secondary" className="absolute top-1 left-1 text-[9px] capitalize">{m.media_type}</Badge>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDelete(m.id)}
|
|
className="absolute top-1 right-1 rounded-full bg-foreground/80 text-background p-1 opacity-0 group-hover:opacity-100 transition"
|
|
aria-label="Remove"
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|