update forms

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Mohammad Khyata 2026-05-07 21:02:15 +03:00
parent dd32658500
commit 38565298fc
32 changed files with 797 additions and 236 deletions

View File

@ -74,6 +74,7 @@ Create `apps/dashboard/modules/<feature>/<feature>-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
<RhfTextField name="title" label="Title" required />
<RhfCustomerSelectField name="customer" required />
<RhfVehicleSelectField name="vehicle" required customer_id={customer?.value} />
```
### Imports Cheat Sheet
```tsx

View File

@ -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 }:
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<DropdownMenuItem onClick={() => editDialog.open(appointmentId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
@ -120,5 +121,21 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }:
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl lg:min-w-4xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Appointment</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<AppointmentForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</> )
}

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(billId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl lg:min-w-4xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Bill</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<BillForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -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)
<CardContent>
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
<RhfDateField name="bill_date" label="Bill Date" />
<RhfDateField name="bill_date" label="Bill Date" required />
<RhfDateField name="bill_due_date" label="Due Date" />
</div>

View File

@ -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(),

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(creditNoteId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl lg:min-w-4xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Credit Note</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<CreditNoteForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -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
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="date" label="Date" type="date" />
<RhfDateField name="date" label="Date" required />
<RhfSelectField
name="status"

View File

@ -1,21 +1,28 @@
import { z } from "zod"
import { CreditNoteStatus } from "@garage/api"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const optionalStringMax255Schema = z.preprocess(
(value) => (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<typeof creditNoteFormSchema>

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(customerId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl lg:min-w-4xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Customer</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<CustomerForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -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 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
required
name="salutation"
label="Salutation"
placeholder="Select salutation"
@ -201,12 +211,19 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
{/* Contact */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" />
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" required />
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
</div>
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="0551234567" type="tel" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="opening_balance" label="Opening Balance" placeholder="0" type="number" />
<RhfTextField name="credit_limit" label="Credit Limit" placeholder="0" type="number" />
</div>
<RhfTextField name="website" label="Website" placeholder="https://example.com" type="url" />
{/* Relations */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField

View File

@ -10,6 +10,30 @@ const relationFieldSchema = z
type RelationField = z.infer<typeof relationFieldSchema>
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<typeof customerFormSchema>

View File

@ -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<any>
? api.estimates.update(resourceId, payload as any)
: api.estimates.create(payload as any)) as Promise<any>
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
<RhfTextField name="title" label="Title" placeholder="Estimate title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
<RhfCustomerSelectField name="customer" required />
<RhfVehicleSelectField name="vehicle" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
@ -164,11 +201,39 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
{...STORE_OBJECT}
/>
<RhfDateField name="date" label="Date" />
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" />
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" required />
</div>
<RhfCheckboxField name="has_insurance" label="Has Insurance" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfAsyncSelectField
name="insurance_type"
label="Insurance Type"
placeholder="Select insurance type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
listFn={() => api.insuranceTypes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfCustomerSelectField
name="insurer"
label="Insurer"
placeholder="Search insurer..."
customerType="Insurer"
/>
<RhfEmployeeSelectField name="service_writer" label="Service Writer" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField name="discount" label="Discount" options={DISCOUNT_OPTIONS} />
<RhfTextField name="discount_amount" label="Discount Amount" type="number" placeholder="0.00" />
</div>
<RhfCheckboxField name="enable_digital_authorisation" label="Enable Digital Authorisation" />
<RhfTextareaField name="footer" label="Footer" placeholder="Footer notes for this estimate" />
<RhfCustomerRemarksField name="remarks" />
<Button type="submit" variant="default" disabled={isPending}>

View File

@ -1,22 +1,49 @@
import { z } from "zod"
import { DiscountType } from "@garage/api"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => {
if (!value) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "This field is required",
})
}
})
const optionalNumberSchema = z.preprocess(
(value) => (value === "" || value == null ? undefined : value),
z.coerce.number().min(0).optional(),
)
const optionalDateSchema = z.preprocess(
(value) => (value === "" || value == null ? undefined : value),
z.string().date().optional(),
)
const estimateFormSchema = z.object({
// ── Required fields ──
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 ──
customer: relationFieldSchema,
vehicle: relationFieldSchema,
department: relationFieldSchema,
customer: requiredRelationFieldSchema,
vehicle: requiredRelationFieldSchema,
department: requiredRelationFieldSchema,
insurance_type: relationFieldSchema,
insurer: relationFieldSchema,
service_writer: relationFieldSchema,
// ── Optional fields ──
estimate_number: z.string().optional(),
date: z.string().optional(),
has_insurance: z.boolean().default(false),
estimate_number: z.string().trim().min(1, "Estimate number is required").max(255, "Estimate number must be at most 255 characters"),
date: optionalDateSchema,
has_insurance: z.boolean().optional(),
enable_digital_authorisation: z.boolean().optional(),
footer: z.string().optional(),
discount: z.enum(DiscountType).optional(),
discount_amount: optionalNumberSchema,
// ── Remarks (array of strings) ──
remarks: z.array(z.string()).optional(),

View File

@ -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 { ExpenseForm } from "./expense-form"
type ExpenseActionsProps = {
expenseId: string
@ -18,10 +22,7 @@ type ExpenseActionsProps = {
export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/purchase/expense/${expenseId}/edit`)
}
const editDialog = useFormDialog("expense-details-edit")
const handleDelete = async () => {
await api.expenses.destroy(expenseId)
@ -29,22 +30,41 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(expenseId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl lg:min-w-4xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Expense</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<ExpenseForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -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) => ({

View File

@ -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(),

View File

@ -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<string, { next: string; label: string; icon: ty
export function InspectionActions({ inspectionId, status, onStatusChange }: InspectionActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
{transition && (
<DropdownMenuItem onClick={() => handleStatusChange(transition.next)}>
<transition.icon className="size-4" />
{transition.label}
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(inspectionId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{transition && (
<DropdownMenuItem onClick={() => handleStatusChange(transition.next)}>
<transition.icon className="size-4" />
{transition.label}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl lg:min-w-4xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Inspection</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<InspectionForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -302,7 +302,7 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
<RhfTextField name="subject" label="Subject" placeholder="Invoice subject" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAutoGenerateField autoFetch table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" />
<RhfAutoGenerateField autoFetch table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" required />
<RhfTextField name="invoice_title" label="Invoice Title" placeholder="e.g. Tax Invoice" />
</div>
@ -338,8 +338,8 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
<CardContent>
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
<RhfDateField name="invoice_date" label="Invoice Date" />
<RhfDateField name="due_date" label="Due Date" />
<RhfDateField name="invoice_date" label="Invoice Date" required />
<RhfDateField name="due_date" label="Due Date" required />
</div>
<RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" />

View File

@ -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<typeof invoiceFormSchema>

View File

@ -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
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" disabled={!customer?.value} customer_id={customer?.value} />
<RhfCustomerSelectField required name="customer" />
<RhfVehicleSelectField required name="vehicle" disabled={!customer?.value} customer_id={customer?.value} />
</div>
<RhfCheckboxField name="has_insurance" label="Has Insurance Work?" />

View File

@ -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<typeof jobCardFormSchema>

View File

@ -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}

View File

@ -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<typeof paymentMadeFormSchema>

View File

@ -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}

View File

@ -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<typeof paymentReceivedFormSchema>

View File

@ -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<PurchaseOrderFormValues>()
const discount = watch("discount_type")
if (discount !== "transaction_level") return null
return (
<RhfTextField
name="discount_amount"
label="Discount Amount"
type="number"
placeholder="0.00"
/>
)
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
@ -156,17 +191,27 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" />
<RhfAutoGenerateField autoFetch table="purchase-order" name="order_number" label="Order Number" placeholder="Enter purchase order number" required />
<RhfDateField name="order_date" label="Order Date" />
<RhfDateField name="delivery_date" label="Delivery Date" />
</div>
<RhfSelectField
name="discount_type"
label="Discount Type"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
<TransactionDiscountField />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
required
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => 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
/>
<RhfTextareaField name="notes" label="Notes" rows={3} />
<RhfTextareaField name="terms_and_conditions" label="Terms & Conditions" rows={3} />
<PartsSelectorField<PurchaseOrderFormValues, "items"> name="items" />

View File

@ -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(),

View File

@ -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({
<div ref={anchorRef}>
<Combobox
value={inputText || null}
value={normalizeTextValue(inputText) || null}
onValueChange={(val) => {
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)
}
}}
>
<ComboboxInput
placeholder={placeholder}
showClear={!!inputText}
showClear={normalizeTextValue(inputText).length > 0}
onBlur={onBlur}
aria-invalid={!!error || undefined}
@ -307,7 +314,6 @@ export function RhfVehicleIdentityField() {
<VehicleCombobox
label="Year"
placeholder="e.g. 2024"
required
value={yearValue}
onChange={handleYearChange}
onBlur={yearCtrl.field.onBlur}

View File

@ -169,6 +169,7 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
{/* Associations */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
required
name="shop_type_id"
label="Shop Type"
placeholder="Select shop type"
@ -180,6 +181,7 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
required
name="vehicle_body_type_id"
label="Body Type"
placeholder="Select body type"
@ -249,6 +251,7 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
{/* License & identifiers */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
required
name="license_plate"
label="License Plate"
placeholder="e.g. ABC-123"

View File

@ -6,20 +6,20 @@ export const relationFieldSchema = z
export const vehicleFormSchema = z.object({
// ── Relations ──
shop_type_id: relationFieldSchema,
vehicle_body_type_id: relationFieldSchema,
shop_type_id: relationFieldSchema.refine((val) => !!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 ──

View File

@ -107,11 +107,11 @@ export function VendorForm({ resourceId, initialData, onSuccess }: VendorFormPro
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
<RhfTextField name="last_name" label="Last Name" placeholder="Doe" />
<RhfTextField name="last_name" label="Last Name" placeholder="Doe" required />
</div>
<RhfTextField name="company_name" label="Company Name" placeholder="Acme Supplies" />
<RhfTextField name="email" label="Email" placeholder="vendor@example.com" type="email" />
<RhfTextField name="company_name" label="Company Name" placeholder="Acme Supplies" required />
<RhfTextField name="email" label="Email" placeholder="vendor@example.com" type="email" required />
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}

View File

@ -1,12 +1,27 @@
import { z } from "zod"
const optionalStringMaxSchema = (max: number) =>
z.preprocess(
(value) => (value === "" || value == null ? undefined : value),
z.string().max(max, `Must be at most ${max} characters`).optional(),
)
const optionalNumberSchema = z.preprocess(
(value) => (value === "" || value == null ? undefined : value),
z.coerce.number().int().optional(),
)
const vendorFormSchema = z.object({
first_name: z.string().min(1, "First name is required"),
last_name: z.string().optional(),
company_name: z.string().optional(),
email: z
.union([z.string().email("Enter a valid email address"), z.literal("")])
.optional(),
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"),
company_name: z.string().trim().min(1, "Company name is required").max(50, "Company 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"),
salutation: optionalStringMaxSchema(50),
phone: optionalStringMaxSchema(20),
alternate_phone: optionalStringMaxSchema(20),
opening_balance: optionalNumberSchema,
credit_limit: optionalNumberSchema,
website: optionalStringMaxSchema(255),
})
type VendorFormValues = z.infer<typeof vendorFormSchema>