- 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.
263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { useForm } from "react-hook-form"
|
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
import { z } from "zod"
|
|
import { toast } from "sonner"
|
|
import { ClipboardList, Plus } from "lucide-react"
|
|
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { FieldGroup } from "@/shared/components/ui/field"
|
|
import {
|
|
Rhform,
|
|
RhfTextField,
|
|
RhfTextareaField,
|
|
RhfDateField,
|
|
RhfTimeField,
|
|
} from "@/shared/components/form"
|
|
import { useAuthApi } from "@/shared/useApi"
|
|
import type { InspectionTemplate } from "@garage/api"
|
|
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
|
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
|
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
|
|
import { RhfAsyncSelectField } from "@/shared/components/form"
|
|
import { DEPARTMENT_ROUTES } from "@garage/api"
|
|
import { useFormContext, useWatch } from "react-hook-form"
|
|
import { useEffect as useReactEffect, useRef } from "react"
|
|
|
|
const relationFieldSchema = z
|
|
.object({ value: z.string(), label: z.string() })
|
|
.nullable()
|
|
|
|
const schema = z.object({
|
|
customer: relationFieldSchema.refine((v) => !!v?.value, "Customer is required"),
|
|
vehicle: relationFieldSchema.refine((v) => !!v?.value, "Vehicle is required"),
|
|
department: relationFieldSchema.refine((v) => !!v?.value, "Department is required"),
|
|
employee: relationFieldSchema.refine((v) => !!v?.value, "Employee is required"),
|
|
title: z.string().min(1, "Title is required").max(100),
|
|
order_number: z.string().min(1, "Order number is required").max(100),
|
|
date: z.string().min(1, "Date is required"),
|
|
time: z.string().min(1, "Time is required"),
|
|
odometer: z.coerce.number().int().min(0).optional().or(z.literal("")).transform(v => v === "" ? undefined : v),
|
|
note: z.string().optional(),
|
|
})
|
|
|
|
type FormValues = z.infer<typeof schema>
|
|
|
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
|
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name ?? item.title ?? String(item.id) })
|
|
|
|
// Scope vehicle picker to selected customer
|
|
function CustomerScopedVehicleField() {
|
|
const { setValue, control, getValues } = useFormContext<FormValues>()
|
|
const customer = useWatch({ control, name: "customer" })
|
|
const customerId = customer?.value ?? null
|
|
const lastRef = useRef<string | null>(customerId)
|
|
|
|
useReactEffect(() => {
|
|
if (lastRef.current !== customerId) {
|
|
if (getValues("vehicle")) {
|
|
setValue("vehicle", null, { shouldDirty: true, shouldValidate: false })
|
|
}
|
|
lastRef.current = customerId
|
|
}
|
|
}, [customerId, setValue, getValues])
|
|
|
|
return (
|
|
<RhfVehicleSelectField
|
|
name="vehicle"
|
|
customer_id={customerId}
|
|
disabled={!customerId}
|
|
placeholder={customerId ? "Search this customer's vehicles…" : "Select a customer first"}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function InspectionFromTemplateForm({ onSuccess }: { onSuccess?: (inspectionId: number) => void }) {
|
|
const api = useAuthApi()
|
|
const [step, setStep] = useState<"pick-template" | "fill-details">("pick-template")
|
|
const [templates, setTemplates] = useState<InspectionTemplate[]>([])
|
|
const [selectedTemplate, setSelectedTemplate] = useState<InspectionTemplate | null>(null)
|
|
const [loadingTemplates, setLoadingTemplates] = useState(true)
|
|
|
|
const form = useForm<FormValues>({
|
|
resolver: zodResolver(schema) as any,
|
|
defaultValues: {
|
|
customer: null,
|
|
vehicle: null,
|
|
department: null,
|
|
employee: null,
|
|
title: "",
|
|
order_number: "",
|
|
date: new Date().toISOString().slice(0, 10),
|
|
time: new Date().toTimeString().slice(0, 8),
|
|
odometer: undefined,
|
|
note: "",
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoadingTemplates(true)
|
|
api.inspectionTemplates.list({ is_active: true }).then((res) => {
|
|
if (!cancelled) {
|
|
setTemplates(res.data ?? [])
|
|
setLoadingTemplates(false)
|
|
}
|
|
}).catch((e: any) => {
|
|
if (!cancelled) {
|
|
toast.error(e?.message ?? "Failed to load templates")
|
|
setLoadingTemplates(false)
|
|
}
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [])
|
|
|
|
const pickTemplate = (t: InspectionTemplate) => {
|
|
setSelectedTemplate(t)
|
|
form.setValue("title", t.name)
|
|
setStep("fill-details")
|
|
}
|
|
|
|
const handleSubmit = async (values: FormValues) => {
|
|
if (!selectedTemplate) return
|
|
try {
|
|
const res = await api.inspections.createFromTemplate({
|
|
template_id: selectedTemplate.id,
|
|
title: values.title,
|
|
customer_id: values.customer!.value,
|
|
vehicle_id: values.vehicle!.value,
|
|
department_id: values.department!.value,
|
|
employee_id: values.employee!.value,
|
|
order_number: values.order_number,
|
|
date: values.date,
|
|
time: values.time,
|
|
odometer: values.odometer as any,
|
|
note: values.note || undefined,
|
|
})
|
|
const created = (res as any)?.data
|
|
toast.success("Inspection created with checkpoints from template")
|
|
onSuccess?.(created?.id)
|
|
} 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 ?? "Failed to create inspection")
|
|
}
|
|
}
|
|
|
|
if (step === "pick-template") {
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<h3 className="text-sm font-medium">Choose an inspection template</h3>
|
|
<p className="text-xs text-muted-foreground">
|
|
The selected template's sections and checkpoints will be cloned onto the new inspection.
|
|
</p>
|
|
</div>
|
|
{loadingTemplates && (
|
|
<div className="rounded border-dashed border p-6 text-center text-sm text-muted-foreground">Loading templates…</div>
|
|
)}
|
|
{!loadingTemplates && templates.length === 0 && (
|
|
<div className="rounded border-dashed border p-6 text-center text-sm text-muted-foreground">
|
|
No active templates. Create one under Settings → Inspection Templates.
|
|
</div>
|
|
)}
|
|
{!loadingTemplates && templates.length > 0 && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[60vh] overflow-y-auto">
|
|
{templates.map((t) => {
|
|
const sectionCount = t.sections?.length ?? 0
|
|
const cpCount = (t.sections ?? []).reduce((s, sec) => s + (sec.check_points?.length ?? 0), 0)
|
|
const empty = cpCount === 0
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={t.id}
|
|
onClick={() => !empty && pickTemplate(t)}
|
|
disabled={empty}
|
|
title={empty ? "This template has no checkpoints yet — add some in Settings → Inspection Templates" : undefined}
|
|
className={`rounded border p-3 text-left transition ${
|
|
empty
|
|
? "opacity-50 cursor-not-allowed border-dashed"
|
|
: "hover:border-primary hover:bg-primary/5"
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<ClipboardList className="size-4 mt-0.5 text-muted-foreground" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-sm flex items-center gap-2">
|
|
{t.name}
|
|
{empty && (
|
|
<span className="text-[10px] uppercase tracking-wide bg-amber-100 text-amber-800 px-1.5 py-0.5 rounded">
|
|
Empty
|
|
</span>
|
|
)}
|
|
</div>
|
|
{t.description && (
|
|
<div className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
|
{t.description}
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{sectionCount} sections · {cpCount} checkpoints
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Rhform form={form} onSubmit={handleSubmit}>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="text-sm">
|
|
<span className="text-muted-foreground">Template:</span>{" "}
|
|
<span className="font-medium">{selectedTemplate?.name}</span>
|
|
</div>
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => setStep("pick-template")}>
|
|
Change template
|
|
</Button>
|
|
</div>
|
|
<FieldGroup>
|
|
<RhfTextField name="title" label="Title" placeholder="e.g. Pre-purchase" required />
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<RhfCustomerSelectField name="customer" />
|
|
<CustomerScopedVehicleField />
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<RhfAsyncSelectField
|
|
name="department"
|
|
label="Department"
|
|
placeholder="Select department"
|
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
|
listFn={() => api.departments.list()}
|
|
mapOption={mapLookupOption}
|
|
{...STORE_OBJECT}
|
|
/>
|
|
<RhfEmployeeSelectField name="employee" />
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<RhfTextField name="order_number" label="Order number" placeholder="e.g. ORD-001" required />
|
|
<RhfDateField name="date" label="Date" />
|
|
<RhfTimeField name="time" label="Time" withSeconds />
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<RhfTextField name="odometer" label="Odometer" type="number" placeholder="km" />
|
|
</div>
|
|
<RhfTextareaField name="note" label="Note" placeholder="Internal notes…" />
|
|
<Button type="submit" variant="default" disabled={form.formState.isSubmitting}>
|
|
<Plus className="size-4" />
|
|
{form.formState.isSubmitting ? "Creating…" : "Create inspection"}
|
|
</Button>
|
|
</FieldGroup>
|
|
</Rhform>
|
|
)
|
|
}
|