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.
This commit is contained in:
@@ -55,6 +55,8 @@ export default function DepartmentsPage() {
|
||||
<ResourcePage<DepartmentsClient>
|
||||
pageTitle="Departments"
|
||||
routeKey={DEPARTMENT_ROUTES.INDEX}
|
||||
searchable
|
||||
searchPlaceholder="Search departments..."
|
||||
getClient={(api) => api.departments}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ChevronLeft, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Switch } from "@/shared/components/ui/switch"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { prompt } from "@/shared/components/prompt-dialog"
|
||||
import { TemplateCheckpointEditDialog } from "@/modules/inspections/template-checkpoint-edit-dialog"
|
||||
import type {
|
||||
InspectionTemplate,
|
||||
InspectionTemplateSection,
|
||||
InspectionTemplateCheckPoint,
|
||||
InspectionSeverity,
|
||||
} from "@garage/api"
|
||||
|
||||
const SEVERITY_BADGE: Record<InspectionSeverity, { label: string; cls: string }> = {
|
||||
good: { label: "Good", cls: "bg-emerald-100 text-emerald-800" },
|
||||
attention: { label: "Attention", cls: "bg-amber-100 text-amber-800" },
|
||||
critical: { label: "Critical", cls: "bg-rose-100 text-rose-800" },
|
||||
na: { label: "N/A", cls: "bg-slate-100 text-slate-700" },
|
||||
not_inspected: { label: "Not inspected", cls: "bg-gray-100 text-gray-700" },
|
||||
}
|
||||
|
||||
const RECORD_TYPE_LABEL: Record<string, string> = {
|
||||
capture_photo: "Photo",
|
||||
record_video: "Video",
|
||||
record_audio: "Audio",
|
||||
record_conditions: "0–100",
|
||||
wire_frame: "Wireframe",
|
||||
none: "No media",
|
||||
}
|
||||
|
||||
export default function InspectionTemplateEditorPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const id = params.id
|
||||
const api = useAuthApi()
|
||||
const [template, setTemplate] = useState<InspectionTemplate | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [editing, setEditing] = useState<{
|
||||
section: InspectionTemplateSection
|
||||
checkpoint: InspectionTemplateCheckPoint | null
|
||||
} | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.inspectionTemplates.show(id)
|
||||
setTemplate(res.data)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to load template")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const updateMeta = async (partial: Partial<InspectionTemplate>) => {
|
||||
if (!template) return
|
||||
try {
|
||||
const res = await api.inspectionTemplates.update(template.id, partial)
|
||||
setTemplate(res.data)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to save")
|
||||
}
|
||||
}
|
||||
|
||||
const addSection = async () => {
|
||||
if (!template) return
|
||||
const name = await prompt({
|
||||
title: "New section",
|
||||
label: "Section name",
|
||||
placeholder: "e.g. Brakes",
|
||||
confirmLabel: "Add section",
|
||||
})
|
||||
if (!name) return
|
||||
const nextOrder = (template.sections?.length ?? 0) + 1
|
||||
try {
|
||||
await api.inspectionTemplates.createSection(template.id, { name, sort_order: nextOrder })
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to add section")
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSection = async (section: InspectionTemplateSection) => {
|
||||
if (!template) return
|
||||
const confirmed = await confirm({
|
||||
title: `Delete section "${section.name}"?`,
|
||||
description: "This will remove the section and all its checkpoints.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await api.inspectionTemplates.destroySection(template.id, section.id)
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to delete section")
|
||||
}
|
||||
}
|
||||
|
||||
const renameSection = async (section: InspectionTemplateSection, name: string) => {
|
||||
if (!template || name === section.name) return
|
||||
try {
|
||||
await api.inspectionTemplates.updateSection(template.id, section.id, { name })
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to rename section")
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCheckpoint = async (section: InspectionTemplateSection, cp: InspectionTemplateCheckPoint, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!template) return
|
||||
const confirmed = await confirm({
|
||||
title: `Delete "${cp.name}"?`,
|
||||
description: "This checkpoint will be removed from the template.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await api.inspectionTemplates.destroyCheckpoint(template.id, section.id, cp.id)
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to delete checkpoint")
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-6 text-muted-foreground">Loading…</div>
|
||||
if (!template) return <div className="p-6 text-muted-foreground">Template not found</div>
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-4xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/settings/inspection-templates">
|
||||
<Button variant="ghost" size="sm"><ChevronLeft className="size-4 mr-1" /> Back</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Name</label>
|
||||
<Input
|
||||
defaultValue={template.name}
|
||||
onBlur={(e) => e.currentTarget.value !== template.name && updateMeta({ name: e.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-5">
|
||||
<Switch
|
||||
checked={template.is_active}
|
||||
onCheckedChange={(checked) => updateMeta({ is_active: checked })}
|
||||
/>
|
||||
<span className="text-sm">{template.is_active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Description</label>
|
||||
<Textarea
|
||||
defaultValue={template.description ?? ""}
|
||||
rows={2}
|
||||
onBlur={(e) => e.currentTarget.value !== (template.description ?? "") && updateMeta({ description: e.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Sections & Checkpoints</h2>
|
||||
<Button onClick={addSection} size="sm"><Plus className="size-4 mr-1" /> Add section</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(template.sections ?? []).length === 0 && (
|
||||
<div className="rounded border-dashed border p-6 text-center text-sm text-muted-foreground">
|
||||
No sections yet. Add one to start building the checklist.
|
||||
</div>
|
||||
)}
|
||||
{(template.sections ?? []).map((section) => (
|
||||
<div key={section.id} className="rounded border">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/40">
|
||||
<Input
|
||||
defaultValue={section.name}
|
||||
onBlur={(e) => renameSection(section, e.currentTarget.value)}
|
||||
className="max-w-md h-8"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(section.check_points ?? []).length} checkpoint{(section.check_points ?? []).length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing({ section, checkpoint: null })}>
|
||||
<Plus className="size-4 mr-1" /> Checkpoint
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => deleteSection(section)}>
|
||||
<Trash2 className="size-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{(section.check_points ?? []).length === 0 && (
|
||||
<div className="p-3 text-sm text-muted-foreground">No checkpoints in this section.</div>
|
||||
)}
|
||||
{(section.check_points ?? []).map((cp) => {
|
||||
const sev = SEVERITY_BADGE[cp.severity_default] ?? SEVERITY_BADGE.not_inspected
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={cp.id}
|
||||
onClick={() => setEditing({ section, checkpoint: cp })}
|
||||
className="w-full text-left flex items-center gap-3 px-3 py-2 hover:bg-muted/30 transition"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground w-6 text-right">
|
||||
{cp.sort_order}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-sm font-medium">
|
||||
{cp.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline-block">
|
||||
{RECORD_TYPE_LABEL[cp.record_type] ?? cp.record_type}
|
||||
</span>
|
||||
<span className={`text-xs rounded-full px-2 py-0.5 ${sev.cls}`}>
|
||||
{sev.label}
|
||||
</span>
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => deleteCheckpoint(section, cp, e)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
aria-label="Delete checkpoint"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<TemplateCheckpointEditDialog
|
||||
open={!!editing}
|
||||
onOpenChange={(o) => { if (!o) setEditing(null) }}
|
||||
templateId={template.id}
|
||||
sectionId={editing.section.id}
|
||||
checkpoint={editing.checkpoint}
|
||||
nextSortOrder={(editing.section.check_points?.length ?? 0) + 1}
|
||||
onSaved={load}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Copy, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import type { InspectionTemplate } from "@garage/api"
|
||||
|
||||
export default function InspectionTemplatesPage() {
|
||||
const api = useAuthApi()
|
||||
const [items, setItems] = useState<InspectionTemplate[]>([])
|
||||
const [search, setSearch] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState("")
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.inspectionTemplates.list(search ? { search } : undefined)
|
||||
setItems(res.data ?? [])
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to load templates")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return
|
||||
try {
|
||||
await api.inspectionTemplates.create({ name: newName.trim(), is_active: true })
|
||||
setNewName("")
|
||||
setCreating(false)
|
||||
toast.success("Template created")
|
||||
load()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to create template")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = async (id: number) => {
|
||||
try {
|
||||
await api.inspectionTemplates.duplicate(id)
|
||||
toast.success("Template duplicated")
|
||||
load()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to duplicate template")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
const confirmed = await confirm({
|
||||
title: `Delete "${name}"?`,
|
||||
description: "This will remove the template and all its sections / checkpoints.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await api.inspectionTemplates.destroy(id)
|
||||
toast.success("Template deleted")
|
||||
load()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to delete template")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-2xl font-semibold">Inspection Templates</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && load()}
|
||||
className="w-56"
|
||||
/>
|
||||
<Button onClick={() => setCreating((c) => !c)}>
|
||||
<Plus className="size-4 mr-1" /> New template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="flex items-center gap-2 rounded border p-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Template name (e.g. Annual Safety)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<Button onClick={handleCreate}>Create</Button>
|
||||
<Button variant="ghost" onClick={() => { setCreating(false); setNewName("") }}>Cancel</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="text-left p-3">Name</th>
|
||||
<th className="text-left p-3">Sections</th>
|
||||
<th className="text-left p-3">Checkpoints</th>
|
||||
<th className="text-left p-3">Status</th>
|
||||
<th className="text-right p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={5} className="p-6 text-center text-muted-foreground">Loading…</td></tr>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={5} className="p-6 text-center text-muted-foreground">No templates yet</td></tr>
|
||||
)}
|
||||
{!loading && items.map((t) => {
|
||||
const sectionCount = t.sections?.length ?? 0
|
||||
const cpCount = (t.sections ?? []).reduce((sum, s) => sum + (s.check_points?.length ?? 0), 0)
|
||||
return (
|
||||
<tr key={t.id} className="border-t hover:bg-muted/40">
|
||||
<td className="p-3 font-medium">{t.name}</td>
|
||||
<td className="p-3">{sectionCount}</td>
|
||||
<td className="p-3">{cpCount}</td>
|
||||
<td className="p-3">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs ${t.is_active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"}`}>
|
||||
{t.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-right space-x-1">
|
||||
<Link href={`/settings/inspection-templates/${t.id}`}>
|
||||
<Button size="sm" variant="ghost"><Pencil className="size-4" /></Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDuplicate(t.id)}><Copy className="size-4" /></Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDelete(t.id, t.name)}><Trash2 className="size-4 text-red-600" /></Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export default function InsuranceTypesPage() {
|
||||
<ResourcePage<InsuranceTypesClient>
|
||||
pageTitle="Insurance Types"
|
||||
routeKey={INSURANCE_TYPE_ROUTES.INDEX}
|
||||
searchable
|
||||
searchPlaceholder="Search insurance types..."
|
||||
getClient={(api) => api.insuranceTypes}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
|
||||
@@ -12,6 +12,8 @@ export default function MakeAndModelsPage() {
|
||||
<ResourcePage<MakeAndModelsClient>
|
||||
pageTitle="Make & Models"
|
||||
routeKey={MAKE_AND_MODEL_ROUTES.INDEX}
|
||||
searchable
|
||||
searchPlaceholder="Search makes & models..."
|
||||
getClient={(api) => api.makeAndModels}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
|
||||
@@ -13,6 +13,8 @@ export default function ShopTypesPage() {
|
||||
<ResourcePage<ShopTypesClient>
|
||||
pageTitle="Shop Types"
|
||||
routeKey={SHOP_TYPE_ROUTES.INDEX}
|
||||
searchable
|
||||
searchPlaceholder="Search shop types..."
|
||||
getClient={(api) => api.shopTypes}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
|
||||
@@ -55,6 +55,8 @@ export default function TaxesPage() {
|
||||
<ResourcePage<TaxesClient>
|
||||
pageTitle="Tax & Rates"
|
||||
routeKey={TAX_ROUTES.INDEX}
|
||||
searchable
|
||||
searchPlaceholder="Search tax rates..."
|
||||
getClient={(api) => api.taxes}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
|
||||
Reference in New Issue
Block a user