garage-erp/apps/dashboard/modules/inspections/checkpoint-fill-dialog.tsx
humam kerdiah 4bfd8c84a9 feat: add template checkpoint edit dialog and vendor management components
- 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.
2026-05-18 12:08:42 +04:00

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>
)
}