diff --git a/apps/dashboard/modules/bills/bill-payments-section.tsx b/apps/dashboard/modules/bills/bill-payments-section.tsx index 4231857..988fa94 100644 --- a/apps/dashboard/modules/bills/bill-payments-section.tsx +++ b/apps/dashboard/modules/bills/bill-payments-section.tsx @@ -39,10 +39,9 @@ export function BillPaymentsSection() { {(resourceId) => ( ({ +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
- + +
+ +
+ +
- +
@@ -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} />
+ + +
+ +
+ api.geo.countries()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> { + 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 -export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS } +export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS, WAGE_TYPE_OPTIONS } export type { EmployeeFormValues } diff --git a/apps/dashboard/modules/payment-mades/payment-made-form.tsx b/apps/dashboard/modules/payment-mades/payment-made-form.tsx index 2ff785e..d9683bf 100644 --- a/apps/dashboard/modules/payment-mades/payment-made-form.tsx +++ b/apps/dashboard/modules/payment-mades/payment-made-form.tsx @@ -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
+ {showBillField && ( + api.bills.list()} + mapOption={mapBillOption} + {...STORE_OBJECT} + /> + )} 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 /> { + 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 diff --git a/apps/dashboard/modules/services/service-form.tsx b/apps/dashboard/modules/services/service-form.tsx index 044e761..0e6867b 100644 --- a/apps/dashboard/modules/services/service-form.tsx +++ b/apps/dashboard/modules/services/service-form.tsx @@ -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({ + const { form } = useResourceForm({ 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 )} +
+ api.shopTypes.list()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Shop Type" + {...STORE_OBJECT} + /> + api.inventory.listCategories()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Category" + {...STORE_OBJECT} + /> +
+ +
+ api.inventory.listUnitTypes()} + mapOption={mapLookupOption} + createForm={(props) => } + createLabel="Unit Type" + {...STORE_OBJECT} + /> + api.departments.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + createForm={(props) => } + createLabel="Department" + {...STORE_OBJECT} + /> +
+
- {!isEditing && ( - <> -
- api.shopTypes.list()} - mapOption={mapLookupOption} - createForm={(props) => } - createLabel="Shop Type" - {...STORE_OBJECT} - /> - api.inventory.listCategories()} - mapOption={mapLookupOption} - createForm={(props) => } - createLabel="Category" - {...STORE_OBJECT} - /> -
- -
- api.inventory.listUnitTypes()} - mapOption={mapLookupOption} - createForm={(props) => } - createLabel="Unit Type" - {...STORE_OBJECT} - /> - api.departments.list()} - mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} - createForm={(props) => } - createLabel="Department" - {...STORE_OBJECT} - /> -
- - - - )} - -
- -
+ +
+ + api.inventory.listLaborRates()} + mapOption={mapLaborRateOption} + {...STORE_OBJECT} + /> +
+ +
+ + + +
+ + + +
+ + api.vendors.list()} + mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })} + {...STORE_OBJECT} + /> + +
+ + +