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

445 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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