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

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