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) => ( {(resourceId) => (
<PaymentMadeForm <PaymentMadeForm
initialData={{ initialData={{
anount: bill?.balance_due, amount: bill?.balance_due,
vendor: toRelation(bill?.vendor?.id, getFullName(bill?.vendor as any)), 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} billId={bill?.id}
resourceId={resourceId} resourceId={resourceId}

View File

@ -21,21 +21,29 @@ import { toRelation, toId } from "@/shared/lib/utils"
import { import {
employeeFormSchema, employeeFormSchema,
type EmployeeFormValues, type EmployeeFormValues,
STATUS_OPTIONS,
TYPE_OPTIONS,
WAGE_TYPE_OPTIONS,
} from "./employee.schema" } 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 ── // ── Constants ──
const STATUS_OPTIONS = [ const STATUS_SELECT_OPTIONS = STATUS_OPTIONS.map((v) => ({
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
]
const TYPE_OPTIONS = EmployeeType.map((v) => ({
value: v, value: v,
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), 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 ── // ── Props ──
export type EmployeeFormProps = { export type EmployeeFormProps = {
@ -48,18 +56,23 @@ export type EmployeeFormProps = {
const DEFAULT_VALUES: EmployeeFormValues = { const DEFAULT_VALUES: EmployeeFormValues = {
department: null, department: null,
country: null,
shop_calender: null, shop_calender: null,
shop_timing: null, shop_timing: null,
role_id: null,
first_name: "", first_name: "",
last_name: "", last_name: "",
email: "", email: "",
password: "",
phone: "", phone: "",
position: "", designation: "",
salary: null,
wage_type: null,
status: "active", status: "active",
type: "employee", type: "employee",
track_attendance: true, track_attendance: true,
notify_owner_when_punch_in_out: false, notify_owner_when_punch_in_out: false,
geo_fence_radius: 100, geo_fence_radius: null,
} }
// ── Mapping helpers ── // ── Mapping helpers ──
@ -69,36 +82,46 @@ function mapToFormValues(data: unknown): EmployeeFormValues {
return { return {
department: toRelation(d.department_id, d.department?.name), 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_calender: toRelation(d.shop_calender_id, d.shop_calender?.title),
shop_timing: toRelation(d.shop_timing_id, d.shop_timing?.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 || "", first_name: d.first_name || "",
last_name: d.last_name || "", last_name: d.last_name || "",
email: d.email || "", email: d.email || "",
password: "",
phone: d.phone || "", phone: d.phone || "",
position: d.position || "", designation: d.designation || "",
salary: d.salary ?? null,
wage_type: d.wage_type ?? null,
status: d.status || "active", status: d.status || "active",
type: d.type || "employee", type: d.type || "employee",
track_attendance: d.track_attendance ?? true, track_attendance: d.track_attendance ?? true,
notify_owner_when_punch_in_out: d.notify_owner_when_punch_in_out ?? false, 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) { function mapFormToPayload(values: EmployeeFormValues) {
return { return {
department_id: toId(values.department), department_id: toId(values.department),
country_id: toId(values.country),
shop_calender_id: toId(values.shop_calender), shop_calender_id: toId(values.shop_calender),
shop_timing_id: toId(values.shop_timing), shop_timing_id: toId(values.shop_timing),
role_id: values.role_id ?? undefined,
first_name: values.first_name, first_name: values.first_name,
last_name: values.last_name, last_name: values.last_name,
email: values.email || undefined, email: values.email,
password: values.password,
phone: values.phone || undefined, phone: values.phone || undefined,
position: values.position || undefined, designation: values.designation || undefined,
status: values.status || undefined, salary: values.salary ?? undefined,
type: values.type || undefined, wage_type: values.wage_type ?? undefined,
status: values.status,
type: values.type,
track_attendance: values.track_attendance, track_attendance: values.track_attendance,
notify_owner_when_punch_in_out: values.notify_owner_when_punch_in_out, 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"> <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="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>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="position" label="Position" placeholder="Technician" />
<RhfAsyncSelectField <RhfAsyncSelectField
name="department" name="department"
label="Department" label="Department"
@ -179,6 +206,7 @@ export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFor
mapOption={mapLookupOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<RhfTextField name="role_id" label="Role ID" placeholder="1" type="number" />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <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" name="status"
label="Status" label="Status"
placeholder="Select status" placeholder="Select status"
options={STATUS_OPTIONS} options={STATUS_SELECT_OPTIONS}
/> />
<RhfSelectField <RhfSelectField
name="type" name="type"
label="Type" label="Type"
placeholder="Select type" placeholder="Select type"
options={TYPE_OPTIONS} options={TYPE_SELECT_OPTIONS}
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <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 <RhfAsyncSelectField
name="shop_calender" name="shop_calender"
label="Shop Calendar" label="Shop Calendar"

View File

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

View File

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

View File

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

View File

@ -10,6 +10,8 @@ import {
RhfTextField, RhfTextField,
RhfTextareaField, RhfTextareaField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfSelectField,
RhfCheckboxField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form" import { ShopTypeInlineForm } from "@/modules/vehicles/inline-forms/shop-type-inline-form"
import { InventoryCategoryInlineForm } from "./inline-forms/inventory-category-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 { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form" import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation" 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 {
import { SERVICE_ROUTES } from "@garage/api" 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 ── // ── Props ──
@ -43,7 +51,16 @@ const DEFAULT_VALUES: ServiceFormValues = {
service_code: "", service_code: "",
labor_matrix: "", labor_matrix: "",
description: "", description: "",
sales_information: false,
rate_type: undefined,
labor_rate: null,
labor_hours: undefined,
sales_chart_of_account: undefined,
selling_price: undefined, selling_price: undefined,
purchase_information: false,
purchase_chart_of_account: undefined,
purchase_preferred_vendor: null,
purchase_price: undefined,
} }
// ── Mapping helpers ── // ── Mapping helpers ──
@ -53,42 +70,66 @@ const mapLookupOption = (item: any) => ({
label: item.name ?? item.title ?? String(item.id), 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 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 { function mapToFormValues(data: unknown): ServiceFormValues {
const d = (data as any)?.data ?? data ?? {} const d = (data as any)?.data ?? data ?? {}
return { return {
shop_type: null, shop_type: toRelation(d.shop_type_id, d.shop_type?.name ?? d.shop_type_name ?? d.shop_type_title),
category: null, category: toRelation(d.category_id, d.category?.name ?? d.category?.title ?? d.category_name ?? d.category_title),
unit_type: null, unit_type: toRelation(d.unit_type_id, d.unit_type?.name ?? d.unit_type?.title ?? d.unit_type_name ?? d.unit_type_title),
department: null, department: toRelation(d.department_id, d.department?.name ?? d.department_name ?? d.department_title),
labor_name: d.name || d.labor_name || "", labor_name: d.name ?? d.labor_name ?? "",
service_code: d.service_code || "", service_code: d.service_code ?? "",
labor_matrix: d.labor_matrix || "", labor_matrix: d.labor_matrix ?? "",
description: d.description || "", description: d.description ?? "",
selling_price: d.selling_price ?? undefined, 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 { return {
shop_type_id: toId(values.shop_type), shop_type_id: toId(values.shop_type),
category_id: toId(values.category), category_id: toId(values.category),
unit_type_id: toId(values.unit_type), unit_type_id: toId(values.unit_type),
department_id: toId(values.department), department_id: toId(values.department),
labor_name: values.labor_name, labor_name: values.labor_name,
service_code: values.service_code || undefined, service_code: values.service_code,
labor_matrix: values.labor_matrix || undefined, labor_matrix: values.labor_matrix,
description: values.description || undefined, description: values.description || undefined,
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,
function mapUpdatePayload(values: ServiceFormValues) { sales_chart_of_account: values.sales_chart_of_account ?? undefined,
return { selling_price: values.selling_price ?? undefined,
labor_name: values.labor_name, purchase_information: values.purchase_information,
selling_price: values.selling_price, 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) { export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormProps) {
const api = useAuthApi() const api = useAuthApi()
const isEditing = Boolean(resourceId)
const { form, isEditing } = useResourceForm<ServiceFormValues, any>({ const { form } = useResourceForm<ServiceFormValues, any>({
schema: serviceFormSchema, schema: serviceFormSchema,
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
initialData, initialData,
initialize: (id) => api.services.show(id),
queryKey: [SERVICE_ROUTES.BY_ID, resourceId],
mapToFormValues, mapToFormValues,
}) })
const { mutate, error, isPending } = useFormMutation(form, { const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ServiceFormValues) => { mutationFn: (values: ServiceFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId const promise = isEditing && resourceId
? api.services.update(resourceId, mapUpdatePayload(values)) ? api.services.update(resourceId, payload)
: api.services.create(mapCreatePayload(values)) : api.services.create(payload)
toast.promise(promise, { toast.promise(promise, {
loading: isEditing ? "Updating service..." : "Creating service...", loading: isEditing ? "Updating service..." : "Creating service...",
success: isEditing ? "Service updated successfully" : "Service created successfully", success: isEditing ? "Service updated successfully" : "Service created successfully",
@ -136,28 +181,13 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
)} )}
<FieldGroup> <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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField
name="shop_type" name="shop_type"
label="Shop Type" label="Shop Type"
placeholder="Select shop type" placeholder="Select shop type"
queryKey={["shop-types"]} required
queryKey={[SHOP_TYPE_ROUTES.INDEX]}
listFn={() => api.shopTypes.list()} listFn={() => api.shopTypes.list()}
mapOption={mapLookupOption} mapOption={mapLookupOption}
createForm={(props) => <ShopTypeInlineForm {...props} />} createForm={(props) => <ShopTypeInlineForm {...props} />}
@ -168,7 +198,8 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
name="category" name="category"
label="Category" label="Category"
placeholder="Select category" placeholder="Select category"
queryKey={["inventory-categories"]} required
queryKey={[INVENTORY_ROUTES.CATEGORIES]}
listFn={() => api.inventory.listCategories()} listFn={() => api.inventory.listCategories()}
mapOption={mapLookupOption} mapOption={mapLookupOption}
createForm={(props) => <InventoryCategoryInlineForm {...props} />} createForm={(props) => <InventoryCategoryInlineForm {...props} />}
@ -182,7 +213,8 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
name="unit_type" name="unit_type"
label="Unit Type" label="Unit Type"
placeholder="Select unit type" placeholder="Select unit type"
queryKey={["unit-types"]} required
queryKey={[INVENTORY_ROUTES.UNIT_TYPES]}
listFn={() => api.inventory.listUnitTypes()} listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption} mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />} createForm={(props) => <UnitTypeInlineForm {...props} />}
@ -193,7 +225,8 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
name="department" name="department"
label="Department" label="Department"
placeholder="Select department" placeholder="Select department"
queryKey={["departments"]} required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()} listFn={() => api.departments.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />} createForm={(props) => <DepartmentInlineForm {...props} />}
@ -202,22 +235,27 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
/> />
</div> </div>
<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"
required
/>
</div>
<RhfTextField <RhfTextField
name="labor_matrix" name="labor_matrix"
label="Labor Matrix" label="Labor Matrix"
placeholder="e.g. Standard" placeholder="e.g. Standard"
required
/> />
</>
)}
<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 <RhfTextareaField
name="description" name="description"
@ -226,6 +264,79 @@ export function ServiceForm({ resourceId, initialData, onSuccess }: ServiceFormP
rows={3} 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}> <Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />} {isEditing ? <Save /> : <Plus />}
{isPending {isPending

View File

@ -1,19 +1,58 @@
import { z } from "zod" import { z } from "zod"
import { RateType } from "@garage/api"
export const relationFieldSchema = z export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .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({ export const serviceFormSchema = z.object({
shop_type: relationFieldSchema, shop_type: requiredRelationFieldSchema,
category: relationFieldSchema, category: requiredRelationFieldSchema,
unit_type: relationFieldSchema, unit_type: requiredRelationFieldSchema,
department: relationFieldSchema, department: requiredRelationFieldSchema,
labor_name: z.string().min(1, "Labor name is required"), 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().optional(), 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().optional(), 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(), 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> export type ServiceFormValues = z.infer<typeof serviceFormSchema>