- 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.
445 lines
22 KiB
TypeScript
445 lines
22 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useState } from "react"
|
||
import { useParams } from "next/navigation"
|
||
import {
|
||
AlertCircle,
|
||
Calendar,
|
||
Car,
|
||
CheckCircle2,
|
||
ChevronRight,
|
||
ClipboardList,
|
||
Gauge,
|
||
Loader2,
|
||
Printer,
|
||
ShieldAlert,
|
||
ShieldCheck,
|
||
User,
|
||
} from "lucide-react"
|
||
|
||
type Severity = "good" | "attention" | "critical" | "na" | "not_inspected"
|
||
|
||
type Photo = { id: number; url: string | null; caption?: string | null }
|
||
|
||
type CheckPoint = {
|
||
name: string
|
||
description?: string | null
|
||
severity: Severity
|
||
technician_notes?: string | null
|
||
condition_rate?: number | null
|
||
photos?: Photo[]
|
||
}
|
||
|
||
type Section = { name: string; items: CheckPoint[] }
|
||
|
||
type PublicInspection = {
|
||
title?: string
|
||
order_number?: string
|
||
date?: string
|
||
time?: string
|
||
status?: string
|
||
odometer?: number | null
|
||
note?: string | null
|
||
description?: string | null
|
||
customer?: { name?: string } | null
|
||
vehicle?: { make?: string; model?: string; year?: string | number; license_plate?: string; vin?: string } | null
|
||
technician?: { name?: string } | null
|
||
department?: string | null
|
||
template?: string | null
|
||
totals: { good: number; attention: number; critical: number; na: number; not_inspected: number; considered: number; all: number }
|
||
score: number | null
|
||
sections: Section[]
|
||
share_expires_at?: string | null
|
||
}
|
||
|
||
const SEVERITY_LABEL: Record<Severity, string> = {
|
||
good: "Good",
|
||
attention: "Attention",
|
||
critical: "Critical",
|
||
na: "N/A",
|
||
not_inspected: "Not Inspected",
|
||
}
|
||
|
||
const SEVERITY_DOT_BG: Record<Severity, string> = {
|
||
good: "bg-emerald-500",
|
||
attention: "bg-amber-500",
|
||
critical: "bg-rose-500",
|
||
na: "bg-slate-400",
|
||
not_inspected: "bg-gray-300",
|
||
}
|
||
|
||
const SEVERITY_PILL: Record<Severity, string> = {
|
||
good: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||
attention: "bg-amber-50 text-amber-800 border-amber-200",
|
||
critical: "bg-rose-50 text-rose-700 border-rose-200",
|
||
na: "bg-slate-50 text-slate-700 border-slate-200",
|
||
not_inspected: "bg-gray-50 text-gray-600 border-gray-200",
|
||
}
|
||
|
||
const SEVERITY_BORDER: Record<Severity, string> = {
|
||
good: "border-l-emerald-500",
|
||
attention: "border-l-amber-500",
|
||
critical: "border-l-rose-500",
|
||
na: "border-l-slate-400",
|
||
not_inspected: "border-l-gray-300",
|
||
}
|
||
|
||
function ScoreRing({ score }: { score: number | null }) {
|
||
if (score === null) {
|
||
return (
|
||
<div className="size-32 rounded-full border-4 border-dashed border-muted-foreground/30 flex items-center justify-center text-xs text-muted-foreground">
|
||
Not scored
|
||
</div>
|
||
)
|
||
}
|
||
const color =
|
||
score >= 80 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600"
|
||
const stroke =
|
||
score >= 80 ? "stroke-emerald-500" : score >= 50 ? "stroke-amber-500" : "stroke-rose-500"
|
||
const r = 56
|
||
const c = 2 * Math.PI * r
|
||
const offset = c - (score / 100) * c
|
||
|
||
return (
|
||
<div className="relative size-32 shrink-0">
|
||
<svg viewBox="0 0 128 128" className="size-32 -rotate-90">
|
||
<circle cx="64" cy="64" r={r} className="fill-none stroke-muted" strokeWidth="10" />
|
||
<circle
|
||
cx="64"
|
||
cy="64"
|
||
r={r}
|
||
className={`fill-none ${stroke} transition-[stroke-dashoffset]`}
|
||
strokeWidth="10"
|
||
strokeLinecap="round"
|
||
strokeDasharray={c}
|
||
strokeDashoffset={offset}
|
||
/>
|
||
</svg>
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||
<div className={`text-3xl font-bold leading-none ${color}`}>{score}</div>
|
||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mt-1">
|
||
Health
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function PublicInspectionPage() {
|
||
const params = useParams<{ token: string }>()
|
||
const token = params.token
|
||
const [data, setData] = useState<PublicInspection | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [lightbox, setLightbox] = useState<Photo | null>(null)
|
||
|
||
useEffect(() => {
|
||
const base = process.env.NEXT_PUBLIC_API_URL ?? ""
|
||
fetch(`${base.replace(/\/$/, "")}/api/public/inspections/${token}`, {
|
||
headers: { Accept: "application/json", "X-Lang": "en" },
|
||
})
|
||
.then(async (res) => {
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({} as any))
|
||
throw new Error(body.message ?? `Failed (HTTP ${res.status})`)
|
||
}
|
||
return res.json()
|
||
})
|
||
.then((json: { data: PublicInspection }) => setData(json.data))
|
||
.catch((e: Error) => setError(e.message))
|
||
.finally(() => setLoading(false))
|
||
}, [token])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
|
||
<div className="flex items-center gap-2 text-muted-foreground">
|
||
<Loader2 className="size-4 animate-spin" /> Loading inspection…
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-6">
|
||
<div className="max-w-md w-full text-center space-y-3 bg-white rounded-xl shadow-sm border p-8">
|
||
<AlertCircle className="size-12 mx-auto text-rose-500" />
|
||
<h1 className="text-lg font-semibold">Can't open this inspection</h1>
|
||
<p className="text-sm text-muted-foreground">{error}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!data) return null
|
||
|
||
const vehicleLine = data.vehicle
|
||
? [data.vehicle.year, data.vehicle.make, data.vehicle.model].filter(Boolean).join(" ")
|
||
: "—"
|
||
|
||
const criticalCount = data.totals.critical
|
||
const attentionCount = data.totals.attention
|
||
const headlineStatus =
|
||
criticalCount > 0
|
||
? { Icon: ShieldAlert, color: "text-rose-600", bg: "bg-rose-50", label: `${criticalCount} critical issue${criticalCount === 1 ? "" : "s"} found` }
|
||
: attentionCount > 0
|
||
? { Icon: AlertCircle, color: "text-amber-600", bg: "bg-amber-50", label: `${attentionCount} item${attentionCount === 1 ? "" : "s"} need attention` }
|
||
: { Icon: ShieldCheck, color: "text-emerald-600", bg: "bg-emerald-50", label: "No issues found" }
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||
{/* Print is handled by downloading the backend-generated PDF
|
||
(see the Print button), so this page no longer needs print CSS. */}
|
||
|
||
{/* Print bar */}
|
||
<div className="no-print bg-white/80 backdrop-blur border-b sticky top-0 z-10">
|
||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<ClipboardList className="size-4" />
|
||
<span className="font-medium text-foreground">Inspection report</span>
|
||
</div>
|
||
<a
|
||
href={`${(process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "")}/api/public/inspections/${token}/print`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm border bg-white hover:bg-muted/40 transition"
|
||
>
|
||
<Printer className="size-3.5" /> Print PDF
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-5">
|
||
{/* Hero card */}
|
||
<div className="rounded-2xl bg-white shadow-sm border overflow-hidden">
|
||
<div className="p-6 sm:p-8">
|
||
<div className="flex items-start justify-between gap-6 flex-wrap">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||
Vehicle inspection report
|
||
</div>
|
||
<h1 className="mt-1 text-2xl sm:text-3xl font-bold tracking-tight">{data.title ?? "Inspection"}</h1>
|
||
{data.order_number && (
|
||
<div className="text-sm text-muted-foreground font-mono mt-1">#{data.order_number}</div>
|
||
)}
|
||
|
||
<div className={`mt-4 inline-flex items-center gap-2 rounded-full ${headlineStatus.bg} px-4 py-1.5`}>
|
||
<headlineStatus.Icon className={`size-4 ${headlineStatus.color}`} />
|
||
<span className={`text-sm font-medium ${headlineStatus.color}`}>
|
||
{headlineStatus.label}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<ScoreRing score={data.score} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t bg-gray-50/60 px-6 sm:px-8 py-4">
|
||
<div className="info-grid grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||
<InfoCell icon={User} label="Customer" value={data.customer?.name ?? "—"} />
|
||
<InfoCell icon={Car} label="Vehicle" value={vehicleLine} subtitle={data.vehicle?.license_plate ?? undefined} />
|
||
<InfoCell icon={Calendar} label="Inspected" value={data.date ?? "—"} subtitle={data.time ?? undefined} />
|
||
<InfoCell icon={Gauge} label="Odometer" value={data.odometer != null ? `${Number(data.odometer).toLocaleString()} km` : "—"} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Severity summary */}
|
||
<div className="totals-grid grid grid-cols-2 sm:grid-cols-5 gap-2 pb-avoid">
|
||
{(["good", "attention", "critical", "na", "not_inspected"] as Severity[]).map((s) => (
|
||
<div
|
||
key={s}
|
||
className={`rounded-xl border px-3 py-3 text-center ${SEVERITY_PILL[s]}`}
|
||
>
|
||
<div className="text-xl font-bold leading-none tabular-nums">{data.totals[s]}</div>
|
||
<div className="text-[11px] font-medium mt-1.5">{SEVERITY_LABEL[s]}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Sections */}
|
||
{data.sections.length === 0 ? (
|
||
<div className="rounded-2xl bg-white border p-8 text-center text-sm text-muted-foreground">
|
||
This inspection has no recorded checkpoints yet.
|
||
</div>
|
||
) : (
|
||
data.sections.map((section) => (
|
||
<section key={section.name} className="rounded-2xl bg-white shadow-sm border overflow-hidden">
|
||
<header className="px-5 sm:px-6 py-3 border-b flex items-center justify-between bg-gray-50/60">
|
||
<h2 className="font-semibold text-sm sm:text-base">{section.name}</h2>
|
||
<span className="text-xs text-muted-foreground">
|
||
{section.items.length} {section.items.length === 1 ? "item" : "items"}
|
||
</span>
|
||
</header>
|
||
<ul className="divide-y">
|
||
{section.items.map((cp, i) => (
|
||
<li
|
||
key={i}
|
||
className={`cp-row pb-avoid px-5 sm:px-6 py-4 border-l-4 ${SEVERITY_BORDER[cp.severity]}`}
|
||
>
|
||
{/* Head: dot + name + severity pill (pill stays inline next to name) */}
|
||
<div className="cp-head flex items-center gap-2">
|
||
<span className={`size-2 rounded-full ${SEVERITY_DOT_BG[cp.severity]} shrink-0`} />
|
||
<span className="font-medium text-sm flex-1 min-w-0 truncate">{cp.name}</span>
|
||
<span className={`cp-pill inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium shrink-0 ${SEVERITY_PILL[cp.severity]}`}>
|
||
{SEVERITY_LABEL[cp.severity]}
|
||
</span>
|
||
</div>
|
||
<div className="cp-body ms-4">
|
||
{cp.description && (
|
||
<p className="text-xs text-muted-foreground mt-1">{cp.description}</p>
|
||
)}
|
||
{cp.technician_notes && (
|
||
<p className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-xs text-foreground/80 italic border-l-2 border-muted-foreground/30">
|
||
"{cp.technician_notes}"
|
||
</p>
|
||
)}
|
||
{cp.condition_rate != null && (
|
||
<div className="mt-2">
|
||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||
<div
|
||
className={`h-full transition-all ${cp.severity === "critical" ? "bg-rose-500" : cp.severity === "attention" ? "bg-amber-500" : "bg-emerald-500"}`}
|
||
style={{ width: `${cp.condition_rate}%` }}
|
||
/>
|
||
</div>
|
||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">{cp.condition_rate}%</div>
|
||
</div>
|
||
)}
|
||
{cp.photos && cp.photos.length > 0 && (
|
||
<div className="photo-grid mt-3 grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||
{cp.photos.map((p) => (
|
||
p.url ? (
|
||
<button
|
||
type="button"
|
||
key={p.id}
|
||
onClick={() => setLightbox(p)}
|
||
className="group relative rounded-lg overflow-hidden border bg-muted aspect-square focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||
aria-label="View photo"
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={p.url}
|
||
alt={p.caption ?? ""}
|
||
className="block w-full h-full object-cover transition group-hover:scale-105"
|
||
/>
|
||
</button>
|
||
) : null
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
))
|
||
)}
|
||
|
||
{(data.note || data.description) && (
|
||
<section className="rounded-2xl bg-white shadow-sm border p-5 sm:p-6">
|
||
<h2 className="font-semibold text-sm mb-3 flex items-center gap-2">
|
||
<ChevronRight className="size-4 text-muted-foreground" />
|
||
Additional notes from your technician
|
||
</h2>
|
||
{data.note && <p className="text-sm leading-relaxed">{data.note}</p>}
|
||
{data.description && (
|
||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">{data.description}</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* Technician footer */}
|
||
{data.technician?.name && (
|
||
<div className="rounded-2xl bg-white shadow-sm border px-5 sm:px-6 py-4 flex items-center justify-between gap-3 flex-wrap">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 text-white flex items-center justify-center font-semibold text-sm">
|
||
{data.technician.name
|
||
.split(" ")
|
||
.map((p) => p[0])
|
||
.filter(Boolean)
|
||
.slice(0, 2)
|
||
.join("")}
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-muted-foreground">Inspected by</div>
|
||
<div className="font-medium text-sm">{data.technician.name}</div>
|
||
</div>
|
||
</div>
|
||
{data.department && (
|
||
<span className="text-xs text-muted-foreground">{data.department}</span>
|
||
)}
|
||
{data.template && (
|
||
<span className="text-xs rounded-full border px-3 py-1 bg-muted/30">
|
||
{data.template}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<footer className="text-center text-xs text-muted-foreground py-6 no-print">
|
||
{data.share_expires_at && (
|
||
<div>Link valid until {new Date(data.share_expires_at).toLocaleDateString()}</div>
|
||
)}
|
||
</footer>
|
||
</div>
|
||
|
||
{/* Lightbox */}
|
||
{lightbox && lightbox.url && (
|
||
<div
|
||
className="no-print fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
||
onClick={() => setLightbox(null)}
|
||
role="dialog"
|
||
aria-modal
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={lightbox.url}
|
||
alt={lightbox.caption ?? ""}
|
||
className="max-w-full max-h-[88vh] object-contain shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setLightbox(null)}
|
||
className="absolute top-4 right-4 rounded-full bg-white/10 hover:bg-white/20 text-white size-9 inline-flex items-center justify-center"
|
||
aria-label="Close"
|
||
>
|
||
×
|
||
</button>
|
||
{lightbox.caption && (
|
||
<div className="absolute bottom-4 inset-x-0 text-center text-white/90 text-sm px-4">
|
||
{lightbox.caption}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InfoCell({
|
||
icon: Icon,
|
||
label,
|
||
value,
|
||
subtitle,
|
||
}: {
|
||
icon: React.ComponentType<{ className?: string }>
|
||
label: string
|
||
value: string
|
||
subtitle?: string
|
||
}) {
|
||
return (
|
||
<div className="flex items-start gap-2 min-w-0">
|
||
<Icon className="size-4 mt-0.5 text-muted-foreground shrink-0" />
|
||
<div className="min-w-0">
|
||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||
<div className="font-medium truncate">{value}</div>
|
||
{subtitle && (
|
||
<div className="text-xs text-muted-foreground font-mono truncate">{subtitle}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|