updates
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function DocumentTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await api.vehicleDocuments.createDocumentType({ title: values.title })
|
||||
toast.success("Document type created")
|
||||
form.reset()
|
||||
const item = (result as any)?.data ?? result
|
||||
onSuccess({ value: String(item.id), label: item.title ?? item.name ?? String(item.id) })
|
||||
} catch {
|
||||
toast.error("Failed to create document type")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Registration Certificate"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Document Type"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"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 } 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 { mileageFormSchema, type MileageFormValues } from "./mileage.schema"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type MileageFormProps = {
|
||||
vehicleId: string
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: MileageFormValues = {
|
||||
mileage: 0,
|
||||
date: "",
|
||||
time: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): MileageFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
return {
|
||||
mileage: d.mileage ?? 0,
|
||||
date: d.date || "",
|
||||
time: d.time || "",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function MileageForm({ vehicleId, resourceId, initialData, onSuccess }: MileageFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<MileageFormValues, any>({
|
||||
schema: mileageFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: MileageFormValues) => {
|
||||
const payload = { mileage: values.mileage, date: values.date, time: values.time }
|
||||
const promise = isEditing && resourceId
|
||||
? api.vehicleDocuments.updateMileage(resourceId, payload as any)
|
||||
: api.vehicleDocuments.createMileage({ vehicle_id: Number(vehicleId), ...payload } as any)
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating mileage..." : "Adding mileage...",
|
||||
success: isEditing ? "Mileage updated successfully" : "Mileage added successfully",
|
||||
error: isEditing ? "Failed to update mileage" : "Failed to add mileage",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update mileage" : "Failed to add mileage"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="mileage"
|
||||
label="Mileage"
|
||||
placeholder="e.g. 50000"
|
||||
type="number"
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="date"
|
||||
label="Date"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="time"
|
||||
label="Time"
|
||||
type="time"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? null : isEditing ? <Save /> : <Plus />}
|
||||
{isPending ? "Saving..." : isEditing ? "Update Mileage" : "Add Mileage"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const mileageFormSchema = z.object({
|
||||
mileage: z.coerce.number({ message: "Mileage is required" }).min(0, "Mileage must be 0 or greater"),
|
||||
date: z.string().min(1, "Date is required"),
|
||||
time: z.string().min(1, "Time is required"),
|
||||
})
|
||||
|
||||
export type MileageFormValues = z.infer<typeof mileageFormSchema>
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
|
||||
type VehicleActionsProps = {
|
||||
vehicleId: string
|
||||
}
|
||||
|
||||
export function VehicleActions({ vehicleId }: VehicleActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/vehicles/${vehicleId}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
await api.vehicles.destroy(vehicleId)
|
||||
router.push("/sales/vehicles")
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type VehicleContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const VehicleContext = createContext<VehicleContextValue | null>(null)
|
||||
|
||||
export function VehicleProvider({ vehicle, children }: { vehicle: VehicleContextValue; children: React.ReactNode }) {
|
||||
return <VehicleContext.Provider value={vehicle}>{children}</VehicleContext.Provider>
|
||||
}
|
||||
|
||||
export function useVehicle() {
|
||||
return useContext(VehicleContext)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"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,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDocumentField,
|
||||
} from "@/shared/components/form"
|
||||
import { DocumentTypeInlineForm } from "./inline-forms/document-type-inline-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 { vehicleDocumentFormSchema, type VehicleDocumentFormValues } from "./vehicle-document.schema"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type VehicleDocumentFormProps = {
|
||||
vehicleId: string
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: VehicleDocumentFormValues = {
|
||||
document_type: null,
|
||||
file: null,
|
||||
note: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? String(item.id),
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
function mapToFormValues(data: unknown): VehicleDocumentFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
document_type: toRelation(d.document_type_id, d.document_type?.name ?? d.document_type?.title),
|
||||
file: null,
|
||||
note: d.note || "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapToPayload(values: VehicleDocumentFormValues, vehicleId: string) {
|
||||
return {
|
||||
vehicle_id: Number(vehicleId),
|
||||
document_type_id: toId(values.document_type),
|
||||
file: values.file instanceof File ? values.file : undefined,
|
||||
note: values.note || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function VehicleDocumentForm({ vehicleId, resourceId, initialData, onSuccess }: VehicleDocumentFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<VehicleDocumentFormValues, any>({
|
||||
schema: vehicleDocumentFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: VehicleDocumentFormValues) => {
|
||||
const payload = mapToPayload(values, vehicleId)
|
||||
const promise = isEditing && resourceId
|
||||
? api.vehicleDocuments.updateDocument(resourceId, payload)
|
||||
: api.vehicleDocuments.createDocument(payload)
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating document..." : "Uploading document...",
|
||||
success: isEditing ? "Document updated successfully" : "Document uploaded successfully",
|
||||
error: isEditing ? "Failed to update document" : "Failed to upload document",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update document" : "Failed to upload document"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfAsyncSelectField
|
||||
name="document_type"
|
||||
label="Document Type"
|
||||
placeholder="Select document type"
|
||||
queryKey={["document-types"]}
|
||||
listFn={() => api.vehicleDocuments.listDocumentTypes()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <DocumentTypeInlineForm {...props} />}
|
||||
createLabel="Document Type"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfDocumentField
|
||||
name="file"
|
||||
label="Document File"
|
||||
required
|
||||
/>
|
||||
|
||||
<RhfTextareaField
|
||||
name="note"
|
||||
label="Note"
|
||||
placeholder="Optional notes about this document"
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? null : isEditing ? <Save /> : <Plus />}
|
||||
{isPending ? "Saving..." : isEditing ? "Update Document" : "Upload Document"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
|
||||
|
||||
export const vehicleDocumentFormSchema = z.object({
|
||||
document_type: relationFieldSchema,
|
||||
file: z.instanceof(File, { message: "File is required" }).nullable(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
export type VehicleDocumentFormValues = z.infer<typeof vehicleDocumentFormSchema>
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfImageField,
|
||||
} from "@/shared/components/form"
|
||||
import { ShopTypeInlineForm } from "./inline-forms/shop-type-inline-form"
|
||||
import { BodyTypeInlineForm } from "./inline-forms/body-type-inline-form"
|
||||
@@ -52,6 +53,7 @@ const DEFAULT_VALUES: VehicleFormValues = {
|
||||
mileage: "",
|
||||
owners_number: "",
|
||||
note: "",
|
||||
image: null,
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
@@ -83,10 +85,11 @@ function mapToFormValues(data: unknown): VehicleFormValues {
|
||||
mileage: d.mileage || "",
|
||||
owners_number: d.owners_number || "",
|
||||
note: d.note || "",
|
||||
image: null,
|
||||
}
|
||||
}
|
||||
|
||||
function mapCreatePayload(values: VehicleFormValues) {
|
||||
function mapToPayload(values: VehicleFormValues) {
|
||||
return {
|
||||
shop_type_id: toId(values.shop_type),
|
||||
vehicle_body_type_id: toId(values.vehicle_body_type),
|
||||
@@ -104,13 +107,7 @@ function mapCreatePayload(values: VehicleFormValues) {
|
||||
mileage: values.mileage || undefined,
|
||||
owners_number: values.owners_number || undefined,
|
||||
note: values.note || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapUpdatePayload(values: VehicleFormValues) {
|
||||
return {
|
||||
mileage: values.mileage || undefined,
|
||||
license_plate: values.license_plate || undefined,
|
||||
image: values.image instanceof File ? values.image : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +126,10 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: VehicleFormValues) => {
|
||||
const payload = mapToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.vehicles.update(resourceId, mapUpdatePayload(values))
|
||||
: api.vehicles.create(mapCreatePayload(values))
|
||||
? api.vehicles.update(resourceId, payload)
|
||||
: api.vehicles.create(payload)
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating vehicle..." : "Creating vehicle...",
|
||||
success: isEditing ? "Vehicle updated successfully" : "Vehicle created successfully",
|
||||
@@ -158,102 +156,97 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
{!isEditing && (
|
||||
<>
|
||||
{/* Vehicle identity */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<RhfTextField name="make" label="Make" placeholder="e.g. Toyota" required />
|
||||
<RhfTextField name="model" label="Model" placeholder="e.g. Camry" required />
|
||||
<RhfTextField name="year" label="Year" placeholder="e.g. 2024" required />
|
||||
</div>
|
||||
{/* Vehicle identity */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<RhfTextField name="make" label="Make" placeholder="e.g. Toyota" required />
|
||||
<RhfTextField name="model" label="Model" placeholder="e.g. Camry" required />
|
||||
<RhfTextField name="year" label="Year" placeholder="e.g. 2024" required />
|
||||
</div>
|
||||
|
||||
<RhfTextField name="sub_model" label="Sub Model" placeholder="e.g. LE" />
|
||||
<RhfTextField name="sub_model" label="Sub Model" placeholder="e.g. LE" />
|
||||
|
||||
{/* Associations */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="shop_type"
|
||||
label="Shop Type"
|
||||
placeholder="Select shop type"
|
||||
queryKey={["shop-types"]}
|
||||
listFn={() => api.shopTypes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <ShopTypeInlineForm {...props} />}
|
||||
createLabel="Shop Type"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle_body_type"
|
||||
label="Body Type"
|
||||
placeholder="Select body type"
|
||||
queryKey={["vehicle-body-types"]}
|
||||
listFn={() => api.vehicleAttributes.listBodyTypes()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <BodyTypeInlineForm {...props} />}
|
||||
createLabel="Body Type"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
{/* Associations */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="shop_type"
|
||||
label="Shop Type"
|
||||
placeholder="Select shop type"
|
||||
queryKey={["shop-types"]}
|
||||
listFn={() => api.shopTypes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <ShopTypeInlineForm {...props} />}
|
||||
createLabel="Shop Type"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle_body_type"
|
||||
label="Body Type"
|
||||
placeholder="Select body type"
|
||||
queryKey={["vehicle-body-types"]}
|
||||
listFn={() => api.vehicleAttributes.listBodyTypes()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <BodyTypeInlineForm {...props} />}
|
||||
createLabel="Body Type"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle_fuel_type"
|
||||
label="Fuel Type"
|
||||
placeholder="Select fuel type"
|
||||
queryKey={["vehicle-fuel-types"]}
|
||||
listFn={() => api.vehicleAttributes.listFuelTypes()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <FuelTypeInlineForm {...props} />}
|
||||
createLabel="Fuel Type"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle_transmission"
|
||||
label="Transmission"
|
||||
placeholder="Select transmission"
|
||||
queryKey={["vehicle-transmissions"]}
|
||||
listFn={() => api.vehicleAttributes.listTransmissions()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <TransmissionInlineForm {...props} />}
|
||||
createLabel="Transmission"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle_fuel_type"
|
||||
label="Fuel Type"
|
||||
placeholder="Select fuel type"
|
||||
queryKey={["vehicle-fuel-types"]}
|
||||
listFn={() => api.vehicleAttributes.listFuelTypes()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <FuelTypeInlineForm {...props} />}
|
||||
createLabel="Fuel Type"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle_transmission"
|
||||
label="Transmission"
|
||||
placeholder="Select transmission"
|
||||
queryKey={["vehicle-transmissions"]}
|
||||
listFn={() => api.vehicleAttributes.listTransmissions()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <TransmissionInlineForm {...props} />}
|
||||
createLabel="Transmission"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle_color"
|
||||
label="Color"
|
||||
placeholder="Select color"
|
||||
queryKey={["vehicle-colors"]}
|
||||
listFn={() => api.vehicleAttributes.listColors()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <ColorInlineForm {...props} />}
|
||||
createLabel="Color"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="vin_number" label="VIN Number" placeholder="e.g. 1HGBH41JXMN109186" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle_color"
|
||||
label="Color"
|
||||
placeholder="Select color"
|
||||
queryKey={["vehicle-colors"]}
|
||||
listFn={() => api.vehicleAttributes.listColors()}
|
||||
mapOption={mapLookupOption}
|
||||
createForm={(props) => <ColorInlineForm {...props} />}
|
||||
createLabel="Color"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="vin_number" label="VIN Number" placeholder="e.g. 1HGBH41JXMN109186" />
|
||||
</div>
|
||||
|
||||
{/* Technical specs */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="engine_size" label="Engine Size" placeholder="e.g. 2.5L" />
|
||||
<RhfTextField name="drivetrain" label="Drivetrain" placeholder="e.g. FWD" />
|
||||
</div>
|
||||
{/* Technical specs */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="engine_size" label="Engine Size" placeholder="e.g. 2.5L" />
|
||||
<RhfTextField name="drivetrain" label="Drivetrain" placeholder="e.g. FWD" />
|
||||
</div>
|
||||
|
||||
<RhfTextField name="owners_number" label="Number of Owners" placeholder="e.g. 1" />
|
||||
</>
|
||||
)}
|
||||
<RhfTextField name="owners_number" label="Number of Owners" placeholder="e.g. 1" />
|
||||
|
||||
{/* Editable in both create and update */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="license_plate" label="License Plate" placeholder="e.g. ABC-123" />
|
||||
<RhfTextField name="mileage" label="Mileage" placeholder="e.g. 10000" />
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<RhfTextareaField name="note" label="Notes" rows={3} />
|
||||
)}
|
||||
<RhfImageField name="image" label="Image" />
|
||||
|
||||
<RhfTextareaField name="note" label="Notes" rows={3} />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
Car,
|
||||
Calendar,
|
||||
Gauge,
|
||||
Fuel,
|
||||
Cog,
|
||||
Palette,
|
||||
Hash,
|
||||
FileText,
|
||||
Users,
|
||||
Shield,
|
||||
Wrench,
|
||||
CircleDot,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
type VehicleData = {
|
||||
id?: number
|
||||
make?: string
|
||||
model?: string
|
||||
year?: string
|
||||
sub_model?: string
|
||||
license_plate?: string
|
||||
vin_number?: string
|
||||
engine_number?: string | null
|
||||
engine_size?: string
|
||||
drivetrain?: string
|
||||
mileage?: string
|
||||
owners_number?: string
|
||||
front_tire_size?: string | null
|
||||
rear_tire_size?: string | null
|
||||
note?: string
|
||||
image_url?: string | null
|
||||
reg_date?: string | null
|
||||
mfg_date?: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
shop_type?: { id?: number; title?: string }
|
||||
vehicle_body_type?: { id?: number; title?: string }
|
||||
vehicle_fuel_type?: { id?: number; title?: string }
|
||||
vehicle_transmission?: { id?: number; title?: string }
|
||||
vehicle_color?: { id?: number; title?: string; code?: string }
|
||||
}
|
||||
|
||||
type VehicleGeneralInfoProps = {
|
||||
vehicle: VehicleData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VehicleGeneralInfo({ vehicle }: VehicleGeneralInfoProps) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Vehicle Identity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Car className="size-4" />
|
||||
Vehicle Identity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{[vehicle.make, vehicle.model].filter(Boolean).join(" ") || "Unknown"}
|
||||
</Badge>
|
||||
{vehicle.year && <Badge variant="outline">{vehicle.year}</Badge>}
|
||||
{vehicle.sub_model && (
|
||||
<Badge variant="outline">{vehicle.sub_model}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="License Plate"
|
||||
value={vehicle.license_plate}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Shield}
|
||||
label="VIN Number"
|
||||
value={vehicle.vin_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={FileText}
|
||||
label="Engine Number"
|
||||
value={vehicle.engine_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Users}
|
||||
label="Number of Owners"
|
||||
value={vehicle.owners_number}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Technical Specifications */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wrench className="size-4" />
|
||||
Technical Specifications
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{vehicle.vehicle_body_type?.title && (
|
||||
<Badge variant="secondary">{vehicle.vehicle_body_type.title}</Badge>
|
||||
)}
|
||||
{vehicle.vehicle_transmission?.title && (
|
||||
<Badge variant="outline">{vehicle.vehicle_transmission.title}</Badge>
|
||||
)}
|
||||
{vehicle.vehicle_fuel_type?.title && (
|
||||
<Badge variant="outline">{vehicle.vehicle_fuel_type.title}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Cog}
|
||||
label="Engine Size"
|
||||
value={vehicle.engine_size}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={CircleDot}
|
||||
label="Drivetrain"
|
||||
value={vehicle.drivetrain}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Gauge}
|
||||
label="Mileage"
|
||||
value={vehicle.mileage ? `${Number(vehicle.mileage).toLocaleString()} km` : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Fuel}
|
||||
label="Fuel Type"
|
||||
value={vehicle.vehicle_fuel_type?.title}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Appearance & Shop */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="size-4" />
|
||||
Appearance
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Palette}
|
||||
label="Color"
|
||||
value={vehicle.vehicle_color?.title}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Wrench}
|
||||
label="Shop Type"
|
||||
value={vehicle.shop_type?.title}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={CircleDot}
|
||||
label="Front Tire Size"
|
||||
value={vehicle.front_tire_size}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={CircleDot}
|
||||
label="Rear Tire Size"
|
||||
value={vehicle.rear_tire_size}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates & Notes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="size-4" />
|
||||
Dates & Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Registration Date"
|
||||
value={vehicle.reg_date ? new Date(vehicle.reg_date).toLocaleDateString() : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Manufacturing Date"
|
||||
value={vehicle.mfg_date ? new Date(vehicle.mfg_date).toLocaleDateString() : null}
|
||||
/>
|
||||
</div>
|
||||
{vehicle.note && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">Note</span>
|
||||
<p className="text-sm whitespace-pre-wrap">{vehicle.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,9 @@ export const vehicleFormSchema = z.object({
|
||||
|
||||
// ── Notes ──
|
||||
note: z.string().optional(),
|
||||
|
||||
// ── Image ──
|
||||
image: z.any().optional(),
|
||||
})
|
||||
|
||||
export type VehicleFormValues = z.infer<typeof vehicleFormSchema>
|
||||
|
||||
Reference in New Issue
Block a user