From c5f6d2f596a699199c74c2ac8bd6b48e28f3f93b Mon Sep 17 00:00:00 2001 From: Mohammad Khyata Date: Thu, 9 Apr 2026 11:06:56 +0300 Subject: [PATCH] make and models --- .../settings/make-and-models/page.tsx | 76 +++++ apps/dashboard/config/navGroups.tsx | 1 + .../make-and-models/make-and-model-form.tsx | 222 ++++++++++++++ .../make-and-models/make-and-model.schema.ts | 20 ++ .../vehicles/rhf-vehicle-identity-field.tsx | 284 ++++++++++++++++++ .../modules/vehicles/vehicle-form.tsx | 11 +- 6 files changed, 606 insertions(+), 8 deletions(-) create mode 100644 apps/dashboard/app/(authenticated)/settings/make-and-models/page.tsx create mode 100644 apps/dashboard/modules/settings/make-and-models/make-and-model-form.tsx create mode 100644 apps/dashboard/modules/settings/make-and-models/make-and-model.schema.ts create mode 100644 apps/dashboard/modules/vehicles/rhf-vehicle-identity-field.tsx diff --git a/apps/dashboard/app/(authenticated)/settings/make-and-models/page.tsx b/apps/dashboard/app/(authenticated)/settings/make-and-models/page.tsx new file mode 100644 index 0000000..0abc6c6 --- /dev/null +++ b/apps/dashboard/app/(authenticated)/settings/make-and-models/page.tsx @@ -0,0 +1,76 @@ +"use client" + +import { MakeAndModelForm } from "@/modules/settings/make-and-models/make-and-model-form" +import { ResourcePage } from "@/shared/data-view/resource-page" +import { ColumnHeader } from "@/shared/data-view/table-view" +import FormDialog from "@/shared/components/form-dialog" +import { MAKE_AND_MODEL_ROUTES } from "@garage/api" +import type { MakeAndModelsClient } from "@garage/api" + +export default function MakeAndModelsPage() { + return ( + + pageTitle="Make & Models" + routeKey={MAKE_AND_MODEL_ROUTES.INDEX} + getClient={(api) => api.makeAndModels} + headerProps={({ selectedItem, invalidateQuery }) => ({ + actions: ( + + {(resourceId) => ( + + )} + + ), + })} + columns={({ actionsColumn }) => [ + { + accessorKey: "make", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).make ?? "—", + }, + { + accessorKey: "model", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).model ?? "—", + }, + { + accessorKey: "year", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).year ?? "—", + }, + { + accessorKey: "sub_model", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).sub_model ?? "—", + }, + { + accessorKey: "engine_size", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).engine_size ?? "—", + }, + { + accessorKey: "drivetrain", + header: ({ column }) => , + cell: ({ row }) => (row.original as any).drivetrain ?? "—", + }, + { + accessorKey: "is_active", + header: ({ column }) => , + cell: ({ row }) => { + const active = (row.original as any).is_active + return ( + + {active ? "Active" : "Inactive"} + + ) + }, + }, + actionsColumn(), + ]} + /> + ) +} diff --git a/apps/dashboard/config/navGroups.tsx b/apps/dashboard/config/navGroups.tsx index 7a5a0ab..db9c701 100644 --- a/apps/dashboard/config/navGroups.tsx +++ b/apps/dashboard/config/navGroups.tsx @@ -175,6 +175,7 @@ export const navGroups: NavGroup[] = [ { title: "Departments", href: "/settings/departments", icon: }, { title: "Insurance Types", href: "/settings/insurance-types", icon: }, { title: "Tax & Rates", href: "/settings/tax-rates", icon: }, + { title: "Make & Models", href: "/settings/make-and-models", icon: }, { title: "Configurations", href: "/settings/configurations/preferences/sales", icon: }, { title: "Templates", href: "/settings/templates", icon: }, { title: "Integrations", href: "/settings/integrations/providers", icon: }, diff --git a/apps/dashboard/modules/settings/make-and-models/make-and-model-form.tsx b/apps/dashboard/modules/settings/make-and-models/make-and-model-form.tsx new file mode 100644 index 0000000..35c41ef --- /dev/null +++ b/apps/dashboard/modules/settings/make-and-models/make-and-model-form.tsx @@ -0,0 +1,222 @@ +"use client" + +import { AlertTriangle, Plus, Save } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Alert, AlertTitle } from "@/shared/components/ui/alert" +import { FieldGroup } from "@/shared/components/ui/field" +import { + Rhform, + RhfTextField, + RhfCheckboxField, + 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 { toRelation, toId } from "@/shared/lib/utils" + +import { makeAndModelFormSchema, type MakeAndModelFormValues } from "./make-and-model.schema" +import { MAKE_AND_MODEL_ROUTES } from "@garage/api" + +export type MakeAndModelFormProps = { + resourceId?: string | null + initialData?: unknown + onSuccess?: () => void +} + +const DEFAULT_VALUES: MakeAndModelFormValues = { + make: "", + model: "", + year: "", + sub_model: "", + engine_size: "", + drivetrain: "", + shop_type: null, + body_type: null, + fuel_type: null, + transmission: null, + is_active: true, +} + +function mapToFormValues(data: unknown): MakeAndModelFormValues { + const d = (data as any)?.data ?? data ?? {} + return { + make: d.make ?? "", + model: d.model ?? "", + year: d.year ?? "", + sub_model: d.sub_model ?? "", + engine_size: d.engine_size ?? "", + drivetrain: d.drivetrain ?? "", + shop_type: toRelation(d.shop_type_id, d.shop_type_title ?? d.shop_type_name), + body_type: toRelation(d.body_type_id, d.body_type_title ?? d.body_type_name), + fuel_type: toRelation(d.fuel_id, d.fuel_title ?? d.fuel_name), + transmission: toRelation(d.transmission_id, d.transmission_title ?? d.transmission_name), + is_active: d.is_active ?? true, + } +} + +function mapFormToPayload(values: MakeAndModelFormValues) { + return { + make: values.make, + model: values.model, + year: values.year || undefined, + sub_model: values.sub_model || undefined, + engine_size: values.engine_size || undefined, + drivetrain: values.drivetrain || undefined, + shop_type_id: toId(values.shop_type), + body_type_id: toId(values.body_type), + fuel_id: toId(values.fuel_type), + transmission_id: toId(values.transmission), + is_active: values.is_active, + } +} + +const mapLookupOption = (item: any) => ({ + value: String(item.id), + label: item.title ?? item.name ?? "", +}) + +const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } + +export function MakeAndModelForm({ resourceId, initialData, onSuccess }: MakeAndModelFormProps) { + const api = useAuthApi() + + const { form, isEditing } = useResourceForm({ + schema: makeAndModelFormSchema, + defaultValues: DEFAULT_VALUES, + resourceId, + initialData, + initialize: (id) => api.makeAndModels.show(id), + queryKey: [MAKE_AND_MODEL_ROUTES.BY_ID, resourceId], + mapToFormValues, + }) + + const { mutate, error, isPending } = useFormMutation(form, { + mutationFn: (values: MakeAndModelFormValues) => { + const payload = mapFormToPayload(values) + const promise = isEditing && resourceId + ? api.makeAndModels.update(resourceId, payload) + : api.makeAndModels.create(payload) + + toast.promise(promise, { + loading: isEditing ? "Updating make & model..." : "Creating make & model...", + success: isEditing ? "Make & model updated successfully" : "Make & model created successfully", + error: isEditing ? "Failed to update make & model" : "Failed to create make & model", + }) + + return promise + }, + onSuccess: () => { + form.reset() + onSuccess?.() + }, + }) + + return ( + mutate(values)}> + {error && ( + + + + {isEditing ? "Failed to update make & model" : "Failed to create make & model"} + + {error.message} + + )} + + + + + + + + + + + api.shopTypes.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + + api.vehicleAttributes.listBodyTypes()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + + api.vehicleAttributes.listFuelTypes()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + + api.vehicleAttributes.listTransmissions()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + + + + + + + + + + + ) +} diff --git a/apps/dashboard/modules/settings/make-and-models/make-and-model.schema.ts b/apps/dashboard/modules/settings/make-and-models/make-and-model.schema.ts new file mode 100644 index 0000000..11b9f74 --- /dev/null +++ b/apps/dashboard/modules/settings/make-and-models/make-and-model.schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable() + +export const makeAndModelFormSchema = z.object({ + make: z.string().min(1, "Make is required"), + model: z.string().min(1, "Model is required"), + year: z.string().optional(), + sub_model: z.string().optional(), + engine_size: z.string().optional(), + drivetrain: z.string().optional(), + shop_type: relationFieldSchema, + body_type: relationFieldSchema, + fuel_type: relationFieldSchema, + transmission: relationFieldSchema, + is_active: z.boolean().default(true), +}) + +export type MakeAndModelFormValues = z.infer +export { relationFieldSchema } diff --git a/apps/dashboard/modules/vehicles/rhf-vehicle-identity-field.tsx b/apps/dashboard/modules/vehicles/rhf-vehicle-identity-field.tsx new file mode 100644 index 0000000..59eb862 --- /dev/null +++ b/apps/dashboard/modules/vehicles/rhf-vehicle-identity-field.tsx @@ -0,0 +1,284 @@ +"use client" + +import { useRef, useState, useEffect } from "react" +import { useFormContext, useController, useWatch } from "react-hook-form" +import { useQuery } from "@tanstack/react-query" +import { Loader2 } from "lucide-react" +import { useAuthApi } from "@/shared/useApi" +import { Field, FieldLabel, FieldError } from "@/shared/components/ui/field" +import { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxEmpty, +} from "@/shared/components/ui/combobox" +import type { VehicleFormValues } from "./vehicle.schema" + +// ── Helpers ── + +function unique(values: (string | undefined)[]): string[] { + return [...new Set(values.filter((v): v is string => !!v))].sort() +} + +// ── Internal combobox for a single free-text + suggestion field ── + +type VehicleComboboxProps = { + value: string + onChange: (value: string) => void + onBlur?: () => void + options: string[] + loading?: boolean + placeholder: string + label: string + error?: string + required?: boolean + disabled?: boolean +} + +function VehicleCombobox({ + value, + onChange, + onBlur, + options, + loading, + placeholder, + label, + error, + required, + disabled, +}: VehicleComboboxProps) { + const anchorRef = useRef(null) + + // Local state keeps the input text in sync even when the form value + // is changed externally (e.g., cascade reset or edit-mode population). + const [inputText, setInputText] = useState(value ?? "") + + useEffect(() => { + setInputText(value ?? "") + }, [value]) + + // Client-side filtering of suggestions based on what's been typed + const filtered = options.filter((opt) => + !inputText || opt.toLowerCase().includes(inputText.toLowerCase()), + ) + + return ( + + + {label} + {required && *} + +
+ { + const str = val !== null ? String(val) : "" + setInputText(str) + onChange(str) + }} + disabled={disabled} + onInputValueChange={(text, { reason }) => { + if (reason === "input-change") { + setInputText(text) + onChange(text) + } + }} + > + + + + {loading && ( +
+ +
+ )} + {!loading && + filtered.map((opt) => ( + + {opt} + + ))} + {!loading && filtered.length === 0 && ( + + {inputText + ? `No suggestions for "${inputText}"` + : "Start typing to see suggestions"} + + )} +
+
+
+
+ {error && {error}} +
+ ) +} + +// ── Main composite field ── + +/** + * Manages the four vehicle identity fields (make, model, year, sub_model) as + * a single coordinated component. Each field shows autocomplete suggestions + * fetched from the make-and-models API, filtered by the values of the + * preceding fields. All four fields accept free-text — the user is never + * forced to pick from the suggestion list. + * + * Cascade behaviour: changing make resets model → year → sub_model; + * changing model resets year → sub_model; changing year resets sub_model. + */ +export function RhfVehicleIdentityField() { + const api = useAuthApi() + const { control, setValue } = useFormContext() + + // Read shop_type to pass as shop_type_id filter to the API + const shopType = useWatch({ control, name: "shop_type" }) + const shopTypeId = shopType?.value + + const makeCtrl = useController({ name: "make", control }) + const modelCtrl = useController({ name: "model", control }) + const yearCtrl = useController({ name: "year", control }) + const subModelCtrl = useController({ name: "sub_model", control }) + + const makeValue = makeCtrl.field.value ?? "" + const modelValue = modelCtrl.field.value ?? "" + const yearValue = yearCtrl.field.value ?? "" + + // ── Fetch all records once (large per_page), then derive unique suggestion + // lists client-side so we don't invent API endpoints. ── + + type MakeAndModelRecord = { + id?: number + shop_type_id?: number + make?: string + model?: string + year?: string + sub_model?: string + } + + function extractRecords(response: unknown): MakeAndModelRecord[] { + const obj = response as any + if (Array.isArray(obj?.data)) return obj.data + if (Array.isArray(obj?.data?.data)) return obj.data.data + if (Array.isArray(obj)) return obj + return [] + } + + const { data: allRecords = [], isLoading: recordsLoading } = useQuery({ + queryKey: ["make-and-models-all", shopTypeId], + queryFn: async () => { + const res = await api.makeAndModels.list({ per_page: 9999, ...(shopTypeId ? { shop_type_id: shopTypeId } : {}) }) + return extractRecords(res) + }, + staleTime: 5 * 60 * 1000, + }) + + // Derive unique sorted lists from the full record set, filtered by upstream selections. + const makes = unique(allRecords.map((r) => r.make)) + + const models = unique( + allRecords + .filter((r) => !makeValue || r.make === makeValue) + .map((r) => r.model), + ) + + const years = unique( + allRecords + .filter((r) => (!makeValue || r.make === makeValue) && (!modelValue || r.model === modelValue)) + .map((r) => r.year), + ) + + const subModels = unique( + allRecords + .filter( + (r) => + (!makeValue || r.make === makeValue) && + (!modelValue || r.model === modelValue) && + (!yearValue || r.year === yearValue), + ) + .map((r) => r.sub_model), + ) + + const makesLoading = recordsLoading + const modelsLoading = false + const yearsLoading = false + const subModelsLoading = false + + // ── Cascading onChange handlers ── + + function handleMakeChange(val: string) { + makeCtrl.field.onChange(val) + // Reset all downstream fields whenever make changes + setValue("model", "") + setValue("year", "") + setValue("sub_model", "") + } + + function handleModelChange(val: string) { + modelCtrl.field.onChange(val) + setValue("year", "") + setValue("sub_model", "") + } + + function handleYearChange(val: string) { + yearCtrl.field.onChange(val) + setValue("sub_model", "") + } + + return ( +
+
+ + + +
+ + subModelCtrl.field.onChange(val)} + onBlur={subModelCtrl.field.onBlur} + options={subModels} + loading={subModelsLoading} + error={subModelCtrl.fieldState.error?.message} + /> +
+ ) +} diff --git a/apps/dashboard/modules/vehicles/vehicle-form.tsx b/apps/dashboard/modules/vehicles/vehicle-form.tsx index f90d733..a0729c7 100644 --- a/apps/dashboard/modules/vehicles/vehicle-form.tsx +++ b/apps/dashboard/modules/vehicles/vehicle-form.tsx @@ -17,6 +17,7 @@ import { BodyTypeInlineForm } from "./inline-forms/body-type-inline-form" import { FuelTypeInlineForm } from "./inline-forms/fuel-type-inline-form" import { TransmissionInlineForm } from "./inline-forms/transmission-inline-form" import { ColorInlineForm } from "./inline-forms/color-inline-form" +import { RhfVehicleIdentityField } from "./rhf-vehicle-identity-field" import { toast } from "sonner" import { useAuthApi } from "@/shared/useApi" import { useResourceForm } from "@/shared/hooks/use-resource-form" @@ -156,14 +157,8 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP )} - {/* Vehicle identity */} -
- - - -
- - + {/* Vehicle identity — cascading make/model/year/sub_model autocomplete */} + {/* Associations */}