fix(dashboard): resolve tutorial QA notes (clips 2–6)

- settings/company: show success toast on save (2.1)
- inspection templates: add explicit Save button (2.6)
- parts: label price fields with AED currency (3.1)
- service groups: add parts/services picker to packages (3.3)
- vehicles: add insurance (has_insurance + insurance type) field (4.2)
- appointments: add calendar view with list/calendar toggle (5.1)
- job card check-in: require department (5.4)
- job card payments: refresh job card/invoice queries so PAID shows (5.6)
- vendors: add phone + address fields (6.1)
- bill payments: surface real vendor/employee validation errors (6.4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ERP-System 2026-06-04 11:39:07 +04:00
parent c71b5a19ed
commit fdce301495
14 changed files with 562 additions and 39 deletions

View File

@ -1,14 +1,17 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { AppointmentForm } from "@/modules/appointments/appointment-form"
import { AppointmentsCalendarView } from "@/modules/appointments/appointments-calendar-view"
import { APPOINTMENT_ROUTES, AppointmentStatus } from "@garage/api"
import type { AppointmentsClient } from "@garage/api"
import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon } from "lucide-react"
import { CalendarCheck2Icon, CalendarDaysIcon, ClipboardListIcon, ClockIcon, ListIcon } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { RelationLink } from "@/shared/components/relation-link"
import { formatEnum } from "@/shared/utils/formatters"
@ -22,8 +25,44 @@ const STATUS_COLORS: Record<string, string> = {
export default function AppointmentsPage() {
const router = useRouter()
const [view, setView] = useState<"list" | "calendar">("list")
const viewToggle = (
<div className="flex items-center justify-end gap-1 px-1">
<Button
type="button"
variant={view === "list" ? "default" : "outline"}
size="sm"
onClick={() => setView("list")}
>
<ListIcon className="me-1 size-4" /> List
</Button>
<Button
type="button"
variant={view === "calendar" ? "default" : "outline"}
size="sm"
onClick={() => setView("calendar")}
>
<CalendarDaysIcon className="me-1 size-4" /> Calendar
</Button>
</div>
)
if (view === "calendar") {
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Appointments</h1>
{viewToggle}
</div>
<AppointmentsCalendarView />
</div>
)
}
return (
<div className="space-y-2">
{viewToggle}
<ResourcePage<AppointmentsClient>
pageTitle="Appointments"
routeKey={APPOINTMENT_ROUTES.INDEX}
@ -104,5 +143,6 @@ export default function AppointmentsPage() {
actionsColumn(),
]}
/>
</div>
)
}

View File

@ -3,7 +3,7 @@
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 { ChevronLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
@ -44,6 +44,9 @@ export default function InspectionTemplateEditorPage() {
const api = useAuthApi()
const [template, setTemplate] = useState<InspectionTemplate | null>(null)
const [loading, setLoading] = useState(true)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [saving, setSaving] = useState(false)
const [editing, setEditing] = useState<{
section: InspectionTemplateSection
@ -55,6 +58,8 @@ export default function InspectionTemplateEditorPage() {
try {
const res = await api.inspectionTemplates.show(id)
setTemplate(res.data)
setName(res.data.name ?? "")
setDescription(res.data.description ?? "")
} catch (e: any) {
toast.error(e?.message ?? "Failed to load template")
} finally {
@ -74,6 +79,20 @@ export default function InspectionTemplateEditorPage() {
}
}
const saveTemplate = async () => {
if (!template) return
setSaving(true)
try {
const res = await api.inspectionTemplates.update(template.id, { name, description })
setTemplate(res.data)
toast.success("Template saved")
} catch (e: any) {
toast.error(e?.message ?? "Failed to save")
} finally {
setSaving(false)
}
}
const addSection = async () => {
if (!template) return
const name = await prompt({
@ -153,8 +172,8 @@ export default function InspectionTemplateEditorPage() {
<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 })}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex items-center gap-3 mt-5">
@ -168,9 +187,9 @@ export default function InspectionTemplateEditorPage() {
<div>
<label className="text-xs text-muted-foreground">Description</label>
<Textarea
defaultValue={template.description ?? ""}
value={description}
rows={2}
onBlur={(e) => e.currentTarget.value !== (template.description ?? "") && updateMeta({ description: e.currentTarget.value })}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
@ -259,6 +278,13 @@ export default function InspectionTemplateEditorPage() {
onSaved={load}
/>
)}
<div className="sticky bottom-0 flex justify-end gap-2 border-t bg-background py-3">
<Button onClick={saveTemplate} disabled={saving}>
<Save className="size-4 mr-1" />
{saving ? "Saving…" : "Save"}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,132 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQuery } from "@tanstack/react-query"
import { CalendarCheck2Icon, ClipboardListIcon, ClockIcon } from "lucide-react"
import { Calendar } from "@/shared/components/ui/calendar"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { useAuthApi } from "@/shared/useApi"
import { APPOINTMENT_ROUTES } from "@garage/api"
import { formatEnum } from "@/shared/utils/formatters"
const STATUS_COLORS: Record<string, string> = {
requested: "bg-yellow-100 text-yellow-800",
confirmed: "bg-blue-100 text-blue-800",
in_progress: "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
cancelled: "bg-red-100 text-red-800",
}
function toDateKey(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${day}`
}
export function AppointmentsCalendarView() {
const api = useAuthApi()
const router = useRouter()
const [selected, setSelected] = useState<Date | undefined>(() => new Date())
const { data, isLoading } = useQuery({
queryKey: [APPOINTMENT_ROUTES.INDEX, "calendar"],
queryFn: () => api.appointments.list({ per_page: 100 } as any),
})
const appointments: any[] = (data as any)?.data ?? []
const byDate = useMemo(() => {
const map: Record<string, any[]> = {}
for (const a of appointments) {
const key = String(a.date ?? "").slice(0, 10)
if (!key) continue
;(map[key] ??= []).push(a)
}
return map
}, [appointments])
const bookedDays = useMemo(
() => Object.keys(byDate).map((k) => new Date(`${k}T00:00:00`)),
[byDate],
)
const selectedKey = selected ? toDateKey(selected) : ""
const dayAppointments = byDate[selectedKey] ?? []
return (
<div className="grid gap-6 lg:grid-cols-[auto_1fr]">
<Card className="w-fit">
<CardContent className="p-3">
<Calendar
mode="single"
selected={selected}
onSelect={setSelected}
modifiers={{ booked: bookedDays }}
modifiersClassNames={{
booked:
"relative after:absolute after:bottom-1 after:left-1/2 after:h-1 after:w-1 after:-translate-x-1/2 after:rounded-full after:bg-primary",
}}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-semibold">
{selected
? selected.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})
: "Select a day"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{isLoading && <p className="text-muted-foreground text-sm">Loading</p>}
{!isLoading && dayAppointments.length === 0 && (
<p className="text-muted-foreground text-sm">No appointments on this day.</p>
)}
{dayAppointments.map((a) => {
const jobCardId = a.job_card_id ?? a.job_card?.id
return (
<button
key={a.id}
type="button"
onClick={() => router.push(`/calendar/appointment/${a.id}`)}
className="hover:bg-muted/40 w-full rounded border p-3 text-left transition"
>
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 font-medium">
<CalendarCheck2Icon className="text-muted-foreground size-4" />
{a.title}
</span>
<Badge className={STATUS_COLORS[a.status] ?? "bg-gray-100 text-gray-800"}>
{formatEnum(a.status)}
</Badge>
</div>
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-sm">
<span className="flex items-center gap-1">
<ClockIcon className="size-3" />
{a.from_time} {a.to_time}
</span>
{jobCardId && (
<span className="flex items-center gap-1">
<ClipboardListIcon className="size-3" />
{a.job_card?.title || `#${jobCardId}`}
</span>
)}
</div>
</button>
)
})}
</CardContent>
</Card>
</div>
)
}

View File

@ -87,6 +87,10 @@ export function JobCardCheckInDialog({
})
const handleSubmit = async (values: CheckInFormValues) => {
if (!values.department) {
form.setError("department", { message: "Department is required" })
return
}
try {
await api.jobCards.checkIn(jobCardId, {
check_in_date: values.check_in_date || undefined,
@ -131,6 +135,7 @@ export function JobCardCheckInDialog({
name="department"
label="Department"
placeholder="Select department"
required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(op:any)=> ({value: op.id, label: op.name})}

View File

@ -1,5 +1,6 @@
"use client"
import { useQueryClient } from "@tanstack/react-query"
import { CrudResource } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
@ -22,6 +23,7 @@ import { Money } from "@/shared/components/money"
export default function JobCardPaymentsReceived() {
const jobCard = useJobCard()
const queryClient = useQueryClient()
const [hasOpened, setHasOpened] = useState(false)
return (
@ -60,7 +62,12 @@ export default function JobCardPaymentsReceived() {
invoiceCustomer={jobCard?.customer as any}
lockJobCard
lockCustomer
onSuccess={invalidateQuery}
onSuccess={() => {
// Refresh the payments list AND the job card / invoice
// queries so the PAID status reflects the new payment.
invalidateQuery()
queryClient.invalidateQueries()
}}
/>
)}
</FormDialog>

View File

@ -212,14 +212,14 @@ export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps)
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
label="Selling Price (AED)"
placeholder="0.00"
type="number"
/>
<RhfTextField
name="purchase_price"
label="Purchase Price"
label="Purchase Price (AED)"
placeholder="0.00"
type="number"
/>

View File

@ -213,21 +213,24 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
return
}
const vendorError = error.validationErrors.vendor_id?.[0]
const employeeError = error.validationErrors.employee_id?.[0]
const billError = error.validationErrors.bill_id?.[0]
// Surface the actual backend validation messages on each field
// instead of blanket-replacing them with the "not both" message —
// that masked unrelated errors and showed the wrong text when only
// one of vendor/employee was selected.
const errors = error.validationErrors
const billError = errors.bill_id?.[0]
const vendorError = errors.vendor_id?.[0]
const employeeError = errors.employee_id?.[0]
if (billError) {
form.setError("bill", { message: billError })
}
if (!vendorError && !employeeError) {
return
if (vendorError) {
form.setError("vendor", { message: vendorError })
}
if (employeeError) {
form.setError("employee", { message: employeeError })
}
const message = "Select either a vendor or an employee, not both"
form.setError("vendor", { message })
form.setError("employee", { message })
},
onSuccess: () => {
form.reset()

View File

@ -1,6 +1,7 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { AlertTriangle, Plus, Save, Trash2 } from "lucide-react"
import { useFieldArray } from "react-hook-form"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
@ -49,6 +50,8 @@ const DEFAULT_VALUES: ServiceGroupFormValues = {
mark_as_recommended: false,
set_packaged_pricing: false,
is_active: true,
parts: [],
services: [],
}
// ── Mapping helpers ──
@ -60,6 +63,20 @@ const mapLookupOption = (item: any) => ({
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
const partLineToForm = (r: any) => ({
id: r.id,
part: r.part ? { value: String(r.part.id), label: r.part.title ?? r.part.name ?? `#${r.part.id}` } : null,
quantity: r.quantity != null ? Number(r.quantity) : 1,
rate: r.rate != null ? Number(r.rate) : 0,
})
const serviceLineToForm = (r: any) => ({
id: r.id,
service: r.service ? { value: String(r.service.id), label: r.service.title ?? r.service.name ?? `#${r.service.id}` } : null,
rate: r.rate != null ? Number(r.rate) : 0,
hours: r.hours != null ? Number(r.hours) : 0,
})
function mapToFormValues(data: unknown): ServiceGroupFormValues {
const d = (data as any)?.data ?? data ?? {}
@ -77,6 +94,8 @@ function mapToFormValues(data: unknown): ServiceGroupFormValues {
mark_as_recommended: d.mark_as_recommended ?? false,
set_packaged_pricing: d.set_packaged_pricing ?? false,
is_active: d.is_active ?? true,
parts: Array.isArray(d._parts) ? d._parts.map(partLineToForm) : [],
services: Array.isArray(d._services) ? d._services.map(serviceLineToForm) : [],
}
}
@ -116,18 +135,87 @@ export function ServiceGroupForm({ resourceId, initialData, onSuccess }: Service
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [SERVICE_GROUP_ROUTES.BY_ID, resourceId],
// Fetch the group together with its existing parts/services so the
// pickers can be pre-populated when editing.
initialize: async (id) => {
const [group, parts, services] = await Promise.all([
api.serviceGroups.show(id),
api.serviceGroupParts.list({ service_group_id: id } as any),
api.serviceGroupServices.list({ service_group_id: id } as any),
])
const g = (group as any)?.data ?? group ?? {}
return {
data: {
...g,
_parts: (parts as any)?.data ?? [],
_services: (services as any)?.data ?? [],
},
}
},
mapToFormValues,
})
const partsArray = useFieldArray({ control: form.control, name: "parts" })
const servicesArray = useFieldArray({ control: form.control, name: "services" })
// Replace the package's parts/services on the server with the current form
// rows: delete the existing rows, then recreate from the form. Mirrors the
// "replace details" approach used elsewhere (e.g. payments).
const syncIncludes = async (groupId: string | number, values: ServiceGroupFormValues) => {
const gid = Number(groupId)
const existingParts = await api.serviceGroupParts.list({ service_group_id: gid } as any)
const existingPartIds = (((existingParts as any)?.data) ?? []).map((r: any) => r.id)
await Promise.all(existingPartIds.map((id: any) => api.serviceGroupParts.destroy(String(id))))
await Promise.all(
(values.parts ?? [])
.filter((p) => p.part?.value)
.map((p) =>
api.serviceGroupParts.create({
service_group_id: gid,
part_id: Number(p.part!.value),
quantity: Number(p.quantity ?? 1),
rate: Number(p.rate ?? 0),
} as any),
),
)
const existingServices = await api.serviceGroupServices.list({ service_group_id: gid } as any)
const existingServiceIds = (((existingServices as any)?.data) ?? []).map((r: any) => r.id)
await Promise.all(existingServiceIds.map((id: any) => api.serviceGroupServices.destroy(String(id))))
await Promise.all(
(values.services ?? [])
.filter((s) => s.service?.value)
.map((s) =>
api.serviceGroupServices.create({
service_group_id: gid,
service_id: Number(s.service!.value),
rate: Number(s.rate ?? 0),
hours: Number(s.hours ?? 0),
} as any),
),
)
}
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ServiceGroupFormValues) => {
const promise = isEditing && resourceId
? api.serviceGroups.update(resourceId, mapUpdatePayload(values))
: api.serviceGroups.create(mapCreatePayload(values))
const run = async () => {
const groupRes: any =
isEditing && resourceId
? await api.serviceGroups.update(resourceId, mapUpdatePayload(values))
: await api.serviceGroups.create(mapCreatePayload(values))
const groupId = resourceId ?? groupRes?.data?.id ?? groupRes?.id
if (groupId) {
await syncIncludes(groupId, values)
}
return groupRes
}
const promise = run()
toast.promise(promise, {
loading: isEditing ? "Updating service group..." : "Creating service group...",
success: isEditing ? "Service group updated" : "Service group created",
error: isEditing ? "Failed to update service group" : "Failed to create service group",
loading: isEditing ? "Updating package..." : "Creating package...",
success: isEditing ? "Package updated" : "Package created",
error: isEditing ? "Failed to update package" : "Failed to create package",
})
return promise
},
@ -221,7 +309,7 @@ export function ServiceGroupForm({ resourceId, initialData, onSuccess }: Service
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
label="Selling Price (AED)"
placeholder="0.00"
type="number"
/>
@ -239,6 +327,100 @@ export function ServiceGroupForm({ resourceId, initialData, onSuccess }: Service
rows={3}
/>
{/* ── Parts in this package ── */}
<div className="border-t pt-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">Parts in this package</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => partsArray.append({ part: null, quantity: 1, rate: 0 })}
>
<Plus className="me-1 h-4 w-4" /> Add Part
</Button>
</div>
{partsArray.fields.length === 0 && (
<p className="text-muted-foreground text-sm">No parts added yet.</p>
)}
<div className="space-y-3">
{partsArray.fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_110px_130px_auto] sm:items-end"
>
<RhfAsyncSelectField
name={`parts.${index}.part`}
label="Part"
placeholder="Select part (e.g. Engine Oil 5W-30)"
queryKey={["parts"]}
listFn={() => api.parts.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfTextField name={`parts.${index}.quantity`} label="Qty" type="number" placeholder="1" />
<RhfTextField name={`parts.${index}.rate`} label="Rate (AED)" type="number" placeholder="0.00" />
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => partsArray.remove(index)}
aria-label="Remove part"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
))}
</div>
</div>
{/* ── Services in this package ── */}
<div className="border-t pt-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">Services in this package</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => servicesArray.append({ service: null, rate: 0, hours: 0 })}
>
<Plus className="me-1 h-4 w-4" /> Add Service
</Button>
</div>
{servicesArray.fields.length === 0 && (
<p className="text-muted-foreground text-sm">No services added yet.</p>
)}
<div className="space-y-3">
{servicesArray.fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_110px_130px_auto] sm:items-end"
>
<RhfAsyncSelectField
name={`services.${index}.service`}
label="Service"
placeholder="Select service (e.g. Oil change labour)"
queryKey={["services"]}
listFn={() => api.services.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfTextField name={`services.${index}.hours`} label="Hours" type="number" placeholder="0" />
<RhfTextField name={`services.${index}.rate`} label="Rate (AED)" type="number" placeholder="0.00" />
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => servicesArray.remove(index)}
aria-label="Remove service"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCheckboxField
name="show_as_lump_sum"

View File

@ -4,6 +4,20 @@ export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const packagePartSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
part: relationFieldSchema,
quantity: z.coerce.number().min(0).optional(),
rate: z.coerce.number().min(0).optional(),
})
const packageServiceSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
service: relationFieldSchema,
rate: z.coerce.number().min(0).optional(),
hours: z.coerce.number().min(0).optional(),
})
export const serviceGroupFormSchema = z.object({
shop_type: relationFieldSchema,
inventory_category: relationFieldSchema,
@ -18,6 +32,9 @@ export const serviceGroupFormSchema = z.object({
mark_as_recommended: z.boolean().optional(),
set_packaged_pricing: z.boolean().optional(),
is_active: z.boolean().optional(),
// ── Package contents ──
parts: z.array(packagePartSchema).optional(),
services: z.array(packageServiceSchema).optional(),
})
export type ServiceGroupFormValues = z.infer<typeof serviceGroupFormSchema>

View File

@ -151,15 +151,12 @@ export function SettingsForm() {
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: SettingsFormValues) => {
const payload = mapFormToPayload(values)
const promise = api.settings.update(payload)
toast.promise(promise, {
loading: "Saving settings...",
success: "Settings saved successfully",
error: "Failed to save settings",
})
return promise
mutationFn: (values: SettingsFormValues) => api.settings.update(mapFormToPayload(values)),
onSuccess: () => {
toast.success("Settings saved successfully")
},
onError: () => {
toast.error("Failed to save settings")
},
})

View File

@ -10,6 +10,7 @@ import {
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
RhfCheckboxField,
RhfImageField,
} from "@/shared/components/form"
import { ShopTypeInlineForm } from "./inline-forms/shop-type-inline-form"
@ -24,7 +25,7 @@ import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils"
import { formatUppercase } from "@/shared/utils/formatters"
import { VEHICLE_ROUTES } from "@garage/api"
import { VEHICLE_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api"
import { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema"
import { CustomerForm } from "../customers/customer-form"
@ -57,6 +58,8 @@ const DEFAULT_VALUES: VehicleFormValues = {
drivetrain: "",
mileage: "",
note: "",
has_insurance: false,
insurance_type: null,
image: null,
}
@ -92,6 +95,8 @@ function mapToFormValues(data: unknown): VehicleFormValues {
drivetrain: toFormString(d.drivetrain),
mileage: toFormString(d.mileage),
note: toFormString(d.note),
has_insurance: Boolean(d.has_insurance),
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.title),
image: null,
}
}
@ -115,6 +120,8 @@ function mapToPayload(values: VehicleFormValues) {
drivetrain: values.drivetrain || undefined,
mileage: values.mileage || undefined,
note: values.note || undefined,
has_insurance: values.has_insurance ?? false,
insurance_type_id: values.has_insurance ? toId(values.insurance_type) : undefined,
image: values.image instanceof File ? values.image : undefined,
}
}
@ -137,6 +144,8 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
const existingImageUrl: string | null =
(resolvedData as any)?.data?.image_url ?? (resolvedData as any)?.image_url ?? null
const hasInsurance = form.watch("has_insurance")
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: VehicleFormValues) => {
const payload = mapToPayload(values)
@ -254,6 +263,24 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
/>
</div>
{/* Insurance */}
<div className="border-t pt-4">
<RhfCheckboxField name="has_insurance" label="Has Insurance?" />
{hasInsurance && (
<div className="mt-4">
<RhfAsyncSelectField
name="insurance_type"
label="Insurance Type"
placeholder="Select insurance type (e.g. Comprehensive)"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
listFn={() => api.insuranceTypes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
)}
</div>
{/* License & identifiers */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField

View File

@ -31,6 +31,10 @@ export const vehicleFormSchema = z.object({
// ── Notes ──
note: z.string().optional(),
// ── Insurance ──
has_insurance: z.boolean().optional(),
insurance_type: relationFieldSchema.optional(),
// ── Image ──
image: z.any().optional(),
})

View File

@ -8,11 +8,13 @@ import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId, toRelation } from "@/shared/lib/utils"
import {
vendorFormSchema,
@ -36,19 +38,39 @@ const DEFAULT_VALUES: VendorFormValues = {
last_name: "",
company_name: "",
email: "",
phone: "",
alternate_phone: "",
address_line_1: "",
address_line_2: "",
city: "",
zip_code: "",
country: null,
state: null,
} as any
// ── Mapping helpers ──
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): VendorFormValues {
const d = (data as any)?.data ?? data ?? {}
const address = Array.isArray(d.addresses) ? d.addresses[0] ?? {} : {}
return {
first_name: d.first_name || "",
last_name: d.last_name || "",
company_name: d.company_name || "",
email: d.email || "",
}
phone: d.phone || "",
alternate_phone: d.alternate_phone || "",
address_line_1: address.address_line_1 || "",
address_line_2: address.address_line_2 || "",
city: address.city || "",
zip_code: address.zip_code || "",
country: toRelation(address.country_id, address.country?.name),
state: toRelation(address.state_id, address.state?.name),
} as VendorFormValues
}
function mapFormToPayload(values: VendorFormValues) {
@ -57,6 +79,14 @@ function mapFormToPayload(values: VendorFormValues) {
last_name: values.last_name || undefined,
company_name: values.company_name || undefined,
email: values.email || undefined,
phone: values.phone || undefined,
alternate_phone: values.alternate_phone || undefined,
address_line_1: values.address_line_1 || undefined,
address_line_2: values.address_line_2 || undefined,
city: values.city || undefined,
zip_code: values.zip_code || undefined,
country_id: toId(values.country),
state_id: toId(values.state),
}
}
@ -113,6 +143,46 @@ export function VendorForm({ resourceId, initialData, onSuccess }: VendorFormPro
<RhfTextField name="company_name" label="Company Name" placeholder="Acme Supplies" required />
<RhfTextField name="email" label="Email" placeholder="vendor@example.com" type="email" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="phone" label="Phone" placeholder="+971501234567" type="tel" />
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="+971509876543" type="tel" />
</div>
{/* ── Address ── */}
<div className="border-t pt-4">
<h3 className="mb-3 text-sm font-semibold">Address</h3>
<RhfTextField name="address_line_1" label="Address Line 1" placeholder="Street 10" />
<div className="mt-4">
<RhfTextField name="address_line_2" label="Address Line 2" placeholder="Near Central Plaza" />
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="country"
label="Country"
placeholder="Select country"
queryKey={["countries"]}
listFn={() => api.geo.countries()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="state"
label="State"
placeholder="Select state"
queryKey={["states"]}
listFn={() => api.geo.states()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="city" label="City" placeholder="Dubai" />
<RhfTextField name="zip_code" label="Zip Code" placeholder="00000" />
</div>
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending

View File

@ -11,6 +11,11 @@ const optionalNumberSchema = z.preprocess(
z.coerce.number().int().optional(),
)
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
.optional()
const vendorFormSchema = z.object({
first_name: z.string().trim().min(1, "First name is required").max(50, "First name must be at most 50 characters"),
last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name must be at most 50 characters"),
@ -22,6 +27,14 @@ const vendorFormSchema = z.object({
opening_balance: optionalNumberSchema,
credit_limit: optionalNumberSchema,
website: optionalStringMaxSchema(255),
// ── Address (optional). Persisted by the backend store endpoint when
// address_line_1 + country + state are provided. ──
address_line_1: optionalStringMaxSchema(255),
address_line_2: optionalStringMaxSchema(255),
city: optionalStringMaxSchema(100),
zip_code: optionalStringMaxSchema(20),
country: relationFieldSchema,
state: relationFieldSchema,
})
type VendorFormValues = z.infer<typeof vendorFormSchema>