initialize

This commit is contained in:
2026-03-26 03:49:05 +03:00
parent e9211a4aba
commit 5dc54ffd00
146 changed files with 12078 additions and 146 deletions
+55
View File
@@ -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 }
}
+11
View File
@@ -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 }
+148
View File
@@ -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&apos;t have an account? <a href="#">Sign up</a>
</FieldDescription> */}
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
)
}
+264
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+267
View File
@@ -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>
)
}
+35
View File
@@ -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>