initialize
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"use server"
|
||||
|
||||
import { cookies } from "next/headers"
|
||||
import type { AuthUser } from "@repo/api"
|
||||
|
||||
const TOKEN_COOKIE = "auth_token"
|
||||
const USER_COOKIE = "auth_user"
|
||||
const DEFAULT_EXPIRES_IN = 60 * 60 * 24 * 7 // 7 days in seconds
|
||||
|
||||
export async function setAuthCookies(
|
||||
token: string,
|
||||
user: AuthUser,
|
||||
expiresIn: number = DEFAULT_EXPIRES_IN,
|
||||
) {
|
||||
const cookieStore = await cookies()
|
||||
const expires = new Date(Date.now() + expiresIn * 1000)
|
||||
|
||||
cookieStore.set(TOKEN_COOKIE, token, {
|
||||
expires,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
})
|
||||
|
||||
cookieStore.set(USER_COOKIE, JSON.stringify(user), {
|
||||
expires,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearAuthCookies() {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.delete(TOKEN_COOKIE)
|
||||
cookieStore.delete(USER_COOKIE)
|
||||
}
|
||||
|
||||
export async function getAuthCookies(): Promise<{
|
||||
token: string | undefined
|
||||
user: AuthUser | undefined
|
||||
}> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(TOKEN_COOKIE)?.value
|
||||
const rawUser = cookieStore.get(USER_COOKIE)?.value
|
||||
|
||||
let user: AuthUser | undefined
|
||||
if (rawUser) {
|
||||
try {
|
||||
user = JSON.parse(rawUser) as AuthUser
|
||||
} catch {
|
||||
user = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return { token, user }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const loginFormSchema = z.object({
|
||||
email: z.string().trim().email("Enter a valid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
})
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginFormSchema>
|
||||
|
||||
export { loginFormSchema }
|
||||
export type { LoginFormValues }
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { api } from '@repo/api'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/shared/components/ui/field"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { useAppStore } from "@/shared/stores/app-store"
|
||||
import { useAuthStore } from "@/shared/stores/auth-store"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { loginFormSchema, type LoginFormValues } from "./login-form.schema"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const lastLoginEmail = useAppStore((state) => state.lastLoginEmail)
|
||||
const setLastLoginEmail = useAppStore((state) => state.setLastLoginEmail)
|
||||
const login = useAuthStore((state) => state.login)
|
||||
const router = useRouter()
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors, },
|
||||
} = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginFormSchema),
|
||||
defaultValues: process.env.NODE_ENV === "development" ? {
|
||||
"email": "admin@admin.com",
|
||||
"password": "12345678"
|
||||
} : {
|
||||
email: lastLoginEmail,
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate, error, isPending: isSubmitting } = useMutation({
|
||||
mutationFn: (values: LoginFormValues) => api.auth.login(values),
|
||||
onSuccess: async (data) => {
|
||||
if (data.token && data.user) {
|
||||
await login(data.token, data.user as Parameters<typeof login>[1])
|
||||
router.push("/")
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
async function onSubmit(values: LoginFormValues) {
|
||||
setLastLoginEmail(values.email)
|
||||
mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Image
|
||||
className="mx-auto mb-8 h-20 w-48"
|
||||
alt="Logo"
|
||||
src="/assets/logo.png"
|
||||
height={200}
|
||||
width={200}
|
||||
/>
|
||||
<CardTitle>Login to your account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to login to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error ? (
|
||||
<Alert variant='destructive' className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Login failed</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
aria-invalid={!!errors.email}
|
||||
{...register("email")}
|
||||
/>
|
||||
<FieldError errors={[errors.email]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
aria-invalid={!!errors.password}
|
||||
{...register("password")}
|
||||
/>
|
||||
<FieldError errors={[errors.password]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Logging in..." : "Login"}
|
||||
</Button>
|
||||
|
||||
{lastLoginEmail ? (
|
||||
<FieldDescription className="text-center">
|
||||
Last email used: {lastLoginEmail}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
{/* <FieldDescription className="text-center">
|
||||
Don't have an account? <a href="#">Sign up</a>
|
||||
</FieldDescription> */}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"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,
|
||||
RhfSelectField,
|
||||
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 {
|
||||
customerFormSchema,
|
||||
type CustomerFormValues,
|
||||
} from "./customer.schema"
|
||||
import { CUSTOMER_ROUTES } from "@repo/api"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const SALUTATION_OPTIONS = [
|
||||
{ value: "Mr.", label: "Mr." },
|
||||
{ value: "Mrs.", label: "Mrs." },
|
||||
{ value: "Ms.", label: "Ms." },
|
||||
{ value: "Miss", label: "Miss" },
|
||||
{ value: "Dr.", label: "Dr." },
|
||||
{ value: "Prof.", label: "Prof." },
|
||||
]
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type CustomerFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const CUSTOMER_DEFAULT_VALUES: CustomerFormValues = {
|
||||
customer_type: null,
|
||||
referral_source: null,
|
||||
payment_terms: null,
|
||||
country: null,
|
||||
state: null,
|
||||
salutation: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
company_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alternate_phone: "",
|
||||
address_line_1: "",
|
||||
address_line_2: "",
|
||||
city: "",
|
||||
zip_code: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapCustomerToFormValues(data: unknown): CustomerFormValues {
|
||||
const c = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
customer_type: toRelation(c.customer_type_id, c.customer_type_name),
|
||||
referral_source: toRelation(c.referral_source_id, c.referral_source_name),
|
||||
payment_terms: toRelation(c.payment_terms_id, c.payment_terms_name),
|
||||
country: toRelation(c.country_id, c.country_name),
|
||||
state: toRelation(c.state_id, c.state_name),
|
||||
salutation: c.salutation || "",
|
||||
first_name: c.first_name || "",
|
||||
last_name: c.last_name || "",
|
||||
company_name: c.company_name || "",
|
||||
email: c.email || "",
|
||||
phone: c.phone || "",
|
||||
alternate_phone: c.alternate_phone || "",
|
||||
address_line_1: c.address_line_1 || "",
|
||||
address_line_2: c.address_line_2 || "",
|
||||
city: c.city || "",
|
||||
zip_code: c.zip_code || "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: CustomerFormValues) {
|
||||
return {
|
||||
customer_type_id: toId(values.customer_type),
|
||||
referral_source_id: toId(values.referral_source),
|
||||
payment_terms_id: toId(values.payment_terms),
|
||||
country_id: toId(values.country),
|
||||
state_id: toId(values.state),
|
||||
salutation: values.salutation || undefined,
|
||||
first_name: values.first_name,
|
||||
last_name: values.last_name,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared mapOption for async selects ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<CustomerFormValues, any>({
|
||||
schema: customerFormSchema,
|
||||
defaultValues: CUSTOMER_DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
initialize: (id) => api.customers.show(id),
|
||||
queryKey: [CUSTOMER_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues: mapCustomerToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: CustomerFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.customers.update(resourceId, payload)
|
||||
: api.customers.create(payload)
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating customer..." : "Creating customer...",
|
||||
success: isEditing ? "Customer updated successfully" : "Customer created successfully",
|
||||
error: isEditing ? "Failed to update customer" : "Failed to create customer",
|
||||
})
|
||||
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 customer" : "Failed to create customer"}</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="salutation"
|
||||
label="Salutation"
|
||||
placeholder="Select salutation"
|
||||
options={SALUTATION_OPTIONS}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="customer_type"
|
||||
label="Customer Type"
|
||||
placeholder="Select customer type"
|
||||
queryKey={[CUSTOMER_ROUTES.CUSTOMER_TYPES]}
|
||||
listFn={() => api.customers.listCustomerTypes()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Name */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
|
||||
<RhfTextField name="last_name" label="Last Name" placeholder="Doe" required />
|
||||
</div>
|
||||
|
||||
<RhfTextField name="company_name" label="Company Name" placeholder="Doe Holdings" />
|
||||
|
||||
{/* Contact */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" />
|
||||
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
|
||||
</div>
|
||||
|
||||
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="0551234567" type="tel" />
|
||||
|
||||
{/* Relations */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="referral_source"
|
||||
label="Referral Source"
|
||||
placeholder="Select referral source"
|
||||
queryKey={["referral-sources"]}
|
||||
listFn={() => api.referralSources.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="payment_terms"
|
||||
label="Payment Terms"
|
||||
placeholder="Select payment terms"
|
||||
queryKey={["payment-terms"]}
|
||||
listFn={() => api.paymentTerms.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<RhfTextField name="address_line_1" label="Address Line 1" placeholder="Street 10" />
|
||||
<RhfTextField name="address_line_2" label="Address Line 2" placeholder="Near Central Plaza" />
|
||||
|
||||
<div className="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="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>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Customer" : "Create Customer")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* Reusable schema for relation/lookup fields stored as `{ value, label }` objects.
|
||||
* Use `.nullable()` when the field is optional but explicitly clearable.
|
||||
*/
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
type RelationField = z.infer<typeof relationFieldSchema>
|
||||
|
||||
const customerFormSchema = z.object({
|
||||
// ── Relations (stored as objects, mapped to IDs on submit) ──
|
||||
customer_type: relationFieldSchema,
|
||||
referral_source: relationFieldSchema,
|
||||
payment_terms: relationFieldSchema,
|
||||
country: relationFieldSchema,
|
||||
state: relationFieldSchema,
|
||||
|
||||
// ── Basic info ──
|
||||
salutation: z.string().optional(),
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
company_name: z.string().optional(),
|
||||
|
||||
// ── Contact ──
|
||||
email: z
|
||||
.union([z.string().email("Enter a valid email address"), z.literal("")])
|
||||
.optional(),
|
||||
phone: z.string().optional(),
|
||||
alternate_phone: z.string().optional(),
|
||||
|
||||
// ── Address ──
|
||||
address_line_1: z.string().optional(),
|
||||
address_line_2: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
zip_code: z.string().optional(),
|
||||
})
|
||||
|
||||
type CustomerFormValues = z.infer<typeof customerFormSchema>
|
||||
|
||||
export { customerFormSchema, relationFieldSchema }
|
||||
export type { CustomerFormValues, RelationField }
|
||||
@@ -0,0 +1,157 @@
|
||||
"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,
|
||||
RhfTextareaField,
|
||||
RhfCheckboxField,
|
||||
RhfFileField,
|
||||
} 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 { shopTypeFormSchema, type ShopTypeFormValues } from "./shop-type.schema"
|
||||
import { SHOP_TYPE_ROUTES } from "@repo/api"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type ShopTypeFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: ShopTypeFormValues = {
|
||||
title: "",
|
||||
shop_type: "",
|
||||
note: "",
|
||||
is_default: false,
|
||||
inspection: null,
|
||||
image: null,
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): ShopTypeFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
title: d.title || "",
|
||||
shop_type: d.shop_type || "",
|
||||
note: d.note || "",
|
||||
is_default: d.is_default ?? false,
|
||||
// File fields cannot be pre-filled from URL strings
|
||||
inspection: null,
|
||||
image: null,
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: ShopTypeFormValues) {
|
||||
return {
|
||||
title: values.title,
|
||||
shop_type: values.shop_type || undefined,
|
||||
note: values.note || undefined,
|
||||
is_default: values.is_default,
|
||||
inspection: values.inspection ?? undefined,
|
||||
image: values.image ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function ShopTypeForm({ resourceId, initialData, onSuccess }: ShopTypeFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<ShopTypeFormValues, any>({
|
||||
schema: shopTypeFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: ShopTypeFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.shopTypes.update(resourceId, payload)
|
||||
: api.shopTypes.create({ ...payload, title: values.title })
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating shop type..." : "Creating shop type...",
|
||||
success: isEditing ? "Shop type updated successfully" : "Shop type created successfully",
|
||||
error: isEditing ? "Failed to update shop type" : "Failed to create shop type",
|
||||
})
|
||||
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 shop type" : "Failed to create shop type"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Main Workshop"
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="shop_type"
|
||||
label="Type"
|
||||
placeholder="e.g. Car, Truck"
|
||||
/>
|
||||
<RhfTextareaField
|
||||
name="note"
|
||||
label="Note"
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
<RhfCheckboxField
|
||||
name="is_default"
|
||||
label="Set as default"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfFileField
|
||||
name="inspection"
|
||||
label="Inspection Template"
|
||||
accept=".pdf,.doc,.docx"
|
||||
/>
|
||||
<RhfFileField
|
||||
name="image"
|
||||
label="Image"
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Shop Type" : "Create Shop Type")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const shopTypeFormSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
shop_type: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
is_default: z.boolean().optional(),
|
||||
inspection: z.any().optional(),
|
||||
image: z.any().optional(),
|
||||
})
|
||||
|
||||
export type ShopTypeFormValues = {
|
||||
title: string
|
||||
shop_type?: string
|
||||
note?: string
|
||||
is_default?: boolean
|
||||
inspection?: File | null
|
||||
image?: File | null
|
||||
}
|
||||
@@ -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 BodyTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await api.vehicleAttributes.createBodyType({ title: values.title })
|
||||
toast.success("Body type created")
|
||||
form.reset()
|
||||
const item = (result as any)?.data ?? result
|
||||
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||
} catch {
|
||||
toast.error("Failed to create body type")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Sedan"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Body Type"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -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 ColorInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await api.vehicleAttributes.createColor({ title: values.title })
|
||||
toast.success("Color created")
|
||||
form.reset()
|
||||
const item = (result as any)?.data ?? result
|
||||
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||
} catch {
|
||||
toast.error("Failed to create color")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Black"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Color"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -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 FuelTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await api.vehicleAttributes.createFuelType({ title: values.title })
|
||||
toast.success("Fuel type created")
|
||||
form.reset()
|
||||
const item = (result as any)?.data ?? result
|
||||
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||
} catch {
|
||||
toast.error("Failed to create fuel type")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Gasoline"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Fuel Type"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"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,
|
||||
RhfTextareaField,
|
||||
RhfCheckboxField,
|
||||
RhfFileField,
|
||||
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"),
|
||||
shop_type: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
is_default: z.boolean().optional(),
|
||||
inspection: z.any().optional(),
|
||||
image: z.any().optional(),
|
||||
})
|
||||
|
||||
type FormValues = {
|
||||
title: string
|
||||
shop_type?: string
|
||||
note?: string
|
||||
is_default?: boolean
|
||||
inspection?: File | null
|
||||
image?: File | null
|
||||
}
|
||||
|
||||
export function ShopTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
shop_type: "",
|
||||
note: "",
|
||||
is_default: false,
|
||||
inspection: null,
|
||||
image: null,
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await api.shopTypes.create({
|
||||
title: values.title,
|
||||
shop_type: values.shop_type || undefined,
|
||||
note: values.note || undefined,
|
||||
is_default: values.is_default,
|
||||
inspection: values.inspection ?? undefined,
|
||||
image: values.image ?? undefined,
|
||||
})
|
||||
toast.success("Shop type created")
|
||||
form.reset()
|
||||
const item = (result as any)?.data ?? result
|
||||
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||
} catch {
|
||||
toast.error("Failed to create shop type")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Main Workshop"
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="shop_type"
|
||||
label="Type"
|
||||
placeholder="e.g. Car, Truck"
|
||||
/>
|
||||
<RhfTextareaField
|
||||
name="note"
|
||||
label="Note"
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
<RhfCheckboxField
|
||||
name="is_default"
|
||||
label="Set as default"
|
||||
/>
|
||||
<RhfFileField
|
||||
name="inspection"
|
||||
label="Inspection Template"
|
||||
accept=".pdf,.doc,.docx"
|
||||
/>
|
||||
<RhfFileField
|
||||
name="image"
|
||||
label="Image"
|
||||
accept="image/*"
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Shop Type"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 TransmissionInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await api.vehicleAttributes.createTransmission({ title: values.title })
|
||||
toast.success("Transmission created")
|
||||
form.reset()
|
||||
const item = (result as any)?.data ?? result
|
||||
onSuccess({ value: String(item.id), label: item.title ?? String(item.id) })
|
||||
} catch {
|
||||
toast.error("Failed to create transmission")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Automatic"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Transmission"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"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,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
} from "@/shared/components/form"
|
||||
import { ShopTypeInlineForm } from "./inline-forms/shop-type-inline-form"
|
||||
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 { 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 { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema"
|
||||
import { VEHICLE_ROUTES } from "@repo/api"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type VehicleFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: VehicleFormValues = {
|
||||
shop_type: null,
|
||||
vehicle_body_type: null,
|
||||
vehicle_fuel_type: null,
|
||||
vehicle_transmission: null,
|
||||
vehicle_color: null,
|
||||
make: "",
|
||||
model: "",
|
||||
year: "",
|
||||
sub_model: "",
|
||||
license_plate: "",
|
||||
vin_number: "",
|
||||
engine_size: "",
|
||||
drivetrain: "",
|
||||
mileage: "",
|
||||
owners_number: "",
|
||||
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): VehicleFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
shop_type: toRelation(d.shop_type_id, d.shop_type?.title),
|
||||
vehicle_body_type: toRelation(d.vehicle_body_type_id, d.vehicle_body_type?.title),
|
||||
vehicle_fuel_type: toRelation(d.vehicle_fuel_type_id, d.vehicle_fuel_type?.title),
|
||||
vehicle_transmission: toRelation(d.vehicle_transmission_id, d.vehicle_transmission?.title),
|
||||
vehicle_color: toRelation(d.vehicle_color_id, d.vehicle_color?.title),
|
||||
make: d.make || "",
|
||||
model: d.model || "",
|
||||
year: d.year || "",
|
||||
sub_model: d.sub_model || "",
|
||||
license_plate: d.license_plate || "",
|
||||
vin_number: d.vin_number || "",
|
||||
engine_size: d.engine_size || "",
|
||||
drivetrain: d.drivetrain || "",
|
||||
mileage: d.mileage || "",
|
||||
owners_number: d.owners_number || "",
|
||||
note: d.note || "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapCreatePayload(values: VehicleFormValues) {
|
||||
return {
|
||||
shop_type_id: toId(values.shop_type),
|
||||
vehicle_body_type_id: toId(values.vehicle_body_type),
|
||||
vehicle_fuel_type_id: toId(values.vehicle_fuel_type),
|
||||
vehicle_transmission_id: toId(values.vehicle_transmission),
|
||||
vehicle_color_id: toId(values.vehicle_color),
|
||||
make: values.make,
|
||||
model: values.model,
|
||||
year: values.year,
|
||||
sub_model: values.sub_model || undefined,
|
||||
license_plate: values.license_plate || undefined,
|
||||
vin_number: values.vin_number || undefined,
|
||||
engine_size: values.engine_size || undefined,
|
||||
drivetrain: values.drivetrain || undefined,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<VehicleFormValues, any>({
|
||||
schema: vehicleFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: VehicleFormValues) => {
|
||||
const promise = isEditing && resourceId
|
||||
? api.vehicles.update(resourceId, mapUpdatePayload(values))
|
||||
: api.vehicles.create(mapCreatePayload(values))
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating vehicle..." : "Creating vehicle...",
|
||||
success: isEditing ? "Vehicle updated successfully" : "Vehicle created successfully",
|
||||
error: isEditing ? "Failed to update vehicle" : "Failed to create vehicle",
|
||||
})
|
||||
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 vehicle" : "Failed to create vehicle"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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} />
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Vehicle" : "Create Vehicle")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
export const vehicleFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
shop_type: relationFieldSchema,
|
||||
vehicle_body_type: relationFieldSchema,
|
||||
vehicle_fuel_type: relationFieldSchema,
|
||||
vehicle_transmission: relationFieldSchema,
|
||||
vehicle_color: relationFieldSchema,
|
||||
|
||||
// ── Vehicle identity ──
|
||||
make: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
year: z.string().optional(),
|
||||
sub_model: z.string().optional(),
|
||||
|
||||
// ── License & identifiers ──
|
||||
license_plate: z.string().optional(),
|
||||
vin_number: z.string().optional(),
|
||||
|
||||
// ── Technical specs ──
|
||||
engine_size: z.string().optional(),
|
||||
drivetrain: z.string().optional(),
|
||||
mileage: z.string().optional(),
|
||||
owners_number: z.string().optional(),
|
||||
|
||||
// ── Notes ──
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
export type VehicleFormValues = z.infer<typeof vehicleFormSchema>
|
||||
Reference in New Issue
Block a user