This commit is contained in:
2026-03-27 16:20:46 +03:00
commit 13b56d4960
260 changed files with 111131 additions and 0 deletions
@@ -0,0 +1,55 @@
"use server"
import { cookies } from "next/headers"
import type { AuthUser } from "@garage/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 }
+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 '@garage/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>
)
}
@@ -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 "@garage/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,236 @@
"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,
RhfCheckboxField,
} 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 {
employeeFormSchema,
type EmployeeFormValues,
} from "./employee.schema"
import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES } from "@garage/api"
// ── Constants ──
const STATUS_OPTIONS = [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
]
const TYPE_OPTIONS = [
{ value: "employee", label: "Employee" },
{ value: "contractor", label: "Contractor" },
]
// ── Props ──
export type EmployeeFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: EmployeeFormValues = {
department: null,
shop_calender: null,
shop_timing: null,
first_name: "",
last_name: "",
email: "",
phone: "",
position: "",
status: "active",
type: "employee",
track_attendance: true,
notify_owner_when_punch_in_out: false,
geo_fence_radius: 100,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): EmployeeFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
department: toRelation(d.department_id, d.department?.name),
shop_calender: toRelation(d.shop_calender_id, d.shop_calender?.title),
shop_timing: toRelation(d.shop_timing_id, d.shop_timing?.title),
first_name: d.first_name || "",
last_name: d.last_name || "",
email: d.email || "",
phone: d.phone || "",
position: d.position || "",
status: d.status || "active",
type: d.type || "employee",
track_attendance: d.track_attendance ?? true,
notify_owner_when_punch_in_out: d.notify_owner_when_punch_in_out ?? false,
geo_fence_radius: d.geo_fence_radius ?? 100,
}
}
function mapFormToPayload(values: EmployeeFormValues) {
return {
department_id: toId(values.department),
shop_calender_id: toId(values.shop_calender),
shop_timing_id: toId(values.shop_timing),
first_name: values.first_name,
last_name: values.last_name,
email: values.email || undefined,
phone: values.phone || undefined,
position: values.position || undefined,
status: values.status || undefined,
type: values.type || undefined,
track_attendance: values.track_attendance,
notify_owner_when_punch_in_out: values.notify_owner_when_punch_in_out,
geo_fence_radius: values.geo_fence_radius,
}
}
// ── Shared mapOption for async selects ──
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 }
// ── Component ──
export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<EmployeeFormValues, any>({
schema: employeeFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.employees.show(id),
queryKey: [EMPLOYEE_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: EmployeeFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.employees.update(resourceId, payload)
: api.employees.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating employee..." : "Creating employee...",
success: isEditing ? "Employee updated successfully" : "Employee created successfully",
error: isEditing ? "Failed to update employee" : "Failed to create employee",
})
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 employee" : "Failed to create employee"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="first_name" label="First Name" placeholder="Jane" required />
<RhfTextField name="last_name" label="Last Name" placeholder="Smith" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" placeholder="jane@example.com" type="email" />
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="position" label="Position" placeholder="Technician" />
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={STATUS_OPTIONS}
/>
<RhfSelectField
name="type"
label="Type"
placeholder="Select type"
options={TYPE_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_calender"
label="Shop Calendar"
placeholder="Select calendar"
queryKey={[SHOP_CALENDAR_ROUTES.INDEX]}
listFn={() => api.shopCalendars.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="shop_timing"
label="Shop Timing"
placeholder="Select timing"
queryKey={[SHOP_TIMING_ROUTES.INDEX]}
listFn={() => api.shopTimings.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfTextField name="geo_fence_radius" label="Geo Fence Radius (m)" placeholder="100" type="number" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCheckboxField name="track_attendance" label="Track Attendance" />
<RhfCheckboxField name="notify_owner_when_punch_in_out" label="Notify Owner on Punch In/Out" />
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Employee" : "Create Employee")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,32 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const STATUS_OPTIONS = ["active", "inactive"] as const
const TYPE_OPTIONS = ["employee", "contractor"] as const
const employeeFormSchema = z.object({
department: relationFieldSchema,
shop_calender: relationFieldSchema,
shop_timing: relationFieldSchema,
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
phone: z.string().optional(),
position: z.string().optional(),
status: z.string().optional(),
type: z.string().optional(),
track_attendance: z.boolean(),
notify_owner_when_punch_in_out: z.boolean(),
geo_fence_radius: z.coerce.number().min(0).optional(),
})
type EmployeeFormValues = z.infer<typeof employeeFormSchema>
export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS }
export type { EmployeeFormValues }
@@ -0,0 +1,57 @@
"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({
inspection_name: z.string().min(1, "Name is required"),
})
type FormValues = z.infer<typeof schema>
export function InspectionCategoryInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { inspection_name: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.inspections.createCategory({
inspection_name: values.inspection_name,
})
toast.success("Inspection category created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.name ?? values.inspection_name })
} catch {
toast.error("Failed to create inspection category")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="inspection_name"
label="Name"
placeholder="e.g. Brake Check"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Category"}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,231 @@
"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,
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 { InspectionCategoryInlineForm } from "./inline-forms/inspection-category-inline-form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
import {
inspectionFormSchema,
type InspectionFormValues,
} from "./inspection.schema"
import {
INSPECTION_ROUTES,
CUSTOMER_ROUTES,
VEHICLE_ROUTES,
DEPARTMENT_ROUTES,
EMPLOYEE_ROUTES,
} from "@garage/api"
// ── Props ──
export type InspectionFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: InspectionFormValues = {
customer: null,
vehicle: null,
department: null,
inspection_category: null,
employee: null,
title: "",
order_number: "",
date: "",
time: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): InspectionFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
customer: toRelation(d.customer_id, d.customer?.first_name ? `${d.customer.first_name} ${d.customer.last_name ?? ""}`.trim() : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle?.make ? `${d.vehicle.make} ${d.vehicle.model ?? ""}`.trim() : undefined),
department: toRelation(d.department_id, d.department?.name),
inspection_category: toRelation(d.inspection_category_id, d.inspection_category?.name),
employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : undefined),
title: d.title ?? "",
order_number: d.order_number ?? "",
date: d.date ?? "",
time: d.time ?? "",
}
}
function mapFormToPayload(values: InspectionFormValues) {
return {
customer_id: toId(values.customer),
vehicle_id: toId(values.vehicle),
department_id: toId(values.department),
inspection_category_id: toId(values.inspection_category),
employee_id: toId(values.employee),
title: values.title,
order_number: values.order_number || undefined,
date: values.date || undefined,
time: values.time || undefined,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const mapCustomerOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
})
const mapVehicleOption = (item: any) => ({
value: String(item.id),
label: `${item.make ?? ""} ${item.model ?? ""} ${item.year ?? ""}`.trim() || String(item.id),
})
const mapEmployeeOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function InspectionForm({ resourceId, initialData, onSuccess }: InspectionFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<InspectionFormValues, any>({
schema: inspectionFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [INSPECTION_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: InspectionFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.inspections.update(resourceId, payload)
: api.inspections.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating inspection..." : "Creating inspection...",
success: isEditing ? "Inspection updated successfully" : "Inspection created successfully",
error: isEditing ? "Failed to update inspection" : "Failed to create inspection",
})
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 inspection" : "Failed to create inspection"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="e.g. Pre-purchase" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="customer"
label="Customer"
placeholder="Select customer"
queryKey={[CUSTOMER_ROUTES.INDEX]}
listFn={() => api.customers.list()}
mapOption={mapCustomerOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle"
label="Vehicle"
placeholder="Select vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()}
mapOption={mapVehicleOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="inspection_category"
label="Inspection Category"
placeholder="Select category"
queryKey={[INSPECTION_ROUTES.CATEGORIES]}
listFn={() => api.inspections.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InspectionCategoryInlineForm {...props} />}
createLabel="Inspection Category"
{...STORE_OBJECT}
/>
</div>
<RhfAsyncSelectField
name="employee"
label="Employee"
placeholder="Select employee"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
<RhfTextField name="order_number" label="Order Number" placeholder="e.g. ORD-001" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
<RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" />
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Inspection" : "Create Inspection")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,22 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const inspectionFormSchema = z.object({
customer: relationFieldSchema,
vehicle: relationFieldSchema,
department: relationFieldSchema,
inspection_category: relationFieldSchema,
employee: relationFieldSchema,
title: z.string().min(1, "Title is required"),
order_number: z.string().optional(),
date: z.string().optional(),
time: z.string().optional(),
})
type InspectionFormValues = z.infer<typeof inspectionFormSchema>
export { inspectionFormSchema, relationFieldSchema }
export type { InspectionFormValues }
+242
View File
@@ -0,0 +1,242 @@
"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 "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { InventoryCategoryInlineForm } from "@/modules/services/inline-forms/inventory-category-inline-form"
import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-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 { toId } from "@/shared/lib/utils"
import { partFormSchema, type PartFormValues } from "./part.schema"
import { PARTS_ROUTES } from "@garage/api"
// ── Props ──
export type PartFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: PartFormValues = {
shop_type: null,
category: null,
unit_type: null,
department: null,
title: "",
sku: "",
description: "",
selling_price: undefined,
purchase_price: undefined,
}
// ── 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): PartFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
shop_type: null,
category: null,
unit_type: null,
department: null,
title: d.title ?? d.name ?? "",
sku: d.sku ?? "",
description: d.description ?? "",
selling_price: d.selling_price ?? undefined,
purchase_price: d.purchase_price ?? undefined,
}
}
function mapCreatePayload(values: PartFormValues) {
return {
shop_type_id: toId(values.shop_type),
category_id: toId(values.category),
unit_type_id: toId(values.unit_type),
department_id: toId(values.department),
title: values.title,
sku: values.sku || undefined,
description: values.description || undefined,
selling_price: values.selling_price,
purchase_price: values.purchase_price,
}
}
function mapUpdatePayload(values: PartFormValues) {
return {
title: values.title,
selling_price: values.selling_price,
}
}
// ── Component ──
export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<PartFormValues, any>({
schema: partFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PartFormValues) => {
const promise = isEditing && resourceId
? api.parts.update(resourceId, mapUpdatePayload(values))
: api.parts.create(mapCreatePayload(values))
toast.promise(promise, {
loading: isEditing ? "Updating part..." : "Creating part...",
success: isEditing ? "Part updated successfully" : "Part created successfully",
error: isEditing ? "Failed to update part" : "Failed to create part",
})
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 part" : "Failed to create part"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Brake Pad"
required
/>
<RhfTextField
name="sku"
label="SKU"
placeholder="e.g. BP-001"
/>
</div>
{!isEditing && (
<>
<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="category"
label="Category"
placeholder="Select category"
queryKey={["inventory-categories"]}
listFn={() => api.inventory.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
createLabel="Category"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="unit_type"
label="Unit Type"
placeholder="Select unit type"
queryKey={["unit-types"]}
listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />}
createLabel="Unit Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
</div>
</>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
placeholder="0.00"
type="number"
/>
{!isEditing && (
<RhfTextField
name="purchase_price"
label="Purchase Price"
placeholder="0.00"
type="number"
/>
)}
</div>
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional description"
rows={3}
/>
</FieldGroup>
<div className="mt-4 flex justify-end">
<Button type="submit" disabled={isPending}>
{isEditing ? <Save className="me-2 h-4 w-4" /> : <Plus className="me-2 h-4 w-4" />}
{isPending
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update Part" : "Create Part"}
</Button>
</div>
</Rhform>
)
}
@@ -0,0 +1,19 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const partFormSchema = z.object({
shop_type: relationFieldSchema,
category: relationFieldSchema,
unit_type: relationFieldSchema,
department: relationFieldSchema,
title: z.string().min(1, "Title is required"),
sku: z.string().optional(),
description: z.string().optional(),
selling_price: z.coerce.number().min(0).optional(),
purchase_price: z.coerce.number().min(0).optional(),
})
export type PartFormValues = z.infer<typeof partFormSchema>
@@ -0,0 +1,274 @@
"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,
RhfCheckboxField,
} from "@/shared/components/form"
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { InventoryCategoryInlineForm } from "@/modules/services/inline-forms/inventory-category-inline-form"
import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-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 { toId } from "@/shared/lib/utils"
import { serviceGroupFormSchema, type ServiceGroupFormValues } from "./service-group.schema"
import { SERVICE_GROUP_ROUTES } from "@garage/api"
// ── Props ──
export type ServiceGroupFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ServiceGroupFormValues = {
shop_type: null,
inventory_category: null,
unit_type: null,
department: null,
service_name: "",
code: "",
service_description: "",
selling_price: undefined,
selling_chart_of_account: "",
show_as_lump_sum: false,
mark_as_recommended: false,
set_packaged_pricing: false,
is_active: true,
}
// ── 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): ServiceGroupFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
shop_type: null,
inventory_category: null,
unit_type: null,
department: null,
service_name: d.service_name ?? d.name ?? "",
code: d.code ?? "",
service_description: d.service_description ?? "",
selling_price: d.selling_price ?? undefined,
selling_chart_of_account: d.selling_chart_of_account ?? "",
show_as_lump_sum: d.show_as_lump_sum ?? false,
mark_as_recommended: d.mark_as_recommended ?? false,
set_packaged_pricing: d.set_packaged_pricing ?? false,
is_active: d.is_active ?? true,
}
}
function mapCreatePayload(values: ServiceGroupFormValues) {
return {
service_name: values.service_name,
shop_type_id: toId(values.shop_type),
code: values.code || undefined,
inventory_category_id: toId(values.inventory_category),
unit_type_id: toId(values.unit_type),
department_id: toId(values.department),
service_description: values.service_description || undefined,
show_as_lump_sum: values.show_as_lump_sum,
mark_as_recommended: values.mark_as_recommended,
set_packaged_pricing: values.set_packaged_pricing,
selling_price: values.selling_price,
selling_chart_of_account: values.selling_chart_of_account || undefined,
is_active: values.is_active,
}
}
function mapUpdatePayload(values: ServiceGroupFormValues) {
return {
service_name: values.service_name,
selling_price: values.selling_price,
is_active: values.is_active,
}
}
// ── Component ──
export function ServiceGroupForm({ resourceId, initialData, onSuccess }: ServiceGroupFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<ServiceGroupFormValues, any>({
schema: serviceGroupFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ServiceGroupFormValues) => {
const promise = isEditing && resourceId
? api.serviceGroups.update(resourceId, mapUpdatePayload(values))
: api.serviceGroups.create(mapCreatePayload(values))
toast.promise(promise, {
loading: isEditing ? "Updating service group..." : "Creating service group...",
success: isEditing ? "Service group updated" : "Service group created",
error: isEditing ? "Failed to update service group" : "Failed to create service group",
})
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 service group" : "Failed to create service group"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="service_name"
label="Service Name"
placeholder="e.g. Engine Service Group"
required
/>
<RhfTextField
name="code"
label="Code"
placeholder="e.g. SG-001"
/>
</div>
{!isEditing && (
<>
<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="inventory_category"
label="Category"
placeholder="Select category"
queryKey={["inventory-categories"]}
listFn={() => api.inventory.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
createLabel="Category"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="unit_type"
label="Unit Type"
placeholder="Select unit type"
queryKey={["unit-types"]}
listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />}
createLabel="Unit Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
</div>
</>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
placeholder="0.00"
type="number"
/>
<RhfTextField
name="selling_chart_of_account"
label="Selling Chart of Account"
placeholder="e.g. 4000"
/>
</div>
<RhfTextareaField
name="service_description"
label="Description"
placeholder="Optional description"
rows={3}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCheckboxField
name="show_as_lump_sum"
label="Show as Lump Sum"
/>
<RhfCheckboxField
name="mark_as_recommended"
label="Recommended"
/>
<RhfCheckboxField
name="set_packaged_pricing"
label="Set Packaged Pricing"
/>
<RhfCheckboxField
name="is_active"
label="Active"
/>
</div>
</FieldGroup>
<div className="mt-4 flex justify-end">
<Button type="submit" disabled={isPending}>
{isEditing ? <Save className="me-2 h-4 w-4" /> : <Plus className="me-2 h-4 w-4" />}
{isPending
? isEditing ? "Updating..." : "Creating..."
: isEditing ? "Update Service Group" : "Create Service Group"}
</Button>
</div>
</Rhform>
)
}
@@ -0,0 +1,23 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const serviceGroupFormSchema = z.object({
shop_type: relationFieldSchema,
inventory_category: relationFieldSchema,
unit_type: relationFieldSchema,
department: relationFieldSchema,
service_name: z.string().min(1, "Service name is required"),
code: z.string().optional(),
service_description: z.string().optional(),
selling_price: z.coerce.number().min(0).optional(),
selling_chart_of_account: z.string().optional(),
show_as_lump_sum: z.boolean().optional(),
mark_as_recommended: z.boolean().optional(),
set_packaged_pricing: z.boolean().optional(),
is_active: z.boolean().optional(),
})
export type ServiceGroupFormValues = z.infer<typeof serviceGroupFormSchema>
@@ -0,0 +1,7 @@
export const DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS = [
{ value: "none", label: "None" },
{ value: "bays", label: "Bays" },
{ value: "outsourced", label: "Outsourced" },
] as const
export type DepartmentAssignmentType = typeof DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS[number]["value"]
@@ -0,0 +1,2 @@
// Renamed to inventory-category-inline-form.tsx
export { InventoryCategoryInlineForm as CategoryInlineForm } from "./inventory-category-inline-form"
@@ -0,0 +1,66 @@
"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, RhfSelectField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS } from "../department-assignment-types"
const schema = z.object({
name: z.string().min(1, "Name is required"),
assignment_type: z.string().optional(),
})
type FormValues = z.infer<typeof schema>
export function DepartmentInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: "", assignment_type: "none" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.departments.create({
name: values.name,
assignment_type: values.assignment_type || undefined,
})
toast.success("Department created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.name ?? String(item.id) })
} catch {
toast.error("Failed to create department")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="name"
label="Name"
placeholder="e.g. Mechanical"
required
/>
<RhfSelectField
name="assignment_type"
label="Assignment Type"
placeholder="Select assignment type"
options={[...DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS]}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Department"}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,78 @@
"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, RhfAsyncSelectField, type InlineCreateFormProps } from "@/shared/components/form"
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-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.object({ value: z.string(), label: z.string() }).nullable(),
})
type FormValues = z.infer<typeof schema>
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 }
export function InventoryCategoryInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "", shop_type: null },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.inventory.createCategory({
title: values.title,
shop_type_id: values.shop_type ? Number(values.shop_type.value) : undefined,
})
toast.success("Category 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 category")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Parts"
required
/>
<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}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Category"}
</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 UnitTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.inventory.createUnitType({ title: values.title })
toast.success("Unit 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 unit type")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Hour"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Unit Type"}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,238 @@
"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 "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { InventoryCategoryInlineForm } from "./inline-forms/inventory-category-inline-form"
import { UnitTypeInlineForm } from "./inline-forms/unit-type-inline-form"
import { DepartmentInlineForm } from "./inline-forms/department-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 { toId } from "@/shared/lib/utils"
import { serviceFormSchema, type ServiceFormValues } from "./service.schema"
import { SERVICE_ROUTES } from "@garage/api"
// ── Props ──
export type ServiceFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ServiceFormValues = {
shop_type: null,
category: null,
unit_type: null,
department: null,
labor_name: "",
service_code: "",
labor_matrix: "",
description: "",
selling_price: undefined,
}
// ── 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): ServiceFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
shop_type: null,
category: null,
unit_type: null,
department: null,
labor_name: d.name || d.labor_name || "",
service_code: d.service_code || "",
labor_matrix: d.labor_matrix || "",
description: d.description || "",
selling_price: d.selling_price ?? undefined,
}
}
function mapCreatePayload(values: ServiceFormValues) {
return {
shop_type_id: toId(values.shop_type),
category_id: toId(values.category),
unit_type_id: toId(values.unit_type),
department_id: toId(values.department),
labor_name: values.labor_name,
service_code: values.service_code || undefined,
labor_matrix: values.labor_matrix || undefined,
description: values.description || undefined,
selling_price: values.selling_price,
}
}
function mapUpdatePayload(values: ServiceFormValues) {
return {
labor_name: values.labor_name,
selling_price: values.selling_price,
}
}
// ── Component ──
export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<ServiceFormValues, any>({
schema: serviceFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ServiceFormValues) => {
const promise = isEditing && resourceId
? api.services.update(resourceId, mapUpdatePayload(values))
: api.services.create(mapCreatePayload(values))
toast.promise(promise, {
loading: isEditing ? "Updating service..." : "Creating service...",
success: isEditing ? "Service updated successfully" : "Service created successfully",
error: isEditing ? "Failed to update service" : "Failed to create service",
})
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 service" : "Failed to create service"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="labor_name"
label="Labor Name"
placeholder="e.g. Oil Change"
required
/>
<RhfTextField
name="service_code"
label="Service Code"
placeholder="e.g. SVC-001"
/>
</div>
{!isEditing && (
<>
<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="category"
label="Category"
placeholder="Select category"
queryKey={["inventory-categories"]}
listFn={() => api.inventory.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InventoryCategoryInlineForm {...props} />}
createLabel="Category"
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="unit_type"
label="Unit Type"
placeholder="Select unit type"
queryKey={["unit-types"]}
listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />}
createLabel="Unit Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={["departments"]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
</div>
<RhfTextField
name="labor_matrix"
label="Labor Matrix"
placeholder="e.g. Standard"
/>
</>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
type="number"
placeholder="e.g. 75"
/>
</div>
<RhfTextareaField
name="description"
label="Description"
placeholder="Describe the service..."
rows={3}
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Service" : "Create Service")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,19 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const serviceFormSchema = z.object({
shop_type: relationFieldSchema,
category: relationFieldSchema,
unit_type: relationFieldSchema,
department: relationFieldSchema,
labor_name: z.string().min(1, "Labor name is required"),
service_code: z.string().optional(),
labor_matrix: z.string().optional(),
description: z.string().optional(),
selling_price: z.coerce.number().min(0).optional(),
})
export type ServiceFormValues = z.infer<typeof serviceFormSchema>
@@ -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 "@garage/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,99 @@
"use client"
import { AlertTriangle, Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfCheckboxField,
} 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 {
shopCalendarFormSchema,
type ShopCalendarFormValues,
} from "./shop-calendar.schema"
import { SHOP_CALENDAR_ROUTES } from "@garage/api"
// ── Props ──
export type ShopCalendarFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ShopCalendarFormValues = {
title: "",
is_default: false,
}
// ── Component ──
export function ShopCalendarForm({ resourceId, onSuccess }: ShopCalendarFormProps) {
const api = useAuthApi()
const { form } = useResourceForm<ShopCalendarFormValues, any>({
schema: shopCalendarFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId: null,
queryKey: [SHOP_CALENDAR_ROUTES.INDEX],
mapToFormValues: (data: unknown) => {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title ?? "",
is_default: d.is_default ?? false,
}
},
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ShopCalendarFormValues) => {
const payload = {
title: values.title,
is_default: values.is_default,
}
const promise = api.shopCalendars.create(payload)
toast.promise(promise, {
loading: "Creating shop calendar...",
success: "Shop calendar created successfully",
error: "Failed to create shop calendar",
})
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>Failed to create shop calendar</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter calendar title" required />
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" variant="default" disabled={isPending}>
<Plus />
{isPending ? "Creating..." : "Create Shop Calendar"}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,11 @@
import { z } from "zod"
const shopCalendarFormSchema = z.object({
title: z.string().min(1, "Title is required"),
is_default: z.boolean(),
})
type ShopCalendarFormValues = z.infer<typeof shopCalendarFormSchema>
export { shopCalendarFormSchema }
export type { ShopCalendarFormValues }
@@ -0,0 +1,161 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfCheckboxField,
} 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 {
shopTimingFormSchema,
type ShopTimingFormValues,
} from "./shop-timing.schema"
import { SHOP_TIMING_ROUTES } from "@garage/api"
// ── Props ──
export type ShopTimingFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ShopTimingFormValues = {
title: "",
in_time: "",
out_time: "",
full_day_hours: "",
half_day_hours: "",
punch_in: "",
punch_out: "",
before_time: "",
after_time: "",
is_default: false,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): ShopTimingFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title ?? "",
in_time: d.in_time ?? "",
out_time: d.out_time ?? "",
full_day_hours: d.full_day_hours ?? "",
half_day_hours: d.half_day_hours ?? "",
punch_in: d.punch_in ?? "",
punch_out: d.punch_out ?? "",
before_time: d.before_time ?? "",
after_time: d.after_time ?? "",
is_default: d.is_default ?? false,
}
}
function mapFormToPayload(values: ShopTimingFormValues) {
return {
title: values.title,
in_time: values.in_time,
out_time: values.out_time,
full_day_hours: values.full_day_hours || undefined,
half_day_hours: values.half_day_hours || undefined,
punch_in: values.punch_in || undefined,
punch_out: values.punch_out || undefined,
before_time: values.before_time || undefined,
after_time: values.after_time || undefined,
is_default: values.is_default,
}
}
// ── Component ──
export function ShopTimingForm({ resourceId, initialData, onSuccess }: ShopTimingFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<ShopTimingFormValues, any>({
schema: shopTimingFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.shopTimings.show(id),
queryKey: [SHOP_TIMING_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ShopTimingFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.shopTimings.update(resourceId, payload)
: api.shopTimings.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating shop timing..." : "Creating shop timing...",
success: isEditing ? "Shop timing updated successfully" : "Shop timing created successfully",
error: isEditing ? "Failed to update shop timing" : "Failed to create shop timing",
})
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 timing" : "Failed to create shop timing"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="in_time" label="In Time" placeholder="HH:MM:SS" required />
<RhfTextField name="out_time" label="Out Time" placeholder="HH:MM:SS" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="full_day_hours" label="Full Day Hours" placeholder="HH:MM:SS" />
<RhfTextField name="half_day_hours" label="Half Day Hours" placeholder="HH:MM:SS" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="punch_in" label="Punch In" placeholder="HH:MM:SS" />
<RhfTextField name="punch_out" label="Punch Out" placeholder="HH:MM:SS" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="before_time" label="Before Time" placeholder="HH:MM:SS" />
<RhfTextField name="after_time" label="After Time" placeholder="HH:MM:SS" />
</div>
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Shop Timing" : "Create Shop Timing")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,19 @@
import { z } from "zod"
const shopTimingFormSchema = z.object({
title: z.string().min(1, "Title is required"),
in_time: z.string().min(1, "In time is required"),
out_time: z.string().min(1, "Out time is required"),
full_day_hours: z.string().optional(),
half_day_hours: z.string().optional(),
punch_in: z.string().optional(),
punch_out: z.string().optional(),
before_time: z.string().optional(),
after_time: z.string().optional(),
is_default: z.boolean().default(false),
})
type ShopTimingFormValues = z.infer<typeof shopTimingFormSchema>
export { shopTimingFormSchema }
export type { ShopTimingFormValues }
@@ -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 "@garage/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>