From 38565298fc8e4c538a36acd6ad252308c280c704 Mon Sep 17 00:00:00 2001 From: Mohammad Khyata Date: Thu, 7 May 2026 21:02:15 +0300 Subject: [PATCH] update forms Co-authored-by: Copilot --- .github/skills/crud-page/SKILL.md | 14 ++++ .../appointments/appointment-actions.tsx | 29 +++++-- apps/dashboard/modules/bills/bill-actions.tsx | 62 ++++++++++----- apps/dashboard/modules/bills/bill-form.tsx | 5 +- apps/dashboard/modules/bills/bill.schema.ts | 29 +++++-- .../credit-notes/credit-note-actions.tsx | 62 ++++++++++----- .../modules/credit-notes/credit-note-form.tsx | 3 +- .../credit-notes/credit-note.schema.ts | 13 +++- .../modules/customers/customer-actions.tsx | 62 ++++++++++----- .../modules/customers/customer-form.tsx | 25 +++++- .../modules/customers/customer.schema.ts | 57 +++++++++++--- .../modules/estimates/estimate-form.tsx | 77 +++++++++++++++++-- .../modules/estimates/estimate.schema.ts | 41 ++++++++-- .../modules/expenses/expense-actions.tsx | 62 ++++++++++----- .../modules/expenses/expense-form.tsx | 1 + .../modules/expenses/expense.schema.ts | 33 ++++++-- .../inspections/inspection-actions.tsx | 74 +++++++++++------- .../modules/invoices/invoice-form.tsx | 6 +- .../modules/invoices/invoice.schema.ts | 31 ++++++-- .../modules/job-cards/job-card-form.tsx | 27 ++++++- .../modules/job-cards/job-card.schema.ts | 61 +++++++++++---- .../payment-mades/payment-made-form.tsx | 7 +- .../payment-mades/payment-made.schema.ts | 52 +++++++++++-- .../payment-received-form.tsx | 2 + .../payment-received.schema.ts | 33 ++++++-- .../purchase-orders/purchase-order-form.tsx | 54 ++++++++++++- .../purchase-orders/purchase-order.schema.ts | 43 +++++++++-- .../vehicles/rhf-vehicle-identity-field.tsx | 22 ++++-- .../modules/vehicles/vehicle-form.tsx | 3 + .../modules/vehicles/vehicle.schema.ts | 10 +-- .../dashboard/modules/vendors/vendor-form.tsx | 6 +- .../modules/vendors/vendor.schema.ts | 27 +++++-- 32 files changed, 797 insertions(+), 236 deletions(-) diff --git a/.github/skills/crud-page/SKILL.md b/.github/skills/crud-page/SKILL.md index 8f7c11b..8e22c01 100644 --- a/.github/skills/crud-page/SKILL.md +++ b/.github/skills/crud-page/SKILL.md @@ -74,6 +74,7 @@ Create `apps/dashboard/modules//-form.tsx`: 5. Use `useFormMutation()` for submit with automatic validation error mapping 6. Render with `Rhform` + `RhfTextField` / `RhfSelectField` / `RhfAsyncSelectField` etc. 7. Include error alert, submit button with loading/edit states +8. For every required schema/backend field, pass `required` to the rendered form field component so required UI indicators and consistency are preserved ### Step 5: Create Page Component @@ -181,6 +182,19 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) = | `RhfDateField` | Date picker — see date-time-pickers skill | | `RhfTimeField` | Time picker — see date-time-pickers skill | +### Required Prop Rule (Important) + +When a field is required by schema or backend validation, always pass the `required` prop on the matching form component. + +- Applies to all controls that support it, including custom selectors (for example `RhfCustomerSelectField`, `RhfVehicleSelectField`) and standard fields (`RhfTextField`, `RhfSelectField`, etc.) +- Do not rely on schema-only required validation; keep UI required indicator (`*`) in sync with validation requirements + +```tsx + + + +``` + ### Imports Cheat Sheet ```tsx diff --git a/apps/dashboard/modules/appointments/appointment-actions.tsx b/apps/dashboard/modules/appointments/appointment-actions.tsx index 4a46385..c8518ac 100644 --- a/apps/dashboard/modules/appointments/appointment-actions.tsx +++ b/apps/dashboard/modules/appointments/appointment-actions.tsx @@ -4,6 +4,8 @@ import { useAuthApi } from "@/shared/useApi" import { useRouter } from "next/navigation" import { useState } from "react" import { Button } from "@/shared/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" +import { ScrollArea } from "@/shared/components/ui/scroll-area" import { DropdownMenu, DropdownMenuContent, @@ -15,6 +17,8 @@ import { Ellipsis, Pencil, Trash2, CheckCircle, Unlink } from "lucide-react" import { toast } from "sonner" import { useQueryClient } from "@tanstack/react-query" import { APPOINTMENT_ROUTES } from "@garage/api" +import { useFormDialog } from "@/shared/components/form-dialog" +import { AppointmentForm } from "./appointment-form" type AppointmentActionsProps = { appointmentId: string @@ -27,10 +31,7 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }: const router = useRouter() const queryClient = useQueryClient() const [isLoading, setIsLoading] = useState(false) - - const handleEdit = () => { - router.push(`/calendar/appointment/${appointmentId}/edit`) - } + const editDialog = useFormDialog("appointment-details-edit") const handleDelete = async () => { setIsLoading(true) @@ -82,7 +83,7 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }: - + editDialog.open(appointmentId)}> Edit @@ -120,5 +121,21 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }: - ) + { if (!v) editDialog.close() }}> + + + Edit Appointment + + + { + editDialog.close() + router.refresh() + }} + /> + + + + ) } diff --git a/apps/dashboard/modules/bills/bill-actions.tsx b/apps/dashboard/modules/bills/bill-actions.tsx index efbfdf1..2bd9095 100644 --- a/apps/dashboard/modules/bills/bill-actions.tsx +++ b/apps/dashboard/modules/bills/bill-actions.tsx @@ -3,6 +3,8 @@ import { useAuthApi } from "@/shared/useApi" import { useRouter } from "next/navigation" import { Button } from "@/shared/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" +import { ScrollArea } from "@/shared/components/ui/scroll-area" import { DropdownMenu, DropdownMenuContent, @@ -10,6 +12,8 @@ import { DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" import { Ellipsis, Pencil, Trash2 } from "lucide-react" +import { useFormDialog } from "@/shared/components/form-dialog" +import { BillForm } from "./bill-form" type BillActionsProps = { billId: string @@ -18,10 +22,7 @@ type BillActionsProps = { export function BillActions({ billId }: BillActionsProps) { const api = useAuthApi() const router = useRouter() - - const handleEdit = () => { - router.push(`/purchase/bill/${billId}/edit`) - } + const editDialog = useFormDialog("bill-details-edit") const handleDelete = async () => { await api.bills.destroy(billId) @@ -29,22 +30,41 @@ export function BillActions({ billId }: BillActionsProps) { } return ( - - - - - - - - Edit - - - - Delete - - - + <> + + + + + + editDialog.open(billId)}> + + Edit + + + + Delete + + + + + { if (!v) editDialog.close() }}> + + + Edit Bill + + + { + editDialog.close() + router.refresh() + }} + /> + + + + ) } diff --git a/apps/dashboard/modules/bills/bill-form.tsx b/apps/dashboard/modules/bills/bill-form.tsx index c00b8ef..7911a35 100644 --- a/apps/dashboard/modules/bills/bill-form.tsx +++ b/apps/dashboard/modules/bills/bill-form.tsx @@ -61,6 +61,7 @@ const DEFAULT_VALUES: BillFormValues = { discount: "no", discount_amount: undefined, notes: "", + label_ids: [], part_items: [], service_items: [], expense_items: [], @@ -102,6 +103,7 @@ function mapToFormValues(data: unknown): BillFormValues { discount: d.discount_type || "no", discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined, notes: d.notes || "", + label_ids: Array.isArray(d.label_ids) ? d.label_ids.map((id: any) => Number(id)).filter((id: number) => Number.isFinite(id)) : [], part_items: (d.parts ?? []).map((p: any) => ({ part_id: p.part_id ?? p.id, title: p.part?.name ?? p.part_name ?? p.title ?? "", @@ -146,6 +148,7 @@ function mapFormToPayload(values: BillFormValues) { discount_type: values.discount || undefined, discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined, notes: values.notes || undefined, + label_ids: values.label_ids?.length ? values.label_ids : undefined, part_items: (values.part_items ?? []).map((item) => ({ part_id: item.part_id, quantity: item.quantity, @@ -276,7 +279,7 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
- +
diff --git a/apps/dashboard/modules/bills/bill.schema.ts b/apps/dashboard/modules/bills/bill.schema.ts index de852ca..1b6771f 100644 --- a/apps/dashboard/modules/bills/bill.schema.ts +++ b/apps/dashboard/modules/bills/bill.schema.ts @@ -1,9 +1,25 @@ import { z } from "zod" +import { BillStatus, DiscountType } from "@garage/api" const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() +const optionalDateSchema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().date().optional(), +) + +const optionalStringMax255Schema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().max(255, "Must be at most 255 characters").optional(), +) + +const requiredDateSchema = z + .string() + .min(1, "Bill date is required") + .date("Enter a valid date") + const billPartItemSchema = z.object({ part_id: z.number(), title: z.string(), @@ -33,7 +49,7 @@ const billExpenseItemSchema = z.object({ const billFormSchema = z.object({ // ── Required ── - title: z.string().min(1, "Title is required"), + title: z.string().trim().min(1, "Title is required").max(255, "Title must be at most 255 characters"), // ── Relations ── vendor: relationFieldSchema, @@ -45,13 +61,14 @@ const billFormSchema = z.object({ tax: relationFieldSchema, // ── Optional fields ── - bill_number: z.string().optional(), - bill_date: z.string().optional(), - bill_due_date: z.string().optional(), - status: z.string().optional(), - discount: z.string().optional(), + bill_number: optionalStringMax255Schema, + bill_date: requiredDateSchema, + bill_due_date: optionalDateSchema, + status: z.enum(BillStatus).optional(), + discount: z.enum(DiscountType).optional(), discount_amount: z.coerce.number().min(0).optional(), notes: z.string().optional(), + label_ids: z.array(z.number()).optional(), // ── Line items ── part_items: z.array(billPartItemSchema).optional(), diff --git a/apps/dashboard/modules/credit-notes/credit-note-actions.tsx b/apps/dashboard/modules/credit-notes/credit-note-actions.tsx index 52574c3..43b639f 100644 --- a/apps/dashboard/modules/credit-notes/credit-note-actions.tsx +++ b/apps/dashboard/modules/credit-notes/credit-note-actions.tsx @@ -3,6 +3,8 @@ import { useAuthApi } from "@/shared/useApi" import { useRouter } from "next/navigation" import { Button } from "@/shared/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" +import { ScrollArea } from "@/shared/components/ui/scroll-area" import { DropdownMenu, DropdownMenuContent, @@ -10,6 +12,8 @@ import { DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" import { Ellipsis, Pencil, Trash2 } from "lucide-react" +import { useFormDialog } from "@/shared/components/form-dialog" +import { CreditNoteForm } from "./credit-note-form" type CreditNoteActionsProps = { creditNoteId: string @@ -18,10 +22,7 @@ type CreditNoteActionsProps = { export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) { const api = useAuthApi() const router = useRouter() - - const handleEdit = () => { - router.push(`/sales/credit-notes/${creditNoteId}/edit`) - } + const editDialog = useFormDialog("credit-note-details-edit") const handleDelete = async () => { await api.creditNotes.destroy(creditNoteId) @@ -29,22 +30,41 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) { } return ( - - - - - - - - Edit - - - - Delete - - - + <> + + + + + + editDialog.open(creditNoteId)}> + + Edit + + + + Delete + + + + + { if (!v) editDialog.close() }}> + + + Edit Credit Note + + + { + editDialog.close() + router.refresh() + }} + /> + + + + ) } diff --git a/apps/dashboard/modules/credit-notes/credit-note-form.tsx b/apps/dashboard/modules/credit-notes/credit-note-form.tsx index 33cfd72..1c0e958 100644 --- a/apps/dashboard/modules/credit-notes/credit-note-form.tsx +++ b/apps/dashboard/modules/credit-notes/credit-note-form.tsx @@ -11,6 +11,7 @@ import { RhfSelectField, RhfTextareaField, RhfAsyncSelectField, + RhfDateField, } from "@/shared/components/form" import { toast } from "sonner" import { useAuthApi } from "@/shared/useApi" @@ -150,7 +151,7 @@ export function CreditNoteForm({ resourceId, initialData, onSuccess }: CreditNot
- + (value === "" || value == null ? undefined : value), + z.string().max(255, "Must be at most 255 characters").optional(), +) + const creditNoteFormSchema = z.object({ // ── Required fields ── - subject: z.string().min(1, "Subject is required"), + subject: z.string().trim().min(1, "Subject is required").max(255, "Subject must be at most 255 characters"), // ── Relations ── customer: relationFieldSchema, department: relationFieldSchema, // ── Optional fields ── - date: z.string().optional(), - status: z.string().optional(), + date: z.string().min(1, "Date is required").date("Enter a valid date"), + status: z.enum(CreditNoteStatus).optional(), notes: z.string().optional(), + credit_invoice: optionalStringMax255Schema, }) type CreditNoteFormValues = z.infer diff --git a/apps/dashboard/modules/customers/customer-actions.tsx b/apps/dashboard/modules/customers/customer-actions.tsx index 570671e..c338d6d 100644 --- a/apps/dashboard/modules/customers/customer-actions.tsx +++ b/apps/dashboard/modules/customers/customer-actions.tsx @@ -3,6 +3,8 @@ import { useAuthApi } from "@/shared/useApi" import { useRouter } from "next/navigation" import { Button } from "@/shared/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" +import { ScrollArea } from "@/shared/components/ui/scroll-area" import { DropdownMenu, DropdownMenuContent, @@ -12,6 +14,8 @@ import { import { Ellipsis, Pencil, Trash2 } from "lucide-react" import { confirm } from "@/shared/components/confirm-dialog" import { toast } from "sonner" +import { useFormDialog } from "@/shared/components/form-dialog" +import { CustomerForm } from "./customer-form" type CustomerActionsProps = { customerId: string @@ -20,10 +24,7 @@ type CustomerActionsProps = { export function CustomerActions({ customerId }: CustomerActionsProps) { const api = useAuthApi() const router = useRouter() - - const handleEdit = () => { - router.push(`/sales/customers/${customerId}/edit`) - } + const editDialog = useFormDialog("customer-details-edit") const handleDelete = async () => { const confirmed = await confirm({ @@ -44,22 +45,41 @@ export function CustomerActions({ customerId }: CustomerActionsProps) { } return ( - - - - - - - - Edit - - - - Delete - - - + <> + + + + + + editDialog.open(customerId)}> + + Edit + + + + Delete + + + + + { if (!v) editDialog.close() }}> + + + Edit Customer + + + { + editDialog.close() + router.refresh() + }} + /> + + + + ) } diff --git a/apps/dashboard/modules/customers/customer-form.tsx b/apps/dashboard/modules/customers/customer-form.tsx index c2545ff..baca67a 100644 --- a/apps/dashboard/modules/customers/customer-form.tsx +++ b/apps/dashboard/modules/customers/customer-form.tsx @@ -51,13 +51,16 @@ const CUSTOMER_DEFAULT_VALUES: CustomerFormValues = { payment_terms: null, country: null, state: null, - salutation: "", + salutation: "Mr.", first_name: "", last_name: "", company_name: "", email: "", phone: "", alternate_phone: "", + opening_balance: undefined, + credit_limit: undefined, + website: "", address_line_1: "", address_line_2: "", city: "", @@ -82,6 +85,9 @@ function mapCustomerToFormValues(data: unknown): CustomerFormValues { email: c.email || "", phone: c.phone || "", alternate_phone: c.alternate_phone || "", + opening_balance: c.opening_balance ?? undefined, + credit_limit: c.credit_limit ?? undefined, + website: c.website || "", address_line_1: c.address_line_1 || "", address_line_2: c.address_line_2 || "", city: c.city || "", @@ -96,13 +102,16 @@ function mapFormToPayload(values: CustomerFormValues) { payment_terms_id: toId(values.payment_terms), country_id: toId(values.country), state_id: toId(values.state), - salutation: values.salutation || undefined, + salutation: values.salutation, first_name: values.first_name, last_name: values.last_name, company_name: values.company_name || undefined, - email: values.email || undefined, + email: values.email, phone: values.phone || undefined, alternate_phone: values.alternate_phone || undefined, + opening_balance: values.opening_balance, + credit_limit: values.credit_limit, + website: values.website || undefined, address_line_1: values.address_line_1 || undefined, address_line_2: values.address_line_2 || undefined, city: values.city || undefined, @@ -169,6 +178,7 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor {/* Basic Info */}
- +
+
+ + +
+ + + {/* Relations */}
+const optionalTrimmedString = (max: number) => + z.preprocess( + (value) => { + if (typeof value !== "string") { + return value + } + + const trimmed = value.trim() + return trimmed === "" ? undefined : trimmed + }, + z.string().max(max).optional(), + ) + +const optionalInteger = z.preprocess( + (value) => { + if (value === "" || value == null) { + return undefined + } + + return Number(value) + }, + z.number().int().optional(), +) + const customerFormSchema = z.object({ // ── Relations (stored as objects, mapped to IDs on submit) ── customer_type: relationFieldSchema.refine((val) => !!val?.value, "Customer type is required"), @@ -19,23 +43,32 @@ const customerFormSchema = z.object({ 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(), + salutation: z.string().trim().min(1, "Salutation is required").max(50, "Salutation cannot exceed 50 characters"), + first_name: z.string().trim().min(1, "First name is required").max(50, "First name cannot exceed 50 characters"), + last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name cannot exceed 50 characters"), + company_name: optionalTrimmedString(50), // ── Contact ── email: z - .union([z.string().email("Enter a valid email address"), z.literal("")]) - .optional(), - phone: z.string().optional(), - alternate_phone: z.string().optional(), + .string() + .trim() + .min(1, "Email is required") + .email("Enter a valid email address") + .max(100, "Email cannot exceed 100 characters"), + phone: optionalTrimmedString(20), + alternate_phone: optionalTrimmedString(20), + opening_balance: optionalInteger, + credit_limit: optionalInteger, + website: optionalTrimmedString(255).refine( + (value) => !value || z.string().url().safeParse(value).success, + "Enter a valid website URL", + ), // ── Address ── - address_line_1: z.string().optional(), - address_line_2: z.string().optional(), - city: z.string().optional(), - zip_code: z.string().optional(), + address_line_1: optionalTrimmedString(255), + address_line_2: optionalTrimmedString(255), + city: optionalTrimmedString(100), + zip_code: optionalTrimmedString(20), }) type CustomerFormValues = z.infer diff --git a/apps/dashboard/modules/estimates/estimate-form.tsx b/apps/dashboard/modules/estimates/estimate-form.tsx index 4f94f4e..ea4dffd 100644 --- a/apps/dashboard/modules/estimates/estimate-form.tsx +++ b/apps/dashboard/modules/estimates/estimate-form.tsx @@ -11,6 +11,8 @@ import { RhfAsyncSelectField, RhfDateField, RhfAutoGenerateField, + RhfSelectField, + RhfTextareaField, } from "@/shared/components/form" import { toast } from "sonner" import { useAuthApi } from "@/shared/useApi" @@ -22,11 +24,12 @@ import { estimateFormSchema, type EstimateFormValues, } from "./estimate.schema" -import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api" +import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES, DiscountType } from "@garage/api" import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field" import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field" import { RhfLabelPickerField, type LabelItem } from "@/modules/labels/rhf-label-picker-field" import { RhfCustomerRemarksField } from "./rhf-customer-remarks-field" +import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field" // ── Props ── @@ -44,9 +47,16 @@ const DEFAULT_VALUES: EstimateFormValues = { customer: null, vehicle: null, department: null, + insurance_type: null, + insurer: null, + service_writer: null, estimate_number: "", date: "", has_insurance: false, + enable_digital_authorisation: false, + footer: "", + discount: "no", + discount_amount: undefined, remarks: [], labels: [], } @@ -61,9 +71,24 @@ function mapToFormValues(data: unknown): EstimateFormValues { customer: toRelation(d.customer_id, d.customer_name), vehicle: toRelation(d.vehicle_id, d.vehicle_name), department: toRelation(d.department_id, d.department_name), + insurance_type: toRelation(d.insurance_type_id, d.insurance_type_title ?? d.insurance_type_name ?? d.insurance_type?.title), + insurer: toRelation( + d.insurer_id, + d.insurer_name + ?? [d.insurer?.first_name, d.insurer?.last_name].filter(Boolean).join(" ") + ?? d.insurer?.company_name, + ), + service_writer: toRelation( + d.service_writer_id, + d.service_writer_name ?? [d.service_writer?.first_name, d.service_writer?.last_name].filter(Boolean).join(" "), + ), estimate_number: d.estimate_number || "", date: d.date ? d.date.split("T")[0] : "", has_insurance: d.has_insurance ?? false, + enable_digital_authorisation: d.enable_digital_authorisation ?? false, + footer: d.footer ?? "", + discount: d.discount ?? d.discount_type ?? "no", + discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined, remarks: Array.isArray(d.remarks) ? d.remarks.map((r: any) => (typeof r === "string" ? r : (r?.remark ?? ""))).filter(Boolean) : [], @@ -81,9 +106,16 @@ function mapFormToPayload(values: EstimateFormValues) { customer_id: toId(values.customer), vehicle_id: toId(values.vehicle), department_id: toId(values.department), + insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null, + insurer_id: toId(values.insurer), + service_writer_id: toId(values.service_writer), estimate_number: values.estimate_number || undefined, date: values.date || undefined, has_insurance: values.has_insurance, + enable_digital_authorisation: values.enable_digital_authorisation, + footer: values.footer || undefined, + discount: values.discount || undefined, + discount_amount: values.discount_amount, remarks: values.remarks?.filter(Boolean) ?? [], label_ids: values.labels?.map((l) => l.id) ?? [], } @@ -98,6 +130,11 @@ const mapLookupOption = (item: any) => ({ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } +const DISCOUNT_OPTIONS = DiscountType.map((value) => ({ + value, + label: value.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +})) + // ── Component ── export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFormProps) { @@ -116,8 +153,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor mutationFn: (values: EstimateFormValues) => { const payload = mapFormToPayload(values) const promise = (isEditing && resourceId - ? api.estimates.update(resourceId, payload) - : api.estimates.create(payload)) as Promise + ? api.estimates.update(resourceId, payload as any) + : api.estimates.create(payload as any)) as Promise toast.promise(promise, { loading: isEditing ? "Updating estimate..." : "Creating estimate...", success: isEditing ? "Estimate updated successfully" : "Estimate created successfully", @@ -149,8 +186,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
- - + +
@@ -164,11 +201,39 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor {...STORE_OBJECT} /> - +
+
+ api.insuranceTypes.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + + +
+ +
+ + +
+ + + + + - - - - - Edit - - - - Delete - - - + <> + + + + + + editDialog.open(expenseId)}> + + Edit + + + + Delete + + + + + { if (!v) editDialog.close() }}> + + + Edit Expense + + + { + editDialog.close() + router.refresh() + }} + /> + + + + ) } diff --git a/apps/dashboard/modules/expenses/expense-form.tsx b/apps/dashboard/modules/expenses/expense-form.tsx index f0e7cd3..de650e2 100644 --- a/apps/dashboard/modules/expenses/expense-form.tsx +++ b/apps/dashboard/modules/expenses/expense-form.tsx @@ -314,6 +314,7 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP name="job_card" label="Job Card" placeholder="Select job card" + required queryKey={[JOB_CARD_ROUTES.INDEX]} listFn={() => api.jobCards.list()} mapOption={(item: any) => ({ diff --git a/apps/dashboard/modules/expenses/expense.schema.ts b/apps/dashboard/modules/expenses/expense.schema.ts index b277d7d..6948d35 100644 --- a/apps/dashboard/modules/expenses/expense.schema.ts +++ b/apps/dashboard/modules/expenses/expense.schema.ts @@ -1,9 +1,29 @@ import { z } from "zod" +import { ExpenseStatus, InvoiceDiscount } from "@garage/api" const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() +const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => { + if (!value?.value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "This field is required", + }) + } +}) + +const optionalDateSchema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().date().optional(), +) + +const optionalStringMax255Schema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().max(255, "Must be at most 255 characters").optional(), +) + const labelItemSchema = z.object({ id: z.number(), title: z.string(), @@ -22,21 +42,22 @@ const expenseLineItemSchema = z.object({ const expenseFormSchema = z.object({ // ── Relations ── - job_card: relationFieldSchema, + job_card: requiredRelationFieldSchema, category: relationFieldSchema, vendor: relationFieldSchema, department: relationFieldSchema, tax: relationFieldSchema, // ── Basic info ── - title: z.string().min(1, "Title is required"), - invoice_number: z.string().optional(), - expense_date: z.string().optional(), + title: z.string().trim().min(1, "Title is required").max(255, "Title must be at most 255 characters"), + invoice_number: optionalStringMax255Schema, + expense_date: optionalDateSchema, notes: z.string().optional(), - status: z.string().optional(), + status: z.enum(ExpenseStatus).optional(), + paid_through: z.coerce.number().int().optional(), // ── Discount / Tax ── - discount: z.string().optional(), + discount: z.enum(InvoiceDiscount).optional(), discount_amount: z.coerce.number().min(0).optional(), labels: z.array(labelItemSchema).optional(), diff --git a/apps/dashboard/modules/inspections/inspection-actions.tsx b/apps/dashboard/modules/inspections/inspection-actions.tsx index 04ae71a..4777161 100644 --- a/apps/dashboard/modules/inspections/inspection-actions.tsx +++ b/apps/dashboard/modules/inspections/inspection-actions.tsx @@ -3,6 +3,8 @@ import { useAuthApi } from "@/shared/useApi" import { useRouter } from "next/navigation" import { Button } from "@/shared/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" +import { ScrollArea } from "@/shared/components/ui/scroll-area" import { DropdownMenu, DropdownMenuContent, @@ -12,6 +14,8 @@ import { } from "@/shared/components/ui/dropdown-menu" import { Ellipsis, Pencil, Trash2, Play, CheckCircle2 } from "lucide-react" import { toast } from "sonner" +import { useFormDialog } from "@/shared/components/form-dialog" +import { InspectionForm } from "./inspection-form" type InspectionActionsProps = { inspectionId: string @@ -27,10 +31,7 @@ const STATUS_TRANSITIONS: Record { - router.push(`/sales/inspections/${inspectionId}/edit`) - } + const editDialog = useFormDialog("inspection-details-edit") const handleDelete = async () => { const promise = api.inspections.destroy(inspectionId) @@ -60,29 +61,48 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp const transition = status ? STATUS_TRANSITIONS[status] : undefined return ( - - - - - - - - Edit - - {transition && ( - handleStatusChange(transition.next)}> - - {transition.label} + <> + + + + + + editDialog.open(inspectionId)}> + + Edit - )} - - - - Delete - - - + {transition && ( + handleStatusChange(transition.next)}> + + {transition.label} + + )} + + + + Delete + + + + + { if (!v) editDialog.close() }}> + + + Edit Inspection + + + { + editDialog.close() + router.refresh() + }} + /> + + + + ) } diff --git a/apps/dashboard/modules/invoices/invoice-form.tsx b/apps/dashboard/modules/invoices/invoice-form.tsx index f62ca65..c4c39f2 100644 --- a/apps/dashboard/modules/invoices/invoice-form.tsx +++ b/apps/dashboard/modules/invoices/invoice-form.tsx @@ -302,7 +302,7 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
- +
@@ -338,8 +338,8 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
- - + +
name="customer" /> diff --git a/apps/dashboard/modules/invoices/invoice.schema.ts b/apps/dashboard/modules/invoices/invoice.schema.ts index 8d3b7b8..d7bfd86 100644 --- a/apps/dashboard/modules/invoices/invoice.schema.ts +++ b/apps/dashboard/modules/invoices/invoice.schema.ts @@ -1,9 +1,15 @@ import { z } from "zod" +import { InvoiceDiscount, InvoiceStatus } from "@garage/api" const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() +const requiredDateSchema = z + .string() + .min(1, "This field is required") + .date("Enter a valid date") + const invoicePartItemSchema = z.object({ part_id: z.number(), title: z.string(), @@ -33,7 +39,7 @@ const invoiceExpenseItemSchema = z.object({ const invoiceFormSchema = z.object({ // ── Required fields ── - subject: z.string().min(1, "Subject is required"), + subject: z.string().trim().min(1, "Subject is required").max(255, "Subject must be at most 255 characters"), // ── Relations ── customer: relationFieldSchema, @@ -47,14 +53,14 @@ const invoiceFormSchema = z.object({ invoice_to: relationFieldSchema, // ── Optional fields ── - invoice_number: z.string().optional(), + invoice_number: z.string().trim().min(1, "Invoice number is required").max(255, "Invoice number must be at most 255 characters"), invoice_title: z.string().optional(), - invoice_date: z.string().optional(), - due_date: z.string().optional(), - status: z.string().optional(), + invoice_date: requiredDateSchema, + due_date: requiredDateSchema, + status: z.enum(InvoiceStatus).optional(), kms_in: z.coerce.number().optional(), has_insurance: z.boolean().default(false), - discount: z.string().optional(), + discount: z.enum(InvoiceDiscount).optional(), discount_amount: z.coerce.number().min(0).optional(), tax: relationFieldSchema, deposit_to: z.string().optional(), @@ -65,6 +71,19 @@ const invoiceFormSchema = z.object({ parts: z.array(invoicePartItemSchema).optional(), services: z.array(invoiceServiceItemSchema).optional(), expense_items: z.array(invoiceExpenseItemSchema).optional(), +}).superRefine((values, ctx) => { + if (values.invoice_date && values.due_date) { + const invoiceDate = new Date(values.invoice_date) + const dueDate = new Date(values.due_date) + + if (!Number.isNaN(invoiceDate.getTime()) && !Number.isNaN(dueDate.getTime()) && dueDate < invoiceDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["due_date"], + message: "Due date must be on or after invoice date", + }) + } + } }) type InvoiceFormValues = z.infer diff --git a/apps/dashboard/modules/job-cards/job-card-form.tsx b/apps/dashboard/modules/job-cards/job-card-form.tsx index 4304c2e..34f55d2 100644 --- a/apps/dashboard/modules/job-cards/job-card-form.tsx +++ b/apps/dashboard/modules/job-cards/job-card-form.tsx @@ -60,6 +60,7 @@ const DEFAULT_VALUES: JobCardFormValues = { title: "", customer: null, vehicle: null, + estimate: null, department: null, service_writer: null, primary_technician: null, @@ -73,6 +74,7 @@ const DEFAULT_VALUES: JobCardFormValues = { estimate_to: "Customer", tax_inclusive: "Tax Inclusive", discount_type: "no", + discount_amount: undefined, discount_at: "inclusive_of_tax", order_date: new Date().toISOString().split("T")[0], check_in_date: "", @@ -88,7 +90,11 @@ const DEFAULT_VALUES: JobCardFormValues = { enable_parts_issuing: false, enable_digital_authorisation: false, footer: "", + attachments: "", customer_remarks: [], + documents: [], + attachment_files: [], + label_ids: [], labels: [], } @@ -111,6 +117,7 @@ function mapToFormValues(data: unknown): JobCardFormValues { title: d.title || "", customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined), vehicle: toRelation(d.vehicle_id, d.vehicle ? (d.vehicle.plate_number ?? `${d.vehicle.make ?? ""} ${d.vehicle.model ?? ""}`.trim()) : undefined), + estimate: toRelation(d.estimate_id, d.estimate?.estimate_number), department: toRelation(d.department_id, d.department?.name), service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined), primary_technician: toRelation(d.primary_technician_id, d.primary_technician ? `${d.primary_technician.first_name} ${d.primary_technician.last_name}` : undefined), @@ -124,6 +131,7 @@ function mapToFormValues(data: unknown): JobCardFormValues { estimate_to: d.estimate_to || "Customer", tax_inclusive: d.tax_inclusive || "Tax Inclusive", discount_type: d.discount_type || "no", + discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined, discount_at: d.discount_at || "inclusive_of_tax", order_date: mapDate(d.order_date), check_in_date: mapDate(d.check_in_date), @@ -138,11 +146,19 @@ function mapToFormValues(data: unknown): JobCardFormValues { enable_parts_issuing: d.enable_parts_issuing ?? false, enable_digital_authorisation: d.enable_digital_authorisation ?? false, footer: d.footer || "", + attachments: d.attachments || "", customer_remarks: Array.isArray(d.customer_remarks) ? d.customer_remarks.map((r: any) => typeof r === "string" ? r : (r?.remark ?? "") ).filter(Boolean) : [], + documents: Array.isArray(d.documents) ? d.documents : [], + attachment_files: Array.isArray(d.attachment_files) ? d.attachment_files : [], + label_ids: Array.isArray(d.label_ids) + ? d.label_ids + .map((id: any) => Number(id)) + .filter((id: number) => Number.isFinite(id)) + : [], labels: (d.labels ?? []).map((l: any) => ({ id: l.id, title: l.title, @@ -156,6 +172,7 @@ function mapFormToPayload(values: JobCardFormValues) { title: values.title, customer_id: toId(values.customer), vehicle_id: toId(values.vehicle), + estimate_id: toId(values.estimate) ?? undefined, department_id: toId(values.department), service_writer_id: toId(values.service_writer), primary_technician_id: toId(values.primary_technician), @@ -169,6 +186,7 @@ function mapFormToPayload(values: JobCardFormValues) { estimate_to: values.estimate_to || undefined, tax_inclusive: values.tax_inclusive || undefined, discount_type: values.discount_type || undefined, + discount_amount: values.discount_amount, discount_at: values.discount_at || undefined, order_date: values.order_date || undefined, check_in_date: values.check_in_date || undefined, @@ -183,8 +201,11 @@ function mapFormToPayload(values: JobCardFormValues) { enable_parts_issuing: values.enable_parts_issuing, enable_digital_authorisation: values.enable_digital_authorisation, footer: values.footer || undefined, + attachments: values.attachments || undefined, customer_remarks: values.customer_remarks?.filter(Boolean) ?? [], - label_ids: values.labels?.map((l) => l.id), + documents: values.documents, + attachment_files: values.attachment_files, + label_ids: values.labels?.map((l) => l.id) ?? values.label_ids, } } @@ -275,8 +296,8 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
- - + +
diff --git a/apps/dashboard/modules/job-cards/job-card.schema.ts b/apps/dashboard/modules/job-cards/job-card.schema.ts index 998cace..9efd0f9 100644 --- a/apps/dashboard/modules/job-cards/job-card.schema.ts +++ b/apps/dashboard/modules/job-cards/job-card.schema.ts @@ -12,6 +12,19 @@ const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() +const optionalStringMax255 = z.string().max(255).optional() +const optionalTimeString = z.string().max(50).optional() + +const documentSchema = z.object({ + document_type_id: z.coerce.number().int().optional(), + customer_id: z.coerce.number().int().optional(), + vehicle_id: z.coerce.number().int().optional(), + document_number: z.string().optional(), + show_in_invoice: z.boolean().optional(), + show_in_estimate: z.boolean().optional(), + show_in_statement: z.boolean().optional(), +}) + // ── Job Card Statuses ── export const JOB_CARD_STATUS_OPTIONS = JobCardStatus.map((v) => ({ @@ -48,11 +61,12 @@ const FUEL_LEVEL_OPTIONS = FuelLevel.map((v) => ({ const jobCardFormSchema = z.object({ // ── Required fields ── - title: z.string().min(1, "Title is required"), + title: z.string().min(1, "Title is required").max(255, "Title must be at most 255 characters"), // ── Relations ── customer: relationFieldSchema, vehicle: relationFieldSchema, + estimate: relationFieldSchema.optional(), department: relationFieldSchema, service_writer: relationFieldSchema, primary_technician: relationFieldSchema, @@ -62,28 +76,29 @@ const jobCardFormSchema = z.object({ tax: relationFieldSchema, // ── Numbers & identifiers ── - order_number: z.string().optional(), - estimate_number: z.string().optional(), + order_number: optionalStringMax255, + estimate_number: optionalStringMax255, // ── Status & settings ── - status: z.string().optional(), - estimate_to: z.string().optional(), - tax_inclusive: z.string().optional(), - discount_type: z.string().optional(), - discount_at: z.string().optional(), + status: z.enum(JobCardStatus).optional(), + estimate_to: z.enum(EstimateTo).optional(), + tax_inclusive: z.enum(TaxInclusive).optional(), + discount_type: z.enum(DiscountType).optional(), + discount_amount: z.coerce.number().min(0).optional(), + discount_at: z.enum(DiscountAt).optional(), // ── Dates & times ── order_date: z.string().optional(), check_in_date: z.string().optional(), - check_in_time: z.string().optional(), + check_in_time: optionalTimeString, start_date: z.string().optional(), - start_time: z.string().optional(), + start_time: optionalTimeString, delivery_date: z.string().optional(), - delivery_time: z.string().optional(), + delivery_time: optionalTimeString, // ── Vehicle state ── - km_in: z.string().optional(), - fuel_level: z.string().optional(), + km_in: z.union([z.string(), z.number()]).optional(), + fuel_level: optionalTimeString, // ── Boolean options ── has_insurance: z.boolean().optional(), @@ -92,9 +107,12 @@ const jobCardFormSchema = z.object({ // ── Notes ── footer: z.string().optional(), + attachments: z.string().optional(), // ── Customer Remarks ── customer_remarks: z.array(z.string()).optional(), + documents: z.array(documentSchema).optional(), + attachment_files: z.array(z.unknown()).max(20, "Attachment files cannot exceed 20 files").optional(), // ── Labels ── labels: z @@ -106,6 +124,23 @@ const jobCardFormSchema = z.object({ }), ) .optional(), + label_ids: z.array(z.coerce.number().int()).optional(), +}).superRefine((values, ctx) => { + if (!values.customer) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customer"], + message: "Customer is required", + }) + } + + if (!values.vehicle) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["vehicle"], + message: "Vehicle is required", + }) + } }) type JobCardFormValues = z.infer diff --git a/apps/dashboard/modules/payment-mades/payment-made-form.tsx b/apps/dashboard/modules/payment-mades/payment-made-form.tsx index 17a04b9..2d78f49 100644 --- a/apps/dashboard/modules/payment-mades/payment-made-form.tsx +++ b/apps/dashboard/modules/payment-mades/payment-made-form.tsx @@ -63,7 +63,7 @@ const DEFAULT_VALUES: PaymentMadeFormValues & { details: Array<{ bill_id?: strin payment_date: getTodayDate(), paid_through: "", notes: "", - details: [] + details: [{ amount_paid: 0 }] } // ── Mapping helpers ── @@ -79,14 +79,14 @@ function mapToFormValues(data: unknown): typeof DEFAULT_VALUES { 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), - payment_for: d.payment_for || "", + payment_for: d.payment_for || "bill", amount: d.payment_made ? String(d.payment_made) : "", payment_number: d.payment_number || "", payment_reference: d.payment_reference || "", payment_date: d.payment_date || "", paid_through: d.paid_through || "-", notes: d.notes || "-", - details: [{ bill_id: d.bill_id, amount_paid: d.amount_paid }], + details: [{ bill_id: d.bill_id, expense_id: d.expense_id, amount_paid: d.amount_paid ?? 0 }], } } @@ -249,6 +249,7 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex name="payment_mode" label="Payment Mode" placeholder="Select payment mode" + required queryKey={[PAYMENT_MODE_ROUTES.INDEX]} listFn={() => api.paymentModes.list()} mapOption={mapLookupOption} diff --git a/apps/dashboard/modules/payment-mades/payment-made.schema.ts b/apps/dashboard/modules/payment-mades/payment-made.schema.ts index b2a8d1d..9c92fe3 100644 --- a/apps/dashboard/modules/payment-mades/payment-made.schema.ts +++ b/apps/dashboard/modules/payment-mades/payment-made.schema.ts @@ -1,23 +1,61 @@ import { z } from "zod" +import { PaymentFor } from "@garage/api" const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() +const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => { + if (!value?.value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "This field is required", + }) + } +}) + +const optionalStringMax255Schema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().max(255, "Must be at most 255 characters").optional(), +) + +const detailsItemSchema = z.object({ + bill_id: z.union([z.string(), z.number()]).optional().nullable(), + expense_id: z.union([z.string(), z.number()]).optional().nullable(), + amount_paid: z.coerce.number().min(0).optional(), +}) + const paymentMadeFormSchema = z.object({ // ── Relations ── vendor: relationFieldSchema, employee: relationFieldSchema, - payment_mode: relationFieldSchema, + payment_mode: requiredRelationFieldSchema, // ── Payment info ── - amount: z.string().min(1, "Amount is required"), - payment_for: z.string().min(1, "Payment for is required"), - payment_number: z.string().optional(), - payment_reference: z.string().optional(), - payment_date: z.string().min(1, "Payment date is required"), - paid_through: z.string().optional(), + amount: z.coerce.number().min(0, "Amount must be 0 or more"), + payment_for: z.enum(PaymentFor), + payment_number: optionalStringMax255Schema, + payment_reference: optionalStringMax255Schema, + payment_date: z.string().min(1, "Payment date is required").date("Enter a valid date"), + paid_through: optionalStringMax255Schema, notes: z.string().optional(), + details: z.array(detailsItemSchema).min(1, "At least one payment detail is required"), +}).superRefine((values, ctx) => { + const hasVendor = Boolean(values.vendor?.value) + const hasEmployee = Boolean(values.employee?.value) + + if (!hasVendor && !hasEmployee) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["vendor"], + message: "Vendor or employee is required", + }) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["employee"], + message: "Vendor or employee is required", + }) + } }) type PaymentMadeFormValues = z.infer diff --git a/apps/dashboard/modules/payment-received/payment-received-form.tsx b/apps/dashboard/modules/payment-received/payment-received-form.tsx index 312a75d..2dbe1ff 100644 --- a/apps/dashboard/modules/payment-received/payment-received-form.tsx +++ b/apps/dashboard/modules/payment-received/payment-received-form.tsx @@ -157,6 +157,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul name="customer" label="Customer" placeholder="Select customer" + required queryKey={[CUSTOMER_ROUTES.INDEX]} listFn={() => api.customers.list()} mapOption={(item: any) => ({ @@ -193,6 +194,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul name="payment_mode" label="Payment Mode" placeholder="Select payment mode" + required queryKey={[PAYMENT_MODE_ROUTES.INDEX]} listFn={() => api.paymentModes.list()} mapOption={mapLookupOption} diff --git a/apps/dashboard/modules/payment-received/payment-received.schema.ts b/apps/dashboard/modules/payment-received/payment-received.schema.ts index c3ab6fa..9e03553 100644 --- a/apps/dashboard/modules/payment-received/payment-received.schema.ts +++ b/apps/dashboard/modules/payment-received/payment-received.schema.ts @@ -4,17 +4,38 @@ const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() +const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => { + if (!value?.value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "This field is required", + }) + } +}) + +const optionalDateSchema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().date().optional(), +) + +const optionalStringMax255Schema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().max(255, "Must be at most 255 characters").optional(), +) + const paymentReceivedFormSchema = z.object({ // ── Relations ── job_card: relationFieldSchema, - payment_mode: relationFieldSchema, - customer: relationFieldSchema, + payment_mode: requiredRelationFieldSchema, + customer: requiredRelationFieldSchema, // ── Payment info ── - amount_received: z.string().min(1, "Amount is required"), - payment_number: z.string().optional(), - payment_date: z.string().min(1, "Payment date is required"), - note: z.string().optional(), + amount_received: z.coerce.number().min(0, "Amount received must be 0 or more"), + payment_number: optionalStringMax255Schema, + payment_date: optionalDateSchema, + reference_date: optionalDateSchema, + deposit_to: optionalStringMax255Schema, + note: optionalStringMax255Schema, }) type PaymentReceivedFormValues = z.infer diff --git a/apps/dashboard/modules/purchase-orders/purchase-order-form.tsx b/apps/dashboard/modules/purchase-orders/purchase-order-form.tsx index 25d177a..a9039d7 100644 --- a/apps/dashboard/modules/purchase-orders/purchase-order-form.tsx +++ b/apps/dashboard/modules/purchase-orders/purchase-order-form.tsx @@ -8,6 +8,7 @@ import { FieldGroup } from "@/shared/components/ui/field" import { Rhform, RhfTextField, + RhfSelectField, RhfTextareaField, RhfAsyncSelectField, RhfDateField, @@ -25,8 +26,9 @@ import { purchaseOrderFormSchema, type PurchaseOrderFormValues, } from "./purchase-order.schema" -import { VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api" +import { VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES, InvoiceDiscount } from "@garage/api" import { getFullName } from "@/shared/utils/getFullName" +import { useFormContext } from "react-hook-form" // ── Props ── @@ -47,6 +49,10 @@ const DEFAULT_VALUES: PurchaseOrderFormValues = { order_date: new Date().toISOString().split("T")[0], delivery_date: "", notes: "", + terms_and_conditions: "", + discount_type: "no", + discount_amount: undefined, + label_ids: [], items: [], } @@ -64,7 +70,11 @@ function mapToFormValues(data: unknown): PurchaseOrderFormValues { delivery_date: d.delivery_date || "", order_number: d.order_number || "" as any, notes: d.notes || "", - items: (d.parts ?? []).map((p: any) => ({ + terms_and_conditions: d.terms_and_conditions || "", + discount_type: d.discount_type || "no", + discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined, + label_ids: Array.isArray(d.label_ids) ? d.label_ids.map((id: any) => Number(id)).filter((id: number) => Number.isFinite(id)) : [], + items: (d.parts ?? d.items ?? []).map((p: any) => ({ part_id: p.part_id ?? p.id, title: p.part?.title ?? p.title ?? "", quantity: p.quantity ?? 1, @@ -84,6 +94,10 @@ function mapFormToPayload(values: PurchaseOrderFormValues) { order_number: values.order_number, delivery_date: values.delivery_date || undefined, notes: values.notes || undefined, + terms_and_conditions: values.terms_and_conditions || undefined, + discount_type: values.discount_type || undefined, + discount_amount: values.discount_type === "transaction_level" ? (values.discount_amount ?? 0) : undefined, + label_ids: values.label_ids?.length ? values.label_ids : undefined, items: (values.items ?? []).map((item) => ({ part_id: item.part_id, quantity: item.quantity, @@ -93,6 +107,27 @@ function mapFormToPayload(values: PurchaseOrderFormValues) { } } +const DISCOUNT_OPTIONS = InvoiceDiscount.map((v) => ({ + value: v, + label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), +})) + +function TransactionDiscountField() { + const { watch } = useFormContext() + const discount = watch("discount_type") + + if (discount !== "transaction_level") return null + + return ( + + ) +} + // ── Shared mapOption for async selects ── const mapLookupOption = (item: any) => ({ @@ -156,17 +191,27 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
- +
+ + + +
api.vendors.list()} mapOption={mapVendorOption} @@ -185,6 +230,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha name="department" label="Department" placeholder="Select department" + required queryKey={[DEPARTMENT_ROUTES.INDEX]} listFn={() => api.departments.list()} mapOption={mapLookupOption} @@ -196,6 +242,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha name="job_card" label="Job Card" placeholder="Select job card" + required queryKey={[JOB_CARD_ROUTES.INDEX]} listFn={() => api.jobCards.list()} mapOption={(item: any) => ({ @@ -206,6 +253,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha /> + name="items" /> diff --git a/apps/dashboard/modules/purchase-orders/purchase-order.schema.ts b/apps/dashboard/modules/purchase-orders/purchase-order.schema.ts index 982766c..4d56af1 100644 --- a/apps/dashboard/modules/purchase-orders/purchase-order.schema.ts +++ b/apps/dashboard/modules/purchase-orders/purchase-order.schema.ts @@ -1,9 +1,34 @@ import { z } from "zod" +import { InvoiceDiscount } from "@garage/api" const relationFieldSchema = z .object({ value: z.string(), label: z.string() }) .nullable() +const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => { + if (!value?.value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "This field is required", + }) + } +}) + +const optionalDateSchema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().date().optional(), +) + +const optionalStringMax255Schema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.string().max(255, "Must be at most 255 characters").optional(), +) + +const optionalNumberSchema = z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.coerce.number().min(0).optional(), +) + const purchaseOrderItemSchema = z.object({ part_id: z.number(), title: z.string(), @@ -14,16 +39,20 @@ const purchaseOrderItemSchema = z.object({ const purchaseOrderFormSchema = z.object({ // ── Relations ── - vendor: relationFieldSchema, - job_card: relationFieldSchema, - department: relationFieldSchema, + vendor: requiredRelationFieldSchema, + job_card: requiredRelationFieldSchema, + department: requiredRelationFieldSchema, // ── Basic info ── - title: z.string().min(1, "Title is required"), - order_date: z.string().optional(), - delivery_date: z.string().optional(), + title: optionalStringMax255Schema, + order_date: optionalDateSchema, + delivery_date: optionalDateSchema, notes: z.string().optional(), - order_number: z.string().min(1, "Order number is required"), + terms_and_conditions: z.string().optional(), + discount_type: z.enum(InvoiceDiscount).optional(), + discount_amount: optionalNumberSchema, + label_ids: z.array(z.number()).optional(), + order_number: z.string().trim().min(1, "Order number is required").max(255, "Order number must be at most 255 characters"), // ── Items (parts) ── items: z.array(purchaseOrderItemSchema).optional(), diff --git a/apps/dashboard/modules/vehicles/rhf-vehicle-identity-field.tsx b/apps/dashboard/modules/vehicles/rhf-vehicle-identity-field.tsx index cd6a9df..b9b4c0a 100644 --- a/apps/dashboard/modules/vehicles/rhf-vehicle-identity-field.tsx +++ b/apps/dashboard/modules/vehicles/rhf-vehicle-identity-field.tsx @@ -43,6 +43,12 @@ type VehicleComboboxProps = { disabled?: boolean } +function normalizeTextValue(value: unknown): string { + if (typeof value === "string") return value + if (typeof value === "number") return String(value) + return "" +} + function VehicleCombobox({ value, onChange, @@ -59,14 +65,14 @@ function VehicleCombobox({ // Local state keeps the input text in sync even when the form value // is changed externally (e.g., cascade reset or edit-mode population). - const [inputText, setInputText] = useState(value ?? "") + const [inputText, setInputText] = useState(() => normalizeTextValue(value)) useEffect(() => { - setInputText(value ?? "") + setInputText(normalizeTextValue(value)) }, [value]) // Client-side filtering of suggestions based on what's been typed - const normalizedInput = inputText.toLowerCase() + const normalizedInput = normalizeTextValue(inputText).toLowerCase() const filtered = options.filter((opt) => !normalizedInput || opt.toLowerCase().includes(normalizedInput), ) @@ -80,7 +86,7 @@ function VehicleCombobox({
{ const str = val !== null ? String(val) : "" setInputText(str) @@ -89,14 +95,15 @@ function VehicleCombobox({ disabled={disabled} onInputValueChange={(text, { reason }) => { if (reason === "input-change") { - setInputText(text) - onChange(text) + const next = normalizeTextValue(text) + setInputText(next) + onChange(next) } }} > 0} onBlur={onBlur} aria-invalid={!!error || undefined} @@ -307,7 +314,6 @@ export function RhfVehicleIdentityField() { !!val?.value, "Shop type is required"), + vehicle_body_type_id: relationFieldSchema.refine((val) => !!val?.value, "Vehicle body type is required"), vehicle_fuel_type_id: relationFieldSchema, vehicle_transmission_id: relationFieldSchema, vehicle_color_id: relationFieldSchema, customer_id: relationFieldSchema, // ── Vehicle identity ── - make: z.string().optional(), - model: z.string().optional(), + make: z.string().min(1, "Make is required").max(50, "Make must be at most 50 characters"), + model: z.string().min(1, "Model is required").max(50, "Model must be at most 50 characters"), year: z.string().optional(), sub_model: z.string().optional(), // ── License & identifiers ── - license_plate: z.string().optional(), + license_plate: z.string().min(1, "License plate is required").max(50, "License plate must be at most 50 characters"), vin_number: z.string().optional(), // ── Technical specs ── diff --git a/apps/dashboard/modules/vendors/vendor-form.tsx b/apps/dashboard/modules/vendors/vendor-form.tsx index 8019f53..d3bef4a 100644 --- a/apps/dashboard/modules/vendors/vendor-form.tsx +++ b/apps/dashboard/modules/vendors/vendor-form.tsx @@ -107,11 +107,11 @@ export function VendorForm({ resourceId, initialData, onSuccess }: VendorFormPro
- +
- - + +