- 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.
459 lines
21 KiB
TypeScript
459 lines
21 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import Link from "next/link"
|
|
import { useParams } from "next/navigation"
|
|
import { toast } from "sonner"
|
|
import {
|
|
AlertTriangle,
|
|
Car,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
CircleCheck,
|
|
FileSignature,
|
|
Gauge,
|
|
Image as ImageIcon,
|
|
Loader2,
|
|
Share2,
|
|
StickyNote,
|
|
User,
|
|
} from "lucide-react"
|
|
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/shared/components/ui/card"
|
|
import { Progress } from "@/shared/components/ui/progress"
|
|
import { Separator } from "@/shared/components/ui/separator"
|
|
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
|
import { useAuthApi } from "@/shared/useApi"
|
|
import {
|
|
CheckpointFillDialog,
|
|
isCheckpointComplete,
|
|
type Checkpoint,
|
|
type Severity,
|
|
} from "@/modules/inspections/checkpoint-fill-dialog"
|
|
import { SignaturePad } from "@/modules/inspections/signature-pad"
|
|
import { InspectionShareDialog } from "@/modules/inspections/inspection-share-dialog"
|
|
|
|
type Inspection = {
|
|
id: number
|
|
title: string
|
|
order_number?: string | null
|
|
status?: string | null
|
|
odometer?: number | null
|
|
template?: { id: number; name: string } | null
|
|
customer?: { first_name?: string; last_name?: string } | null
|
|
vehicle?: { make?: string; model?: string; year?: string | number; license_plate?: string } | null
|
|
check_points: Checkpoint[]
|
|
technician_signature_url?: string | null
|
|
customer_signature_url?: string | null
|
|
}
|
|
|
|
const SEVERITY_DOT: Record<Severity, string> = {
|
|
good: "bg-emerald-500 shadow-emerald-500/30",
|
|
attention: "bg-amber-500 shadow-amber-500/30",
|
|
critical: "bg-rose-500 shadow-rose-500/30",
|
|
na: "bg-slate-400 shadow-slate-400/30",
|
|
not_inspected: "bg-muted shadow-none",
|
|
}
|
|
const SEVERITY_LABEL: Record<Severity, string> = {
|
|
good: "Good",
|
|
attention: "Attention",
|
|
critical: "Critical",
|
|
na: "N/A",
|
|
not_inspected: "Not inspected",
|
|
}
|
|
const SEVERITY_BADGE: Record<Severity, "default" | "secondary" | "destructive" | "outline"> = {
|
|
good: "default",
|
|
attention: "secondary",
|
|
critical: "destructive",
|
|
na: "outline",
|
|
not_inspected: "outline",
|
|
}
|
|
|
|
function groupBySection(cps: Checkpoint[]) {
|
|
const map = new Map<string, Checkpoint[]>()
|
|
for (const cp of cps) {
|
|
const key = cp.section_name || "Other"
|
|
if (!map.has(key)) map.set(key, [])
|
|
map.get(key)!.push(cp)
|
|
}
|
|
return Array.from(map.entries()).map(([name, items]) => ({ name, items }))
|
|
}
|
|
|
|
export default function InspectionCheckpointsPage() {
|
|
const params = useParams<{ id: string }>()
|
|
const id = params.id
|
|
const api = useAuthApi()
|
|
|
|
const [data, setData] = useState<Inspection | null>(null)
|
|
const [initialLoad, setInitialLoad] = useState(true)
|
|
const [refetching, setRefetching] = useState(false)
|
|
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
|
const [shareOpen, setShareOpen] = useState(false)
|
|
const [mode, setMode] = useState<"all" | "wizard">("all")
|
|
const [wizardSectionIdx, setWizardSectionIdx] = useState(0)
|
|
const [signing, setSigning] = useState<{ who: "technician" | "customer" } | null>(null)
|
|
|
|
const load = useCallback(async () => {
|
|
const isFirst = !data
|
|
if (isFirst) setInitialLoad(true)
|
|
else setRefetching(true)
|
|
try {
|
|
const res = await api.inspections.showOne(id)
|
|
setData(res.data as Inspection)
|
|
} catch (e: any) {
|
|
toast.error(e?.payload?.message ?? "Failed to load inspection")
|
|
} finally {
|
|
setInitialLoad(false)
|
|
setRefetching(false)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [id])
|
|
|
|
const patchCheckpoint = useCallback((checkpointId: number, patch: Partial<Checkpoint>) => {
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
check_points: prev.check_points.map((c) =>
|
|
c.id === checkpointId ? { ...c, ...patch } : c
|
|
),
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const sections = useMemo(() => data ? groupBySection(data.check_points ?? []) : [], [data])
|
|
const flatCheckpoints = useMemo(() => sections.flatMap((s) => s.items), [sections])
|
|
|
|
const totals = useMemo(() => {
|
|
const t = { good: 0, attention: 0, critical: 0, na: 0, not_inspected: 0 }
|
|
for (const cp of data?.check_points ?? []) {
|
|
const s = (cp.severity as Severity) ?? "not_inspected"
|
|
t[s]++
|
|
}
|
|
return t
|
|
}, [data])
|
|
|
|
const setSeverity = async (cp: Checkpoint, severity: Severity) => {
|
|
patchCheckpoint(cp.id, { severity })
|
|
try {
|
|
await api.inspections.updateCheckpoint(String(cp.id), { severity } as any)
|
|
} catch (e: any) {
|
|
toast.error(e?.payload?.message ?? "Failed to save")
|
|
load()
|
|
}
|
|
}
|
|
|
|
const handleSign = async (who: "technician" | "customer", dataUrl: string) => {
|
|
setSigning({ who })
|
|
try {
|
|
await api.inspections.sign(id, who, dataUrl)
|
|
toast.success(who === "technician" ? "Technician signature saved" : "Customer signature saved")
|
|
load()
|
|
} catch (e: any) {
|
|
toast.error(e?.payload?.message ?? "Failed to save signature")
|
|
} finally {
|
|
setSigning(null)
|
|
}
|
|
}
|
|
|
|
if (initialLoad) {
|
|
return (
|
|
<div className="min-h-[60vh] flex items-center justify-center">
|
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
<Loader2 className="size-4 animate-spin" /> Loading inspection…
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
if (!data) return <div className="p-6 text-muted-foreground">Inspection not found</div>
|
|
|
|
const vehicleLine = data.vehicle
|
|
? [data.vehicle.year, data.vehicle.make, data.vehicle.model].filter(Boolean).join(" ")
|
|
: "—"
|
|
const customerName = data.customer ? `${data.customer.first_name ?? ""} ${data.customer.last_name ?? ""}`.trim() : "—"
|
|
const totalCount = data.check_points.length
|
|
const inspectedCount = totalCount - totals.not_inspected
|
|
const progress = totalCount > 0 ? Math.round((inspectedCount / totalCount) * 100) : 0
|
|
|
|
const wizardSection = sections[wizardSectionIdx]
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
|
|
{/* Header */}
|
|
<Card>
|
|
<CardHeader className="gap-3">
|
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
<Link href={`/sales/inspections/${id}`}>
|
|
<Button variant="ghost" size="sm" className="-ms-2">
|
|
<ChevronLeft className="size-4" /> Back to inspection
|
|
</Button>
|
|
</Link>
|
|
<div className="flex items-center gap-2">
|
|
{refetching && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
|
|
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
|
|
<Share2 className="size-4" /> Share with customer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<Separator />
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div className="min-w-0">
|
|
<CardDescription className="uppercase tracking-wide text-[11px]">Inspection</CardDescription>
|
|
<CardTitle className="text-2xl">{data.title}</CardTitle>
|
|
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground flex-wrap">
|
|
<span className="inline-flex items-center gap-1.5"><User className="size-3.5" /> {customerName}</span>
|
|
<span className="inline-flex items-center gap-1.5"><Car className="size-3.5" /> {vehicleLine}</span>
|
|
{data.vehicle?.license_plate && (
|
|
<Badge variant="outline" className="font-mono">{data.vehicle.license_plate}</Badge>
|
|
)}
|
|
{data.odometer != null && (
|
|
<span className="inline-flex items-center gap-1.5"><Gauge className="size-3.5" /> {Number(data.odometer).toLocaleString()} km</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="gap-3">
|
|
{/* Progress */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>Progress</span>
|
|
<span className="tabular-nums">{inspectedCount} / {totalCount} ({progress}%)</span>
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
|
|
{/* Severity tally */}
|
|
<div className="flex items-center gap-2 flex-wrap mt-3">
|
|
{(["good", "attention", "critical", "na", "not_inspected"] as Severity[]).map((s) => (
|
|
<Badge key={s} variant={SEVERITY_BADGE[s]} className="gap-1.5">
|
|
<span className={`size-1.5 rounded-full ${SEVERITY_DOT[s].split(" ")[0]}`} />
|
|
{totals[s]} {SEVERITY_LABEL[s]}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-3 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground flex items-center gap-3 flex-wrap">
|
|
<span className="font-medium text-foreground">Tap a dot to mark:</span>
|
|
{(["good", "attention", "critical", "na"] as Severity[]).map((s) => (
|
|
<span key={s} className="inline-flex items-center gap-1.5">
|
|
<span className={`size-3 rounded-full ${SEVERITY_DOT[s].split(" ")[0]} shadow-md`} />
|
|
{SEVERITY_LABEL[s]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Mode tabs */}
|
|
{totalCount > 0 && (
|
|
<Tabs value={mode} onValueChange={(v) => setMode(v as any)} className="w-full">
|
|
<TabsList className="w-full sm:w-auto">
|
|
<TabsTrigger value="all">All sections</TabsTrigger>
|
|
<TabsTrigger value="wizard">Wizard</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
)}
|
|
|
|
{/* Sections */}
|
|
{totalCount === 0 ? (
|
|
<Card>
|
|
<CardContent className="py-10 text-center">
|
|
<CircleCheck className="size-8 mx-auto text-muted-foreground/60 mb-3" />
|
|
<p className="text-sm text-muted-foreground">
|
|
This inspection has no checkpoints yet. It was probably created from an empty template.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : mode === "all" ? (
|
|
sections.map((section) => (
|
|
<Card key={section.name}>
|
|
<CardHeader className="py-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">{section.name}</CardTitle>
|
|
<Badge variant="outline" className="font-normal">
|
|
{section.items.filter(isCheckpointComplete).length} / {section.items.length} done
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<ul className="divide-y">
|
|
{section.items.map((cp) => (
|
|
<CheckpointRow key={cp.id} cp={cp} onSeverity={setSeverity} onOpen={() => setActiveIndex(flatCheckpoints.findIndex((c) => c.id === cp.id))} />
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
) : (
|
|
wizardSection && (() => {
|
|
const pendingItems = wizardSection.items.filter((cp) => !isCheckpointComplete(cp))
|
|
const sectionDone = pendingItems.length === 0
|
|
return (
|
|
<Card>
|
|
<CardHeader className="py-3 gap-1">
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<CardTitle className="text-base">{wizardSection.name}</CardTitle>
|
|
<Badge variant="outline" className="font-normal">
|
|
Section {wizardSectionIdx + 1} / {sections.length}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<ul className="divide-y">
|
|
{wizardSection.items.map((cp) => (
|
|
<CheckpointRow key={cp.id} cp={cp} onSeverity={setSeverity} onOpen={() => setActiveIndex(flatCheckpoints.findIndex((c) => c.id === cp.id))} />
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
<div className="px-4 py-3 border-t bg-muted/30 space-y-2">
|
|
{!sectionDone && (
|
|
<div className="flex items-start gap-2 text-xs text-amber-900">
|
|
<AlertTriangle className="size-3.5 mt-0.5 shrink-0 text-amber-600" />
|
|
<div>
|
|
<span className="font-medium">{pendingItems.length} pending.</span>{' '}
|
|
Each checkpoint needs a finding and its required recording before you can move on.
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={wizardSectionIdx === 0}
|
|
onClick={() => setWizardSectionIdx((i) => Math.max(0, i - 1))}
|
|
>
|
|
<ChevronLeft className="size-4" /> Previous
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={wizardSectionIdx >= sections.length - 1 || !sectionDone}
|
|
onClick={() => setWizardSectionIdx((i) => Math.min(sections.length - 1, i + 1))}
|
|
>
|
|
Next <ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)
|
|
})()
|
|
)}
|
|
|
|
{/* Signatures */}
|
|
{totalCount > 0 && (
|
|
<Card>
|
|
<CardHeader className="py-3 flex-row items-center gap-2">
|
|
<FileSignature className="size-4 text-muted-foreground" />
|
|
<CardTitle className="text-base">Sign-off</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="gap-3 grid grid-cols-1 md:grid-cols-2">
|
|
<SignaturePad
|
|
label="Technician signature"
|
|
existingUrl={data.technician_signature_url}
|
|
saving={signing?.who === "technician"}
|
|
onSave={(d) => handleSign("technician", d)}
|
|
/>
|
|
<SignaturePad
|
|
label="Customer signature"
|
|
existingUrl={data.customer_signature_url}
|
|
saving={signing?.who === "customer"}
|
|
onSave={(d) => handleSign("customer", d)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<CheckpointFillDialog
|
|
open={activeIndex !== null}
|
|
onOpenChange={(o) => { if (!o) setActiveIndex(null) }}
|
|
checkpoints={flatCheckpoints}
|
|
activeIndex={activeIndex}
|
|
onIndexChange={setActiveIndex}
|
|
onSaved={load}
|
|
onPatch={patchCheckpoint}
|
|
/>
|
|
|
|
<InspectionShareDialog
|
|
open={shareOpen}
|
|
onOpenChange={setShareOpen}
|
|
inspectionId={id}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CheckpointRow({
|
|
cp,
|
|
onSeverity,
|
|
onOpen,
|
|
}: {
|
|
cp: Checkpoint
|
|
onSeverity: (cp: Checkpoint, s: Severity) => void
|
|
onOpen: () => void
|
|
}) {
|
|
const current = (cp.severity as Severity) ?? "not_inspected"
|
|
const attachments = cp.attachments ?? cp.media ?? []
|
|
const photoCount = attachments.filter((m) => m.media_type === "photo").length
|
|
const complete = isCheckpointComplete(cp)
|
|
return (
|
|
<li
|
|
className={`px-3 sm:px-4 py-3 transition-colors hover:bg-muted/30 ${!complete ? "bg-amber-50/40" : ""}`}
|
|
>
|
|
<div className="flex items-start gap-3 flex-wrap sm:flex-nowrap">
|
|
<button
|
|
type="button"
|
|
onClick={onOpen}
|
|
className="flex-1 min-w-0 text-left group"
|
|
>
|
|
<div className="font-medium text-sm flex items-center gap-2">
|
|
<span className="group-hover:text-primary transition">{cp.name}</span>
|
|
{!complete && (
|
|
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
|
|
Pending
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{cp.technician_notes && (
|
|
<div className="flex items-start gap-1 text-xs text-muted-foreground mt-1">
|
|
<StickyNote className="size-3 mt-0.5 shrink-0" />
|
|
<span className="italic line-clamp-1">{cp.technician_notes}</span>
|
|
</div>
|
|
)}
|
|
{photoCount > 0 && (
|
|
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
|
<ImageIcon className="size-3" /> {photoCount} photo{photoCount === 1 ? "" : "s"}
|
|
</div>
|
|
)}
|
|
</button>
|
|
<div className="flex gap-2 justify-end shrink-0 w-full sm:w-auto">
|
|
{(["good", "attention", "critical", "na"] as Severity[]).map((s) => {
|
|
const active = current === s
|
|
return (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); onSeverity(cp, s) }}
|
|
aria-label={SEVERITY_LABEL[s]}
|
|
title={SEVERITY_LABEL[s]}
|
|
className={`size-10 sm:size-9 rounded-full transition shadow-md ${SEVERITY_DOT[s]} ${active ? "ring-2 ring-offset-2 ring-foreground/40 scale-105" : "opacity-60 hover:opacity-100"}`}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|