- 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.
220 lines
8.8 KiB
TypeScript
220 lines
8.8 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useState } from "react"
|
||
import { toast } from "sonner"
|
||
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/shared/components/ui/dialog"
|
||
import { Button } from "@/shared/components/ui/button"
|
||
import { Input } from "@/shared/components/ui/input"
|
||
import { Textarea } from "@/shared/components/ui/textarea"
|
||
import { Label } from "@/shared/components/ui/label"
|
||
import { useAuthApi } from "@/shared/useApi"
|
||
import type {
|
||
InspectionSeverity,
|
||
InspectionTemplateCheckPoint,
|
||
InspectionTemplateCheckPointRecordType,
|
||
} from "@garage/api"
|
||
|
||
const RECORD_TYPE_OPTIONS: { value: InspectionTemplateCheckPointRecordType; label: string }[] = [
|
||
{ value: "none", label: "No media" },
|
||
{ value: "capture_photo", label: "Capture photo" },
|
||
{ value: "record_video", label: "Record video" },
|
||
{ value: "record_audio", label: "Record audio" },
|
||
{ value: "record_conditions", label: "Condition rating (0–100)" },
|
||
{ value: "wire_frame", label: "Wireframe diagram" },
|
||
]
|
||
|
||
const SEVERITY_OPTIONS: { value: InspectionSeverity; label: string; cls: string }[] = [
|
||
{ value: "not_inspected", label: "Not inspected", cls: "bg-gray-100 text-gray-700" },
|
||
{ value: "good", label: "Good", cls: "bg-emerald-100 text-emerald-800" },
|
||
{ value: "attention", label: "Needs attention", cls: "bg-amber-100 text-amber-800" },
|
||
{ value: "critical", label: "Critical", cls: "bg-rose-100 text-rose-800" },
|
||
{ value: "na", label: "Not applicable", cls: "bg-slate-100 text-slate-700" },
|
||
]
|
||
|
||
export type CheckpointDraft = {
|
||
name: string
|
||
description: string
|
||
record_type: InspectionTemplateCheckPointRecordType
|
||
severity_default: InspectionSeverity
|
||
sort_order: number
|
||
}
|
||
|
||
export function TemplateCheckpointEditDialog({
|
||
open,
|
||
onOpenChange,
|
||
templateId,
|
||
sectionId,
|
||
checkpoint,
|
||
nextSortOrder,
|
||
onSaved,
|
||
}: {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
templateId: number
|
||
sectionId: number
|
||
/** When set, edit this checkpoint. When null, create new. */
|
||
checkpoint: InspectionTemplateCheckPoint | null
|
||
nextSortOrder?: number
|
||
onSaved: () => void
|
||
}) {
|
||
const api = useAuthApi()
|
||
const isEdit = !!checkpoint
|
||
|
||
const [name, setName] = useState("")
|
||
const [description, setDescription] = useState("")
|
||
const [recordType, setRecordType] = useState<InspectionTemplateCheckPointRecordType>("capture_photo")
|
||
const [severity, setSeverity] = useState<InspectionSeverity>("not_inspected")
|
||
const [sortOrder, setSortOrder] = useState<number>(1)
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
if (checkpoint) {
|
||
setName(checkpoint.name)
|
||
setDescription(checkpoint.description ?? "")
|
||
setRecordType(checkpoint.record_type)
|
||
setSeverity(checkpoint.severity_default)
|
||
setSortOrder(checkpoint.sort_order)
|
||
} else {
|
||
setName("")
|
||
setDescription("")
|
||
setRecordType("capture_photo")
|
||
setSeverity("not_inspected")
|
||
setSortOrder(nextSortOrder ?? 1)
|
||
}
|
||
}, [open, checkpoint, nextSortOrder])
|
||
|
||
const submit = async () => {
|
||
if (!name.trim()) {
|
||
toast.error("Name is required")
|
||
return
|
||
}
|
||
setSaving(true)
|
||
try {
|
||
const payload = {
|
||
name: name.trim(),
|
||
description: description.trim() || undefined,
|
||
record_type: recordType,
|
||
severity_default: severity,
|
||
sort_order: sortOrder,
|
||
}
|
||
if (isEdit && checkpoint) {
|
||
await api.inspectionTemplates.updateCheckpoint(templateId, sectionId, checkpoint.id, payload)
|
||
toast.success("Checkpoint updated")
|
||
} else {
|
||
await api.inspectionTemplates.createCheckpoint(templateId, sectionId, payload)
|
||
toast.success("Checkpoint added")
|
||
}
|
||
onSaved()
|
||
onOpenChange(false)
|
||
} catch (e: any) {
|
||
const errors = e?.payload?.errors
|
||
const firstError = errors && typeof errors === "object"
|
||
? (Object.values(errors)[0] as string[])?.[0]
|
||
: null
|
||
toast.error(firstError ?? e?.message ?? (isEdit ? "Failed to update checkpoint" : "Failed to add checkpoint"))
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>{isEdit ? "Edit checkpoint" : "Add checkpoint"}</DialogTitle>
|
||
<DialogDescription>
|
||
Define what the technician will check and how the finding is recorded.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-3">
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="cp-name">Name</Label>
|
||
<Input
|
||
id="cp-name"
|
||
autoFocus
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="e.g. Front-left brake pad thickness"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="cp-description">Description (optional)</Label>
|
||
<Textarea
|
||
id="cp-description"
|
||
value={description}
|
||
rows={2}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="Brief guidance for the technician"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-1.5">
|
||
<Label>How it's recorded</Label>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{RECORD_TYPE_OPTIONS.map((opt) => (
|
||
<button
|
||
type="button"
|
||
key={opt.value}
|
||
onClick={() => setRecordType(opt.value)}
|
||
className={`rounded border px-2 py-1.5 text-xs text-left transition ${
|
||
recordType === opt.value ? "border-primary bg-primary/5 text-foreground" : "text-muted-foreground hover:border-foreground/30"
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-1.5">
|
||
<Label>Default severity (when not yet inspected)</Label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{SEVERITY_OPTIONS.map((opt) => (
|
||
<button
|
||
type="button"
|
||
key={opt.value}
|
||
onClick={() => setSeverity(opt.value)}
|
||
className={`rounded-full px-3 py-1 text-xs font-medium transition border ${
|
||
severity === opt.value ? "border-primary ring-1 ring-primary " + opt.cls : opt.cls + " border-transparent opacity-70 hover:opacity-100"
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-1.5 max-w-[160px]">
|
||
<Label htmlFor="cp-order">Sort order</Label>
|
||
<Input
|
||
id="cp-order"
|
||
type="number"
|
||
min={0}
|
||
value={sortOrder}
|
||
onChange={(e) => setSortOrder(parseInt(e.target.value || "0", 10))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>Cancel</Button>
|
||
<Button onClick={submit} disabled={saving || !name.trim()}>
|
||
{saving ? "Saving…" : isEdit ? "Save changes" : "Add checkpoint"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|