refactor: enhance employee and service forms with new fields and validation

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Mohammad Khyata 2026-05-08 15:55:33 +03:00
parent b0361fcbfb
commit 4b51ffe457
7 changed files with 448 additions and 143 deletions

View File

@ -39,10 +39,9 @@ export function BillPaymentsSection() {
{(resourceId) => (
<PaymentMadeForm
initialData={{
anount: bill?.balance_due,
amount: bill?.balance_due,
vendor: toRelation(bill?.vendor?.id, getFullName(bill?.vendor as any)),
payment_made: bill?.balance_due != null ? String(bill.balance_due) : undefined,
payment_for: "bill",
}}
billId={bill?.id}
resourceId={resourceId}

View File

@ -21,21 +21,29 @@ import { toRelation, toId } from "@/shared/lib/utils"
import {
employeeFormSchema,
type EmployeeFormValues,
STATUS_OPTIONS,
TYPE_OPTIONS,
WAGE_TYPE_OPTIONS,
} from "./employee.schema"
import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES, EmployeeType } from "@garage/api"
import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES, GEO_ROUTES } from "@garage/api"
// ── Constants ──
const STATUS_OPTIONS = [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
]
const TYPE_OPTIONS = EmployeeType.map((v) => ({
const STATUS_SELECT_OPTIONS = STATUS_OPTIONS.map((v) => ({
value: v,
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
}))
const TYPE_SELECT_OPTIONS = TYPE_OPTIONS.map((v) => ({
value: v,
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
}))
const WAGE_TYPE_SELECT_OPTIONS = WAGE_TYPE_OPTIONS.map((v) => ({
value: v,
label: v,
}))
// ── Props ──
export type EmployeeFormProps = {
@ -48,18 +56,23 @@ export type EmployeeFormProps = {
const DEFAULT_VALUES: EmployeeFormValues = {
department: null,
country: null,
shop_calender: null,
shop_timing: null,
role_id: null,
first_name: "",
last_name: "",
email: "",
password: "",
phone: "",
position: "",
designation: "",
salary: null,
wage_type: null,
status: "active",
type: "employee",
track_attendance: true,
notify_owner_when_punch_in_out: false,
geo_fence_radius: 100,
geo_fence_radius: null,
}
// ── Mapping helpers ──
@ -69,36 +82,46 @@ function mapToFormValues(data: unknown): EmployeeFormValues {
return {
department: toRelation(d.department_id, d.department?.name),
country: toRelation(d.country_id, d.country?.name),
shop_calender: toRelation(d.shop_calender_id, d.shop_calender?.title),
shop_timing: toRelation(d.shop_timing_id, d.shop_timing?.title),
role_id: d.role_id ? Number(d.role_id) : null,
first_name: d.first_name || "",
last_name: d.last_name || "",
email: d.email || "",
password: "",
phone: d.phone || "",
position: d.position || "",
designation: d.designation || "",
salary: d.salary ?? null,
wage_type: d.wage_type ?? null,
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,
geo_fence_radius: d.geo_fence_radius ?? null,
}
}
function mapFormToPayload(values: EmployeeFormValues) {
return {
department_id: toId(values.department),
country_id: toId(values.country),
shop_calender_id: toId(values.shop_calender),
shop_timing_id: toId(values.shop_timing),
role_id: values.role_id ?? undefined,
first_name: values.first_name,
last_name: values.last_name,
email: values.email || undefined,
email: values.email,
password: values.password,
phone: values.phone || undefined,
position: values.position || undefined,
status: values.status || undefined,
type: values.type || undefined,
designation: values.designation || undefined,
salary: values.salary ?? undefined,
wage_type: values.wage_type ?? undefined,
status: values.status,
type: values.type,
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,
geo_fence_radius: values.geo_fence_radius ?? undefined,
}
}
@ -165,11 +188,15 @@ export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFor
<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" />
<RhfTextField name="password" label="Password" placeholder="At least 6 characters" type="password" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
<RhfTextField name="designation" label="Designation" placeholder="Technician" />
</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"
@ -179,6 +206,7 @@ export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFor
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfTextField name="role_id" label="Role ID" placeholder="1" type="number" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@ -186,17 +214,36 @@ export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFor
name="status"
label="Status"
placeholder="Select status"
options={STATUS_OPTIONS}
options={STATUS_SELECT_OPTIONS}
/>
<RhfSelectField
name="type"
label="Type"
placeholder="Select type"
options={TYPE_OPTIONS}
options={TYPE_SELECT_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="salary" label="Salary" placeholder="0" type="number" />
<RhfSelectField
name="wage_type"
label="Wage Type"
placeholder="Select wage type"
options={WAGE_TYPE_SELECT_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="country"
label="Country"
placeholder="Select country"
queryKey={[GEO_ROUTES.COUNTRIES]}
listFn={() => api.geo.countries()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="shop_calender"
label="Shop Calendar"

View File

@ -1,33 +1,56 @@
import { z } from "zod"
import { EmployeeType } from "@garage/api"
import { EmployeeType, WageType } from "@garage/api"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const optionalNumericField = z.preprocess(
(value) => {
if (value === "" || value === null || value === undefined) {
return null
}
return Number(value)
},
z.number().finite().nullable(),
)
const optionalIntegerIdField = z.preprocess(
(value) => {
if (value === "" || value === null || value === undefined) {
return null
}
return Number(value)
},
z.number().int().positive().nullable(),
)
const STATUS_OPTIONS = ["active", "inactive"] as const
const TYPE_OPTIONS = EmployeeType
const WAGE_TYPE_OPTIONS = WageType
const employeeFormSchema = z.object({
department: relationFieldSchema,
country: 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(),
role_id: optionalIntegerIdField,
first_name: z.string().trim().min(1, "First name is required").max(50, "First name must be at most 50 characters"),
last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name must be at most 50 characters"),
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address").max(100, "Email must be at most 100 characters"),
password: z.string().min(6, "Password must be at least 6 characters"),
phone: z.string().trim().max(30, "Phone must be at most 30 characters").optional().or(z.literal("")),
designation: z.string().trim().max(50, "Designation must be at most 50 characters").optional().or(z.literal("")),
salary: optionalNumericField,
wage_type: z.enum(WAGE_TYPE_OPTIONS).nullable(),
status: z.enum(STATUS_OPTIONS),
type: z.enum(TYPE_OPTIONS),
track_attendance: z.boolean(),
notify_owner_when_punch_in_out: z.boolean(),
geo_fence_radius: z.coerce.number().min(0).optional(),
geo_fence_radius: optionalNumericField,
})
type EmployeeFormValues = z.infer<typeof employeeFormSchema>
export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS }
export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS, WAGE_TYPE_OPTIONS }
export type { EmployeeFormValues }

View File

@ -26,7 +26,8 @@ import {
type PaymentMadeFormValues,
} from "./payment-made.schema"
import {
PAYMENT_MADE_ROUTES,
ApiError,
BILL_ROUTES,
PAYMENT_MODE_ROUTES,
EMPLOYEE_ROUTES,
PaymentFor,
@ -53,6 +54,7 @@ export type PaymentMadeFormProps = {
// ── Default values ──
const DEFAULT_VALUES: PaymentMadeFormValues & { details: Array<{ bill_id?: string | number | null; amount_paid?: number, expense_id?: string | number | null }> } = {
bill: null,
vendor: null,
employee: null,
payment_mode: null,
@ -76,6 +78,7 @@ function mapToFormValues(data: unknown): typeof DEFAULT_VALUES {
const paymentModeLabel = d.payment_mode?.title ?? d.payment_mode?.name ?? d.payment_mode_name
return {
bill: toRelation(d.bill_id, d.bill?.bill_number ?? d.bill_number),
vendor: toRelation(d.vendor_id, d.vendor?.company_name ?? d.vendor?.name ?? d.vendor_name),
employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : d.employee_name),
payment_mode: toRelation(paymentModeId, paymentModeLabel),
@ -92,10 +95,13 @@ function mapToFormValues(data: unknown): typeof DEFAULT_VALUES {
function mapFormToPayload(values: PaymentMadeFormValues, billId?: string | number | null, expenseId?: string | number | null) {
const resolvedBillId = billId ?? toId(values.bill)
const paymentDetails =
expenseId ? [{ expense_id: expenseId, amount_paid: values.amount || 0 }]
: [{ bill_id: billId, amount_paid: values.amount || 0 }]
values.payment_for === "expense"
? [{ expense_id: expenseId, amount_paid: values.amount || 0 }]
: [{ bill_id: resolvedBillId, amount_paid: values.amount || 0 }]
return {
bill_id: values.payment_for === "bill" ? Number(resolvedBillId) : undefined,
vendor_id: toId(values.vendor),
employee_id: toId(values.employee) || undefined,
payment_mode_id: toId(values.payment_mode),
@ -108,7 +114,6 @@ function mapFormToPayload(values: PaymentMadeFormValues, billId?: string | numbe
notes: values.notes || undefined,
payment_made: values.amount || 0,
details: paymentDetails,
...(billId ? { bill_id: Number(billId) } : {}),
...(expenseId ? { expense_id: Number(expenseId) } : {}),
}
}
@ -120,6 +125,11 @@ const mapLookupOption = (item: any) => ({
label: item.name ?? item.title ?? `#${item.id}`,
})
const mapBillOption = (item: any) => ({
value: String(item.id),
label: item.bill_number ?? item.title ?? `#${item.id}`,
})
const mapVendorOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.company_name ?? `#${item.id}`,
@ -139,6 +149,13 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, expenseId }: PaymentMadeFormProps) {
const api = useAuthApi()
const paymentForOptions = useMemo(() => {
if (expenseId) {
return PAYMENT_FOR_OPTIONS.filter((option) => option.value === "expense")
}
return PAYMENT_FOR_OPTIONS.filter((option) => option.value === "bill")
}, [expenseId])
const resolvedInitialData = useMemo(() => {
const base: any = { ...(initialData as any) }
if (!resourceId) {
@ -146,6 +163,8 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
base.payment_for = "bill"
} else if (expenseId) {
base.payment_for = "expense"
} else {
base.payment_for = "bill"
}
}
return Object.keys(base).length ? base : initialData
@ -159,6 +178,13 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
mapToFormValues,
})
const vendor = form.watch("vendor")
const employee = form.watch("employee")
const paymentFor = form.watch("payment_for")
const vendorRequired = !employee?.value
const employeeRequired = !vendor?.value
const showBillField = !billId && !expenseId
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PaymentMadeFormValues) => {
const payload = mapFormToPayload(values, billId, expenseId)
@ -172,6 +198,27 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
})
return promise
},
onError: (error) => {
if (!(error instanceof ApiError) || !error.validationErrors) {
return
}
const vendorError = error.validationErrors.vendor_id?.[0]
const employeeError = error.validationErrors.employee_id?.[0]
const billError = error.validationErrors.bill_id?.[0]
if (billError) {
form.setError("bill", { message: billError })
}
if (!vendorError && !employeeError) {
return
}
const message = "Select either a vendor or an employee, not both"
form.setError("vendor", { message })
form.setError("employee", { message })
},
onSuccess: () => {
form.reset()
onSuccess?.()
@ -192,14 +239,30 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{showBillField && (
<RhfAsyncSelectField
name="bill"
label="Bill"
placeholder="Select bill"
required={paymentFor === "bill"}
queryKey={[BILL_ROUTES.INDEX]}
listFn={() => api.bills.list()}
mapOption={mapBillOption}
{...STORE_OBJECT}
/>
)}
<RhfVendorSelectField
name="vendor"
label="Vendor"
required={vendorRequired}
description="Select either a vendor or an employee."
/>
<RhfAsyncSelectField
name="employee"
label="Employee"
placeholder="Select employee"
required={employeeRequired}
description="Select either a vendor or an employee."
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
@ -212,7 +275,7 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
name="payment_for"
label="Payment For"
placeholder="Select type"
options={PAYMENT_FOR_OPTIONS}
options={paymentForOptions}
required
/>
<RhfTextField

View File

@ -27,6 +27,7 @@ const detailsItemSchema = z.object({
const paymentMadeFormSchema = z.object({
// ── Relations ──
bill: relationFieldSchema,
vendor: relationFieldSchema,
employee: relationFieldSchema,
payment_mode: requiredRelationFieldSchema,
@ -41,9 +42,18 @@ const paymentMadeFormSchema = z.object({
notes: z.string().optional(),
details: z.array(detailsItemSchema).min(1, "At least one payment detail is required"),
}).superRefine((values, ctx) => {
const hasBill = Boolean(values.bill?.value)
const hasVendor = Boolean(values.vendor?.value)
const hasEmployee = Boolean(values.employee?.value)
if (values.payment_for === "bill" && !hasBill) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["bill"],
message: "Bill is required",
})
}
if (!hasVendor && !hasEmployee) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@ -56,6 +66,19 @@ const paymentMadeFormSchema = z.object({
message: "Vendor or employee is required",
})
}
if (hasVendor && hasEmployee) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["vendor"],
message: "Select either a vendor or an employee, not both",
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["employee"],
message: "Select either a vendor or an employee, not both",
})
}
})
type PaymentMadeFormValues = z.infer<typeof paymentMadeFormSchema>

View File

@ -10,6 +10,8 @@ import {
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
RhfSelectField,
RhfCheckboxField,
} from "@/shared/components/form"
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { InventoryCategoryInlineForm } from "./inline-forms/inventory-category-inline-form"
@ -19,10 +21,16 @@ 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 { toId, toRelation } from "@/shared/lib/utils"
import { serviceFormSchema, type ServiceFormValues } from "./service.schema"
import { SERVICE_ROUTES } from "@garage/api"
import {
DEPARTMENT_ROUTES,
INVENTORY_ROUTES,
SERVICE_ROUTES,
SHOP_TYPE_ROUTES,
VENDOR_ROUTES,
} from "@garage/api"
import { RATE_TYPE_OPTIONS, serviceFormSchema, type ServiceFormValues } from "./service.schema"
// ── Props ──
@ -43,7 +51,16 @@ const DEFAULT_VALUES: ServiceFormValues = {
service_code: "",
labor_matrix: "",
description: "",
sales_information: false,
rate_type: undefined,
labor_rate: null,
labor_hours: undefined,
sales_chart_of_account: undefined,
selling_price: undefined,
purchase_information: false,
purchase_chart_of_account: undefined,
purchase_preferred_vendor: null,
purchase_price: undefined,
}
// ── Mapping helpers ──
@ -53,42 +70,66 @@ const mapLookupOption = (item: any) => ({
label: item.name ?? item.title ?? String(item.id),
})
const mapLaborRateOption = (item: any) => ({
value: String(item.id),
label: item.title ?? item.name ?? String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
const RATE_TYPE_SELECT_OPTIONS = RATE_TYPE_OPTIONS.map((value) => ({
value,
label: value === "flat_rate" ? "Flat Rate" : "Hourly",
}))
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,
shop_type: toRelation(d.shop_type_id, d.shop_type?.name ?? d.shop_type_name ?? d.shop_type_title),
category: toRelation(d.category_id, d.category?.name ?? d.category?.title ?? d.category_name ?? d.category_title),
unit_type: toRelation(d.unit_type_id, d.unit_type?.name ?? d.unit_type?.title ?? d.unit_type_name ?? d.unit_type_title),
department: toRelation(d.department_id, d.department?.name ?? d.department_name ?? d.department_title),
labor_name: d.name ?? d.labor_name ?? "",
service_code: d.service_code ?? "",
labor_matrix: d.labor_matrix ?? "",
description: d.description ?? "",
sales_information: d.sales_information ?? false,
rate_type: RATE_TYPE_OPTIONS.includes(d.rate_type) ? d.rate_type : undefined,
labor_rate: toRelation(d.labor_rate_id, d.labor_rate?.title ?? d.labor_rate_title ?? d.labor_rate_name),
labor_hours: d.labor_hours != null ? Number(d.labor_hours) : undefined,
sales_chart_of_account: d.sales_chart_of_account != null ? Number(d.sales_chart_of_account) : undefined,
selling_price: d.selling_price != null ? Number(d.selling_price) : undefined,
purchase_information: d.purchase_information ?? false,
purchase_chart_of_account: d.purchase_chart_of_account != null ? Number(d.purchase_chart_of_account) : undefined,
purchase_preferred_vendor: toRelation(
d.purchase_preferred_vendor_id,
d.purchase_preferred_vendor?.name ?? d.purchase_preferred_vendor_name,
),
purchase_price: d.purchase_price != null ? Number(d.purchase_price) : undefined,
}
}
function mapCreatePayload(values: ServiceFormValues) {
function mapFormToPayload(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,
service_code: values.service_code,
labor_matrix: values.labor_matrix,
description: values.description || undefined,
selling_price: values.selling_price,
}
}
function mapUpdatePayload(values: ServiceFormValues) {
return {
labor_name: values.labor_name,
selling_price: values.selling_price,
sales_information: values.sales_information,
rate_type: values.rate_type ?? undefined,
labor_rate_id: toId(values.labor_rate),
labor_hours: values.labor_hours ?? undefined,
sales_chart_of_account: values.sales_chart_of_account ?? undefined,
selling_price: values.selling_price ?? undefined,
purchase_information: values.purchase_information,
purchase_chart_of_account: values.purchase_chart_of_account ?? undefined,
purchase_preferred_vendor_id: toId(values.purchase_preferred_vendor),
purchase_price: values.purchase_price ?? undefined,
}
}
@ -96,20 +137,24 @@ function mapUpdatePayload(values: ServiceFormValues) {
export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormProps) {
const api = useAuthApi()
const isEditing = Boolean(resourceId)
const { form, isEditing } = useResourceForm<ServiceFormValues, any>({
const { form } = useResourceForm<ServiceFormValues, any>({
schema: serviceFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.services.show(id),
queryKey: [SERVICE_ROUTES.BY_ID, resourceId],
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ServiceFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.services.update(resourceId, mapUpdatePayload(values))
: api.services.create(mapCreatePayload(values))
? api.services.update(resourceId, payload)
: api.services.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating service..." : "Creating service...",
success: isEditing ? "Service updated successfully" : "Service created successfully",
@ -136,6 +181,60 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_type"
label="Shop Type"
placeholder="Select shop type"
required
queryKey={[SHOP_TYPE_ROUTES.INDEX]}
listFn={() => api.shopTypes.list()}
mapOption={mapLookupOption}
createForm={(props) => <ShopTypeInlineForm {...props} />}
createLabel="Shop Type"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="category"
label="Category"
placeholder="Select category"
required
queryKey={[INVENTORY_ROUTES.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"
required
queryKey={[INVENTORY_ROUTES.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"
required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
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="labor_name"
@ -147,77 +246,16 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
name="service_code"
label="Service Code"
placeholder="e.g. SVC-001"
required
/>
</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>
<RhfTextField
name="labor_matrix"
label="Labor Matrix"
placeholder="e.g. Standard"
required
/>
<RhfTextareaField
name="description"
@ -226,6 +264,79 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
rows={3}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="rate_type"
label="Rate Type"
placeholder="Select rate type"
options={RATE_TYPE_SELECT_OPTIONS}
/>
<RhfAsyncSelectField
name="labor_rate"
label="Labor Rate"
placeholder="Select labor rate"
queryKey={[INVENTORY_ROUTES.LABOR_RATES]}
listFn={() => api.inventory.listLaborRates()}
mapOption={mapLaborRateOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfTextField
name="labor_hours"
label="Labor Hours"
type="number"
placeholder="0"
/>
<RhfTextField
name="sales_chart_of_account"
label="Sales Chart of Account"
type="number"
placeholder="0"
/>
<RhfTextField
name="selling_price"
label="Selling Price"
type="number"
placeholder="0.00"
/>
</div>
<RhfCheckboxField
name="sales_information"
label="Sales Information"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfTextField
name="purchase_chart_of_account"
label="Purchase Chart of Account"
type="number"
placeholder="0"
/>
<RhfAsyncSelectField
name="purchase_preferred_vendor"
label="Purchase Preferred Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
{...STORE_OBJECT}
/>
<RhfTextField
name="purchase_price"
label="Purchase Price"
type="number"
placeholder="0.00"
/>
</div>
<RhfCheckboxField
name="purchase_information"
label="Purchase Information"
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending

View File

@ -1,19 +1,58 @@
import { z } from "zod"
import { RateType } from "@garage/api"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const requiredRelationFieldSchema = relationFieldSchema.refine((value) => value !== null, {
message: "This field is required",
})
const optionalNumericField = z.preprocess(
(value) => {
if (value === "" || value === null || value === undefined) {
return undefined
}
return Number(value)
},
z.number().min(0).optional(),
)
const optionalIntegerField = z.preprocess(
(value) => {
if (value === "" || value === null || value === undefined) {
return undefined
}
return Number(value)
},
z.number().int().optional(),
)
export const RATE_TYPE_OPTIONS = RateType
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(),
shop_type: requiredRelationFieldSchema,
category: requiredRelationFieldSchema,
unit_type: requiredRelationFieldSchema,
department: requiredRelationFieldSchema,
labor_name: z.string().trim().min(1, "Labor name is required").max(255, "Labor name must be at most 255 characters"),
service_code: z.string().trim().min(1, "Service code is required").max(255, "Service code must be at most 255 characters"),
labor_matrix: z.string().trim().min(1, "Labor matrix is required").max(255, "Labor matrix must be at most 255 characters"),
description: z.string().optional(),
selling_price: z.coerce.number().min(0).optional(),
sales_information: z.boolean().default(false),
rate_type: z.preprocess(
(value) => (value === "" || value === null ? undefined : value),
z.enum(RATE_TYPE_OPTIONS).optional(),
),
labor_rate: relationFieldSchema,
labor_hours: optionalNumericField,
sales_chart_of_account: optionalIntegerField,
selling_price: optionalNumericField,
purchase_information: z.boolean().default(false),
purchase_chart_of_account: optionalIntegerField,
purchase_preferred_vendor: relationFieldSchema,
purchase_price: optionalNumericField,
})
export type ServiceFormValues = z.infer<typeof serviceFormSchema>