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 5. Use `useFormMutation()` for submit with automatic validation error mapping
6. Render with `Rhform` + `RhfTextField` / `RhfSelectField` / `RhfAsyncSelectField` etc. 6. Render with `Rhform` + `RhfTextField` / `RhfSelectField` / `RhfAsyncSelectField` etc.
7. Include error alert, submit button with loading/edit states 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 ### 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 | | `RhfDateField` | Date picker — see date-time-pickers skill |
| `RhfTimeField` | Time 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 ### Imports Cheat Sheet
```tsx ```tsx

View File

@ -4,6 +4,8 @@ import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { Button } from "@/shared/components/ui/button" 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -15,6 +17,8 @@ import { Ellipsis, Pencil, Trash2, CheckCircle, Unlink } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { APPOINTMENT_ROUTES } from "@garage/api" import { APPOINTMENT_ROUTES } from "@garage/api"
import { useFormDialog } from "@/shared/components/form-dialog"
import { AppointmentForm } from "./appointment-form"
type AppointmentActionsProps = { type AppointmentActionsProps = {
appointmentId: string appointmentId: string
@ -27,10 +31,7 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }:
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const editDialog = useFormDialog("appointment-details-edit")
const handleEdit = () => {
router.push(`/calendar/appointment/${appointmentId}/edit`)
}
const handleDelete = async () => { const handleDelete = async () => {
setIsLoading(true) setIsLoading(true)
@ -82,7 +83,7 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }:
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> <DropdownMenuItem onClick={() => editDialog.open(appointmentId)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@ -120,5 +121,21 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }:
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button" 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -10,6 +12,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu" } from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2 } from "lucide-react" import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useFormDialog } from "@/shared/components/form-dialog"
import { BillForm } from "./bill-form"
type BillActionsProps = { type BillActionsProps = {
billId: string billId: string
@ -18,10 +22,7 @@ type BillActionsProps = {
export function BillActions({ billId }: BillActionsProps) { export function BillActions({ billId }: BillActionsProps) {
const api = useAuthApi() const api = useAuthApi()
const router = useRouter() const router = useRouter()
const editDialog = useFormDialog("bill-details-edit")
const handleEdit = () => {
router.push(`/purchase/bill/${billId}/edit`)
}
const handleDelete = async () => { const handleDelete = async () => {
await api.bills.destroy(billId) await api.bills.destroy(billId)
@ -29,6 +30,7 @@ export function BillActions({ billId }: BillActionsProps) {
} }
return ( return (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@ -36,7 +38,7 @@ export function BillActions({ billId }: BillActionsProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> <DropdownMenuItem onClick={() => editDialog.open(billId)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@ -46,5 +48,23 @@ export function BillActions({ billId }: BillActionsProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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: "no",
discount_amount: undefined, discount_amount: undefined,
notes: "", notes: "",
label_ids: [],
part_items: [], part_items: [],
service_items: [], service_items: [],
expense_items: [], expense_items: [],
@ -102,6 +103,7 @@ function mapToFormValues(data: unknown): BillFormValues {
discount: d.discount_type || "no", discount: d.discount_type || "no",
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined, discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
notes: d.notes || "", 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_items: (d.parts ?? []).map((p: any) => ({
part_id: p.part_id ?? p.id, part_id: p.part_id ?? p.id,
title: p.part?.name ?? p.part_name ?? p.title ?? "", title: p.part?.name ?? p.part_name ?? p.title ?? "",
@ -146,6 +148,7 @@ function mapFormToPayload(values: BillFormValues) {
discount_type: values.discount || undefined, discount_type: values.discount || undefined,
discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined, discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
notes: values.notes || undefined, notes: values.notes || undefined,
label_ids: values.label_ids?.length ? values.label_ids : undefined,
part_items: (values.part_items ?? []).map((item) => ({ part_items: (values.part_items ?? []).map((item) => ({
part_id: item.part_id, part_id: item.part_id,
quantity: item.quantity, quantity: item.quantity,
@ -276,7 +279,7 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
<CardContent> <CardContent>
<FieldGroup> <FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1"> <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" /> <RhfDateField name="bill_due_date" label="Due Date" />
</div> </div>

View File

@ -1,9 +1,25 @@
import { z } from "zod" import { z } from "zod"
import { BillStatus, DiscountType } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const 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({ const billPartItemSchema = z.object({
part_id: z.number(), part_id: z.number(),
title: z.string(), title: z.string(),
@ -33,7 +49,7 @@ const billExpenseItemSchema = z.object({
const billFormSchema = z.object({ const billFormSchema = z.object({
// ── Required ── // ── 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 ── // ── Relations ──
vendor: relationFieldSchema, vendor: relationFieldSchema,
@ -45,13 +61,14 @@ const billFormSchema = z.object({
tax: relationFieldSchema, tax: relationFieldSchema,
// ── Optional fields ── // ── Optional fields ──
bill_number: z.string().optional(), bill_number: optionalStringMax255Schema,
bill_date: z.string().optional(), bill_date: requiredDateSchema,
bill_due_date: z.string().optional(), bill_due_date: optionalDateSchema,
status: z.string().optional(), status: z.enum(BillStatus).optional(),
discount: z.string().optional(), discount: z.enum(DiscountType).optional(),
discount_amount: z.coerce.number().min(0).optional(), discount_amount: z.coerce.number().min(0).optional(),
notes: z.string().optional(), notes: z.string().optional(),
label_ids: z.array(z.number()).optional(),
// ── Line items ── // ── Line items ──
part_items: z.array(billPartItemSchema).optional(), part_items: z.array(billPartItemSchema).optional(),

View File

@ -3,6 +3,8 @@
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button" 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -10,6 +12,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu" } from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2 } from "lucide-react" import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useFormDialog } from "@/shared/components/form-dialog"
import { CreditNoteForm } from "./credit-note-form"
type CreditNoteActionsProps = { type CreditNoteActionsProps = {
creditNoteId: string creditNoteId: string
@ -18,10 +22,7 @@ type CreditNoteActionsProps = {
export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) { export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
const api = useAuthApi() const api = useAuthApi()
const router = useRouter() const router = useRouter()
const editDialog = useFormDialog("credit-note-details-edit")
const handleEdit = () => {
router.push(`/sales/credit-notes/${creditNoteId}/edit`)
}
const handleDelete = async () => { const handleDelete = async () => {
await api.creditNotes.destroy(creditNoteId) await api.creditNotes.destroy(creditNoteId)
@ -29,6 +30,7 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
} }
return ( return (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@ -36,7 +38,7 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> <DropdownMenuItem onClick={() => editDialog.open(creditNoteId)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@ -46,5 +48,23 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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, RhfSelectField,
RhfTextareaField, RhfTextareaField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfDateField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
@ -150,7 +151,7 @@ export function CreditNoteForm({ resourceId, initialData, onSuccess }: CreditNot
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="date" label="Date" type="date" /> <RhfDateField name="date" label="Date" required />
<RhfSelectField <RhfSelectField
name="status" name="status"

View File

@ -1,21 +1,28 @@
import { z } from "zod" import { z } from "zod"
import { CreditNoteStatus } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const optionalStringMax255Schema = z.preprocess(
(value) => (value === "" || value == null ? undefined : value),
z.string().max(255, "Must be at most 255 characters").optional(),
)
const creditNoteFormSchema = z.object({ const creditNoteFormSchema = z.object({
// ── Required fields ── // ── 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 ── // ── Relations ──
customer: relationFieldSchema, customer: relationFieldSchema,
department: relationFieldSchema, department: relationFieldSchema,
// ── Optional fields ── // ── Optional fields ──
date: z.string().optional(), date: z.string().min(1, "Date is required").date("Enter a valid date"),
status: z.string().optional(), status: z.enum(CreditNoteStatus).optional(),
notes: z.string().optional(), notes: z.string().optional(),
credit_invoice: optionalStringMax255Schema,
}) })
type CreditNoteFormValues = z.infer<typeof creditNoteFormSchema> type CreditNoteFormValues = z.infer<typeof creditNoteFormSchema>

View File

@ -3,6 +3,8 @@
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button" 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -12,6 +14,8 @@ import {
import { Ellipsis, Pencil, Trash2 } from "lucide-react" import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { confirm } from "@/shared/components/confirm-dialog" import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner" import { toast } from "sonner"
import { useFormDialog } from "@/shared/components/form-dialog"
import { CustomerForm } from "./customer-form"
type CustomerActionsProps = { type CustomerActionsProps = {
customerId: string customerId: string
@ -20,10 +24,7 @@ type CustomerActionsProps = {
export function CustomerActions({ customerId }: CustomerActionsProps) { export function CustomerActions({ customerId }: CustomerActionsProps) {
const api = useAuthApi() const api = useAuthApi()
const router = useRouter() const router = useRouter()
const editDialog = useFormDialog("customer-details-edit")
const handleEdit = () => {
router.push(`/sales/customers/${customerId}/edit`)
}
const handleDelete = async () => { const handleDelete = async () => {
const confirmed = await confirm({ const confirmed = await confirm({
@ -44,6 +45,7 @@ export function CustomerActions({ customerId }: CustomerActionsProps) {
} }
return ( return (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@ -51,7 +53,7 @@ export function CustomerActions({ customerId }: CustomerActionsProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> <DropdownMenuItem onClick={() => editDialog.open(customerId)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@ -61,5 +63,23 @@ export function CustomerActions({ customerId }: CustomerActionsProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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, payment_terms: null,
country: null, country: null,
state: null, state: null,
salutation: "", salutation: "Mr.",
first_name: "", first_name: "",
last_name: "", last_name: "",
company_name: "", company_name: "",
email: "", email: "",
phone: "", phone: "",
alternate_phone: "", alternate_phone: "",
opening_balance: undefined,
credit_limit: undefined,
website: "",
address_line_1: "", address_line_1: "",
address_line_2: "", address_line_2: "",
city: "", city: "",
@ -82,6 +85,9 @@ function mapCustomerToFormValues(data: unknown): CustomerFormValues {
email: c.email || "", email: c.email || "",
phone: c.phone || "", phone: c.phone || "",
alternate_phone: c.alternate_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_1: c.address_line_1 || "",
address_line_2: c.address_line_2 || "", address_line_2: c.address_line_2 || "",
city: c.city || "", city: c.city || "",
@ -96,13 +102,16 @@ function mapFormToPayload(values: CustomerFormValues) {
payment_terms_id: toId(values.payment_terms), payment_terms_id: toId(values.payment_terms),
country_id: toId(values.country), country_id: toId(values.country),
state_id: toId(values.state), state_id: toId(values.state),
salutation: values.salutation || undefined, salutation: values.salutation,
first_name: values.first_name, first_name: values.first_name,
last_name: values.last_name, last_name: values.last_name,
company_name: values.company_name || undefined, company_name: values.company_name || undefined,
email: values.email || undefined, email: values.email,
phone: values.phone || undefined, phone: values.phone || undefined,
alternate_phone: values.alternate_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_1: values.address_line_1 || undefined,
address_line_2: values.address_line_2 || undefined, address_line_2: values.address_line_2 || undefined,
city: values.city || undefined, city: values.city || undefined,
@ -169,6 +178,7 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
{/* Basic Info */} {/* Basic Info */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField <RhfSelectField
required
name="salutation" name="salutation"
label="Salutation" label="Salutation"
placeholder="Select salutation" placeholder="Select salutation"
@ -201,12 +211,19 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
{/* Contact */} {/* Contact */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" placeholder="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" /> <RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
</div> </div>
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="0551234567" type="tel" /> <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 */} {/* Relations */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField

View File

@ -10,6 +10,30 @@ const relationFieldSchema = z
type RelationField = z.infer<typeof relationFieldSchema> 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({ const customerFormSchema = z.object({
// ── Relations (stored as objects, mapped to IDs on submit) ── // ── Relations (stored as objects, mapped to IDs on submit) ──
customer_type: relationFieldSchema.refine((val) => !!val?.value, "Customer type is required"), customer_type: relationFieldSchema.refine((val) => !!val?.value, "Customer type is required"),
@ -19,23 +43,32 @@ const customerFormSchema = z.object({
state: relationFieldSchema, state: relationFieldSchema,
// ── Basic info ── // ── Basic info ──
salutation: z.string().optional(), salutation: z.string().trim().min(1, "Salutation is required").max(50, "Salutation cannot exceed 50 characters"),
first_name: z.string().min(1, "First name is required"), first_name: z.string().trim().min(1, "First name is required").max(50, "First name cannot exceed 50 characters"),
last_name: z.string().min(1, "Last name is required"), last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name cannot exceed 50 characters"),
company_name: z.string().optional(), company_name: optionalTrimmedString(50),
// ── Contact ── // ── Contact ──
email: z email: z
.union([z.string().email("Enter a valid email address"), z.literal("")]) .string()
.optional(), .trim()
phone: z.string().optional(), .min(1, "Email is required")
alternate_phone: z.string().optional(), .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 ──
address_line_1: z.string().optional(), address_line_1: optionalTrimmedString(255),
address_line_2: z.string().optional(), address_line_2: optionalTrimmedString(255),
city: z.string().optional(), city: optionalTrimmedString(100),
zip_code: z.string().optional(), zip_code: optionalTrimmedString(20),
}) })
type CustomerFormValues = z.infer<typeof customerFormSchema> type CustomerFormValues = z.infer<typeof customerFormSchema>

View File

@ -11,6 +11,8 @@ import {
RhfAsyncSelectField, RhfAsyncSelectField,
RhfDateField, RhfDateField,
RhfAutoGenerateField, RhfAutoGenerateField,
RhfSelectField,
RhfTextareaField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
@ -22,11 +24,12 @@ import {
estimateFormSchema, estimateFormSchema,
type EstimateFormValues, type EstimateFormValues,
} from "./estimate.schema" } 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 { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field" import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfLabelPickerField, type LabelItem } from "@/modules/labels/rhf-label-picker-field" import { RhfLabelPickerField, type LabelItem } from "@/modules/labels/rhf-label-picker-field"
import { RhfCustomerRemarksField } from "./rhf-customer-remarks-field" import { RhfCustomerRemarksField } from "./rhf-customer-remarks-field"
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
// ── Props ── // ── Props ──
@ -44,9 +47,16 @@ const DEFAULT_VALUES: EstimateFormValues = {
customer: null, customer: null,
vehicle: null, vehicle: null,
department: null, department: null,
insurance_type: null,
insurer: null,
service_writer: null,
estimate_number: "", estimate_number: "",
date: "", date: "",
has_insurance: false, has_insurance: false,
enable_digital_authorisation: false,
footer: "",
discount: "no",
discount_amount: undefined,
remarks: [], remarks: [],
labels: [], labels: [],
} }
@ -61,9 +71,24 @@ function mapToFormValues(data: unknown): EstimateFormValues {
customer: toRelation(d.customer_id, d.customer_name), customer: toRelation(d.customer_id, d.customer_name),
vehicle: toRelation(d.vehicle_id, d.vehicle_name), vehicle: toRelation(d.vehicle_id, d.vehicle_name),
department: toRelation(d.department_id, d.department_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 || "", estimate_number: d.estimate_number || "",
date: d.date ? d.date.split("T")[0] : "", date: d.date ? d.date.split("T")[0] : "",
has_insurance: d.has_insurance ?? false, 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) remarks: Array.isArray(d.remarks)
? d.remarks.map((r: any) => (typeof r === "string" ? r : (r?.remark ?? ""))).filter(Boolean) ? 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), customer_id: toId(values.customer),
vehicle_id: toId(values.vehicle), vehicle_id: toId(values.vehicle),
department_id: toId(values.department), 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, estimate_number: values.estimate_number || undefined,
date: values.date || undefined, date: values.date || undefined,
has_insurance: values.has_insurance, 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) ?? [], remarks: values.remarks?.filter(Boolean) ?? [],
label_ids: values.labels?.map((l) => l.id) ?? [], 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 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 ── // ── Component ──
export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFormProps) { export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFormProps) {
@ -116,8 +153,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
mutationFn: (values: EstimateFormValues) => { mutationFn: (values: EstimateFormValues) => {
const payload = mapFormToPayload(values) const payload = mapFormToPayload(values)
const promise = (isEditing && resourceId const promise = (isEditing && resourceId
? api.estimates.update(resourceId, payload) ? api.estimates.update(resourceId, payload as any)
: api.estimates.create(payload)) as Promise<any> : api.estimates.create(payload as any)) as Promise<any>
toast.promise(promise, { toast.promise(promise, {
loading: isEditing ? "Updating estimate..." : "Creating estimate...", loading: isEditing ? "Updating estimate..." : "Creating estimate...",
success: isEditing ? "Estimate updated successfully" : "Estimate created successfully", 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 /> <RhfTextField name="title" label="Title" placeholder="Estimate title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" /> <RhfCustomerSelectField name="customer" required />
<RhfVehicleSelectField name="vehicle" /> <RhfVehicleSelectField name="vehicle" required />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <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} {...STORE_OBJECT}
/> />
<RhfDateField name="date" label="Date" /> <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> </div>
<RhfCheckboxField name="has_insurance" label="Has Insurance" /> <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" /> <RhfCustomerRemarksField name="remarks" />
<Button type="submit" variant="default" disabled={isPending}> <Button type="submit" variant="default" disabled={isPending}>

View File

@ -1,22 +1,49 @@
import { z } from "zod" import { z } from "zod"
import { DiscountType } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const 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({ const estimateFormSchema = z.object({
// ── Required fields ── // ── 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 ── // ── Relations ──
customer: relationFieldSchema, customer: requiredRelationFieldSchema,
vehicle: relationFieldSchema, vehicle: requiredRelationFieldSchema,
department: relationFieldSchema, department: requiredRelationFieldSchema,
insurance_type: relationFieldSchema,
insurer: relationFieldSchema,
service_writer: relationFieldSchema,
// ── Optional fields ── // ── Optional fields ──
estimate_number: z.string().optional(), estimate_number: z.string().trim().min(1, "Estimate number is required").max(255, "Estimate number must be at most 255 characters"),
date: z.string().optional(), date: optionalDateSchema,
has_insurance: z.boolean().default(false), 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 (array of strings) ──
remarks: z.array(z.string()).optional(), remarks: z.array(z.string()).optional(),

View File

@ -3,6 +3,8 @@
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button" 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -10,6 +12,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu" } from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2 } from "lucide-react" import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useFormDialog } from "@/shared/components/form-dialog"
import { ExpenseForm } from "./expense-form"
type ExpenseActionsProps = { type ExpenseActionsProps = {
expenseId: string expenseId: string
@ -18,10 +22,7 @@ type ExpenseActionsProps = {
export function ExpenseActions({ expenseId }: ExpenseActionsProps) { export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
const api = useAuthApi() const api = useAuthApi()
const router = useRouter() const router = useRouter()
const editDialog = useFormDialog("expense-details-edit")
const handleEdit = () => {
router.push(`/purchase/expense/${expenseId}/edit`)
}
const handleDelete = async () => { const handleDelete = async () => {
await api.expenses.destroy(expenseId) await api.expenses.destroy(expenseId)
@ -29,6 +30,7 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
} }
return ( return (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@ -36,7 +38,7 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> <DropdownMenuItem onClick={() => editDialog.open(expenseId)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@ -46,5 +48,23 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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" name="job_card"
label="Job Card" label="Job Card"
placeholder="Select job card" placeholder="Select job card"
required
queryKey={[JOB_CARD_ROUTES.INDEX]} queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()} listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({ mapOption={(item: any) => ({

View File

@ -1,9 +1,29 @@
import { z } from "zod" import { z } from "zod"
import { ExpenseStatus, InvoiceDiscount } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const 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({ const labelItemSchema = z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
@ -22,21 +42,22 @@ const expenseLineItemSchema = z.object({
const expenseFormSchema = z.object({ const expenseFormSchema = z.object({
// ── Relations ── // ── Relations ──
job_card: relationFieldSchema, job_card: requiredRelationFieldSchema,
category: relationFieldSchema, category: relationFieldSchema,
vendor: relationFieldSchema, vendor: relationFieldSchema,
department: relationFieldSchema, department: relationFieldSchema,
tax: relationFieldSchema, tax: relationFieldSchema,
// ── Basic info ── // ── Basic info ──
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"),
invoice_number: z.string().optional(), invoice_number: optionalStringMax255Schema,
expense_date: z.string().optional(), expense_date: optionalDateSchema,
notes: z.string().optional(), notes: z.string().optional(),
status: z.string().optional(), status: z.enum(ExpenseStatus).optional(),
paid_through: z.coerce.number().int().optional(),
// ── Discount / Tax ── // ── Discount / Tax ──
discount: z.string().optional(), discount: z.enum(InvoiceDiscount).optional(),
discount_amount: z.coerce.number().min(0).optional(), discount_amount: z.coerce.number().min(0).optional(),
labels: z.array(labelItemSchema).optional(), labels: z.array(labelItemSchema).optional(),

View File

@ -3,6 +3,8 @@
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button" 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -12,6 +14,8 @@ import {
} from "@/shared/components/ui/dropdown-menu" } from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2, Play, CheckCircle2 } from "lucide-react" import { Ellipsis, Pencil, Trash2, Play, CheckCircle2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { useFormDialog } from "@/shared/components/form-dialog"
import { InspectionForm } from "./inspection-form"
type InspectionActionsProps = { type InspectionActionsProps = {
inspectionId: string inspectionId: string
@ -27,10 +31,7 @@ const STATUS_TRANSITIONS: Record<string, { next: string; label: string; icon: ty
export function InspectionActions({ inspectionId, status, onStatusChange }: InspectionActionsProps) { export function InspectionActions({ inspectionId, status, onStatusChange }: InspectionActionsProps) {
const api = useAuthApi() const api = useAuthApi()
const router = useRouter() const router = useRouter()
const editDialog = useFormDialog("inspection-details-edit")
const handleEdit = () => {
router.push(`/sales/inspections/${inspectionId}/edit`)
}
const handleDelete = async () => { const handleDelete = async () => {
const promise = api.inspections.destroy(inspectionId) const promise = api.inspections.destroy(inspectionId)
@ -60,6 +61,7 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
const transition = status ? STATUS_TRANSITIONS[status] : undefined const transition = status ? STATUS_TRANSITIONS[status] : undefined
return ( return (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@ -67,7 +69,7 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> <DropdownMenuItem onClick={() => editDialog.open(inspectionId)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@ -84,5 +86,23 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 /> <RhfTextField name="subject" label="Subject" placeholder="Invoice subject" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <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" /> <RhfTextField name="invoice_title" label="Invoice Title" placeholder="e.g. Tax Invoice" />
</div> </div>
@ -338,8 +338,8 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
<CardContent> <CardContent>
<FieldGroup> <FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1"> <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="invoice_date" label="Invoice Date" required />
<RhfDateField name="due_date" label="Due Date" /> <RhfDateField name="due_date" label="Due Date" required />
</div> </div>
<RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" /> <RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" />

View File

@ -1,9 +1,15 @@
import { z } from "zod" import { z } from "zod"
import { InvoiceDiscount, InvoiceStatus } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const requiredDateSchema = z
.string()
.min(1, "This field is required")
.date("Enter a valid date")
const invoicePartItemSchema = z.object({ const invoicePartItemSchema = z.object({
part_id: z.number(), part_id: z.number(),
title: z.string(), title: z.string(),
@ -33,7 +39,7 @@ const invoiceExpenseItemSchema = z.object({
const invoiceFormSchema = z.object({ const invoiceFormSchema = z.object({
// ── Required fields ── // ── 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 ── // ── Relations ──
customer: relationFieldSchema, customer: relationFieldSchema,
@ -47,14 +53,14 @@ const invoiceFormSchema = z.object({
invoice_to: relationFieldSchema, invoice_to: relationFieldSchema,
// ── Optional fields ── // ── 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_title: z.string().optional(),
invoice_date: z.string().optional(), invoice_date: requiredDateSchema,
due_date: z.string().optional(), due_date: requiredDateSchema,
status: z.string().optional(), status: z.enum(InvoiceStatus).optional(),
kms_in: z.coerce.number().optional(), kms_in: z.coerce.number().optional(),
has_insurance: z.boolean().default(false), has_insurance: z.boolean().default(false),
discount: z.string().optional(), discount: z.enum(InvoiceDiscount).optional(),
discount_amount: z.coerce.number().min(0).optional(), discount_amount: z.coerce.number().min(0).optional(),
tax: relationFieldSchema, tax: relationFieldSchema,
deposit_to: z.string().optional(), deposit_to: z.string().optional(),
@ -65,6 +71,19 @@ const invoiceFormSchema = z.object({
parts: z.array(invoicePartItemSchema).optional(), parts: z.array(invoicePartItemSchema).optional(),
services: z.array(invoiceServiceItemSchema).optional(), services: z.array(invoiceServiceItemSchema).optional(),
expense_items: z.array(invoiceExpenseItemSchema).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> type InvoiceFormValues = z.infer<typeof invoiceFormSchema>

View File

@ -60,6 +60,7 @@ const DEFAULT_VALUES: JobCardFormValues = {
title: "", title: "",
customer: null, customer: null,
vehicle: null, vehicle: null,
estimate: null,
department: null, department: null,
service_writer: null, service_writer: null,
primary_technician: null, primary_technician: null,
@ -73,6 +74,7 @@ const DEFAULT_VALUES: JobCardFormValues = {
estimate_to: "Customer", estimate_to: "Customer",
tax_inclusive: "Tax Inclusive", tax_inclusive: "Tax Inclusive",
discount_type: "no", discount_type: "no",
discount_amount: undefined,
discount_at: "inclusive_of_tax", discount_at: "inclusive_of_tax",
order_date: new Date().toISOString().split("T")[0], order_date: new Date().toISOString().split("T")[0],
check_in_date: "", check_in_date: "",
@ -88,7 +90,11 @@ const DEFAULT_VALUES: JobCardFormValues = {
enable_parts_issuing: false, enable_parts_issuing: false,
enable_digital_authorisation: false, enable_digital_authorisation: false,
footer: "", footer: "",
attachments: "",
customer_remarks: [], customer_remarks: [],
documents: [],
attachment_files: [],
label_ids: [],
labels: [], labels: [],
} }
@ -111,6 +117,7 @@ function mapToFormValues(data: unknown): JobCardFormValues {
title: d.title || "", title: d.title || "",
customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined), 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), 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), 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), 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), 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", estimate_to: d.estimate_to || "Customer",
tax_inclusive: d.tax_inclusive || "Tax Inclusive", tax_inclusive: d.tax_inclusive || "Tax Inclusive",
discount_type: d.discount_type || "no", 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", discount_at: d.discount_at || "inclusive_of_tax",
order_date: mapDate(d.order_date), order_date: mapDate(d.order_date),
check_in_date: mapDate(d.check_in_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_parts_issuing: d.enable_parts_issuing ?? false,
enable_digital_authorisation: d.enable_digital_authorisation ?? false, enable_digital_authorisation: d.enable_digital_authorisation ?? false,
footer: d.footer || "", footer: d.footer || "",
attachments: d.attachments || "",
customer_remarks: Array.isArray(d.customer_remarks) customer_remarks: Array.isArray(d.customer_remarks)
? d.customer_remarks.map((r: any) => ? d.customer_remarks.map((r: any) =>
typeof r === "string" ? r : (r?.remark ?? "") typeof r === "string" ? r : (r?.remark ?? "")
).filter(Boolean) ).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) => ({ labels: (d.labels ?? []).map((l: any) => ({
id: l.id, id: l.id,
title: l.title, title: l.title,
@ -156,6 +172,7 @@ function mapFormToPayload(values: JobCardFormValues) {
title: values.title, title: values.title,
customer_id: toId(values.customer), customer_id: toId(values.customer),
vehicle_id: toId(values.vehicle), vehicle_id: toId(values.vehicle),
estimate_id: toId(values.estimate) ?? undefined,
department_id: toId(values.department), department_id: toId(values.department),
service_writer_id: toId(values.service_writer), service_writer_id: toId(values.service_writer),
primary_technician_id: toId(values.primary_technician), primary_technician_id: toId(values.primary_technician),
@ -169,6 +186,7 @@ function mapFormToPayload(values: JobCardFormValues) {
estimate_to: values.estimate_to || undefined, estimate_to: values.estimate_to || undefined,
tax_inclusive: values.tax_inclusive || undefined, tax_inclusive: values.tax_inclusive || undefined,
discount_type: values.discount_type || undefined, discount_type: values.discount_type || undefined,
discount_amount: values.discount_amount,
discount_at: values.discount_at || undefined, discount_at: values.discount_at || undefined,
order_date: values.order_date || undefined, order_date: values.order_date || undefined,
check_in_date: values.check_in_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_parts_issuing: values.enable_parts_issuing,
enable_digital_authorisation: values.enable_digital_authorisation, enable_digital_authorisation: values.enable_digital_authorisation,
footer: values.footer || undefined, footer: values.footer || undefined,
attachments: values.attachments || undefined,
customer_remarks: values.customer_remarks?.filter(Boolean) ?? [], 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 /> <RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" /> <RhfCustomerSelectField required name="customer" />
<RhfVehicleSelectField name="vehicle" disabled={!customer?.value} customer_id={customer?.value} /> <RhfVehicleSelectField required name="vehicle" disabled={!customer?.value} customer_id={customer?.value} />
</div> </div>
<RhfCheckboxField name="has_insurance" label="Has Insurance Work?" /> <RhfCheckboxField name="has_insurance" label="Has Insurance Work?" />

View File

@ -12,6 +12,19 @@ const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .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 ── // ── Job Card Statuses ──
export const JOB_CARD_STATUS_OPTIONS = JobCardStatus.map((v) => ({ export const JOB_CARD_STATUS_OPTIONS = JobCardStatus.map((v) => ({
@ -48,11 +61,12 @@ const FUEL_LEVEL_OPTIONS = FuelLevel.map((v) => ({
const jobCardFormSchema = z.object({ const jobCardFormSchema = z.object({
// ── Required fields ── // ── 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 ── // ── Relations ──
customer: relationFieldSchema, customer: relationFieldSchema,
vehicle: relationFieldSchema, vehicle: relationFieldSchema,
estimate: relationFieldSchema.optional(),
department: relationFieldSchema, department: relationFieldSchema,
service_writer: relationFieldSchema, service_writer: relationFieldSchema,
primary_technician: relationFieldSchema, primary_technician: relationFieldSchema,
@ -62,28 +76,29 @@ const jobCardFormSchema = z.object({
tax: relationFieldSchema, tax: relationFieldSchema,
// ── Numbers & identifiers ── // ── Numbers & identifiers ──
order_number: z.string().optional(), order_number: optionalStringMax255,
estimate_number: z.string().optional(), estimate_number: optionalStringMax255,
// ── Status & settings ── // ── Status & settings ──
status: z.string().optional(), status: z.enum(JobCardStatus).optional(),
estimate_to: z.string().optional(), estimate_to: z.enum(EstimateTo).optional(),
tax_inclusive: z.string().optional(), tax_inclusive: z.enum(TaxInclusive).optional(),
discount_type: z.string().optional(), discount_type: z.enum(DiscountType).optional(),
discount_at: z.string().optional(), discount_amount: z.coerce.number().min(0).optional(),
discount_at: z.enum(DiscountAt).optional(),
// ── Dates & times ── // ── Dates & times ──
order_date: z.string().optional(), order_date: z.string().optional(),
check_in_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_date: z.string().optional(),
start_time: z.string().optional(), start_time: optionalTimeString,
delivery_date: z.string().optional(), delivery_date: z.string().optional(),
delivery_time: z.string().optional(), delivery_time: optionalTimeString,
// ── Vehicle state ── // ── Vehicle state ──
km_in: z.string().optional(), km_in: z.union([z.string(), z.number()]).optional(),
fuel_level: z.string().optional(), fuel_level: optionalTimeString,
// ── Boolean options ── // ── Boolean options ──
has_insurance: z.boolean().optional(), has_insurance: z.boolean().optional(),
@ -92,9 +107,12 @@ const jobCardFormSchema = z.object({
// ── Notes ── // ── Notes ──
footer: z.string().optional(), footer: z.string().optional(),
attachments: z.string().optional(),
// ── Customer Remarks ── // ── Customer Remarks ──
customer_remarks: z.array(z.string()).optional(), 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 ──
labels: z labels: z
@ -106,6 +124,23 @@ const jobCardFormSchema = z.object({
}), }),
) )
.optional(), .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> type JobCardFormValues = z.infer<typeof jobCardFormSchema>

View File

@ -63,7 +63,7 @@ const DEFAULT_VALUES: PaymentMadeFormValues & { details: Array<{ bill_id?: strin
payment_date: getTodayDate(), payment_date: getTodayDate(),
paid_through: "", paid_through: "",
notes: "", notes: "",
details: [] details: [{ amount_paid: 0 }]
} }
// ── Mapping helpers ── // ── 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), vendor: toRelation(d.vendor_id, d.vendor?.company_name ?? d.vendor?.name ?? d.vendor_name),
employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : d.employee_name), employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : d.employee_name),
payment_mode: toRelation(paymentModeId, paymentModeLabel), payment_mode: toRelation(paymentModeId, paymentModeLabel),
payment_for: d.payment_for || "", payment_for: d.payment_for || "bill",
amount: d.payment_made ? String(d.payment_made) : "", amount: d.payment_made ? String(d.payment_made) : "",
payment_number: d.payment_number || "", payment_number: d.payment_number || "",
payment_reference: d.payment_reference || "", payment_reference: d.payment_reference || "",
payment_date: d.payment_date || "", payment_date: d.payment_date || "",
paid_through: d.paid_through || "-", paid_through: d.paid_through || "-",
notes: d.notes || "-", 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" name="payment_mode"
label="Payment Mode" label="Payment Mode"
placeholder="Select payment mode" placeholder="Select payment mode"
required
queryKey={[PAYMENT_MODE_ROUTES.INDEX]} queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
listFn={() => api.paymentModes.list()} listFn={() => api.paymentModes.list()}
mapOption={mapLookupOption} mapOption={mapLookupOption}

View File

@ -1,23 +1,61 @@
import { z } from "zod" import { z } from "zod"
import { PaymentFor } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const 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({ const paymentMadeFormSchema = z.object({
// ── Relations ── // ── Relations ──
vendor: relationFieldSchema, vendor: relationFieldSchema,
employee: relationFieldSchema, employee: relationFieldSchema,
payment_mode: relationFieldSchema, payment_mode: requiredRelationFieldSchema,
// ── Payment info ── // ── Payment info ──
amount: z.string().min(1, "Amount is required"), amount: z.coerce.number().min(0, "Amount must be 0 or more"),
payment_for: z.string().min(1, "Payment for is required"), payment_for: z.enum(PaymentFor),
payment_number: z.string().optional(), payment_number: optionalStringMax255Schema,
payment_reference: z.string().optional(), payment_reference: optionalStringMax255Schema,
payment_date: z.string().min(1, "Payment date is required"), payment_date: z.string().min(1, "Payment date is required").date("Enter a valid date"),
paid_through: z.string().optional(), paid_through: optionalStringMax255Schema,
notes: z.string().optional(), 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> type PaymentMadeFormValues = z.infer<typeof paymentMadeFormSchema>

View File

@ -157,6 +157,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
name="customer" name="customer"
label="Customer" label="Customer"
placeholder="Select customer" placeholder="Select customer"
required
queryKey={[CUSTOMER_ROUTES.INDEX]} queryKey={[CUSTOMER_ROUTES.INDEX]}
listFn={() => api.customers.list()} listFn={() => api.customers.list()}
mapOption={(item: any) => ({ mapOption={(item: any) => ({
@ -193,6 +194,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
name="payment_mode" name="payment_mode"
label="Payment Mode" label="Payment Mode"
placeholder="Select payment mode" placeholder="Select payment mode"
required
queryKey={[PAYMENT_MODE_ROUTES.INDEX]} queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
listFn={() => api.paymentModes.list()} listFn={() => api.paymentModes.list()}
mapOption={mapLookupOption} mapOption={mapLookupOption}

View File

@ -4,17 +4,38 @@ const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .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({ const paymentReceivedFormSchema = z.object({
// ── Relations ── // ── Relations ──
job_card: relationFieldSchema, job_card: relationFieldSchema,
payment_mode: relationFieldSchema, payment_mode: requiredRelationFieldSchema,
customer: relationFieldSchema, customer: requiredRelationFieldSchema,
// ── Payment info ── // ── Payment info ──
amount_received: z.string().min(1, "Amount is required"), amount_received: z.coerce.number().min(0, "Amount received must be 0 or more"),
payment_number: z.string().optional(), payment_number: optionalStringMax255Schema,
payment_date: z.string().min(1, "Payment date is required"), payment_date: optionalDateSchema,
note: z.string().optional(), reference_date: optionalDateSchema,
deposit_to: optionalStringMax255Schema,
note: optionalStringMax255Schema,
}) })
type PaymentReceivedFormValues = z.infer<typeof paymentReceivedFormSchema> type PaymentReceivedFormValues = z.infer<typeof paymentReceivedFormSchema>

View File

@ -8,6 +8,7 @@ import { FieldGroup } from "@/shared/components/ui/field"
import { import {
Rhform, Rhform,
RhfTextField, RhfTextField,
RhfSelectField,
RhfTextareaField, RhfTextareaField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfDateField, RhfDateField,
@ -25,8 +26,9 @@ import {
purchaseOrderFormSchema, purchaseOrderFormSchema,
type PurchaseOrderFormValues, type PurchaseOrderFormValues,
} from "./purchase-order.schema" } 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 { getFullName } from "@/shared/utils/getFullName"
import { useFormContext } from "react-hook-form"
// ── Props ── // ── Props ──
@ -47,6 +49,10 @@ const DEFAULT_VALUES: PurchaseOrderFormValues = {
order_date: new Date().toISOString().split("T")[0], order_date: new Date().toISOString().split("T")[0],
delivery_date: "", delivery_date: "",
notes: "", notes: "",
terms_and_conditions: "",
discount_type: "no",
discount_amount: undefined,
label_ids: [],
items: [], items: [],
} }
@ -64,7 +70,11 @@ function mapToFormValues(data: unknown): PurchaseOrderFormValues {
delivery_date: d.delivery_date || "", delivery_date: d.delivery_date || "",
order_number: d.order_number || "" as any, order_number: d.order_number || "" as any,
notes: d.notes || "", 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, part_id: p.part_id ?? p.id,
title: p.part?.title ?? p.title ?? "", title: p.part?.title ?? p.title ?? "",
quantity: p.quantity ?? 1, quantity: p.quantity ?? 1,
@ -84,6 +94,10 @@ function mapFormToPayload(values: PurchaseOrderFormValues) {
order_number: values.order_number, order_number: values.order_number,
delivery_date: values.delivery_date || undefined, delivery_date: values.delivery_date || undefined,
notes: values.notes || 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) => ({ items: (values.items ?? []).map((item) => ({
part_id: item.part_id, part_id: item.part_id,
quantity: item.quantity, 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 ── // ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({ const mapLookupOption = (item: any) => ({
@ -156,17 +191,27 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
<FieldGroup> <FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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 /> <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="order_date" label="Order Date" />
<RhfDateField name="delivery_date" label="Delivery Date" /> <RhfDateField name="delivery_date" label="Delivery Date" />
</div> </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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField
name="vendor" name="vendor"
label="Vendor" label="Vendor"
placeholder="Select vendor" placeholder="Select vendor"
required
queryKey={[VENDOR_ROUTES.INDEX]} queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()} listFn={() => api.vendors.list()}
mapOption={mapVendorOption} mapOption={mapVendorOption}
@ -185,6 +230,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
name="department" name="department"
label="Department" label="Department"
placeholder="Select department" placeholder="Select department"
required
queryKey={[DEPARTMENT_ROUTES.INDEX]} queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()} listFn={() => api.departments.list()}
mapOption={mapLookupOption} mapOption={mapLookupOption}
@ -196,6 +242,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
name="job_card" name="job_card"
label="Job Card" label="Job Card"
placeholder="Select job card" placeholder="Select job card"
required
queryKey={[JOB_CARD_ROUTES.INDEX]} queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()} listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({ mapOption={(item: any) => ({
@ -206,6 +253,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
/> />
<RhfTextareaField name="notes" label="Notes" rows={3} /> <RhfTextareaField name="notes" label="Notes" rows={3} />
<RhfTextareaField name="terms_and_conditions" label="Terms & Conditions" rows={3} />
<PartsSelectorField<PurchaseOrderFormValues, "items"> name="items" /> <PartsSelectorField<PurchaseOrderFormValues, "items"> name="items" />

View File

@ -1,9 +1,34 @@
import { z } from "zod" import { z } from "zod"
import { InvoiceDiscount } from "@garage/api"
const relationFieldSchema = z const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const 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({ const purchaseOrderItemSchema = z.object({
part_id: z.number(), part_id: z.number(),
title: z.string(), title: z.string(),
@ -14,16 +39,20 @@ const purchaseOrderItemSchema = z.object({
const purchaseOrderFormSchema = z.object({ const purchaseOrderFormSchema = z.object({
// ── Relations ── // ── Relations ──
vendor: relationFieldSchema, vendor: requiredRelationFieldSchema,
job_card: relationFieldSchema, job_card: requiredRelationFieldSchema,
department: relationFieldSchema, department: requiredRelationFieldSchema,
// ── Basic info ── // ── Basic info ──
title: z.string().min(1, "Title is required"), title: optionalStringMax255Schema,
order_date: z.string().optional(), order_date: optionalDateSchema,
delivery_date: z.string().optional(), delivery_date: optionalDateSchema,
notes: z.string().optional(), 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 (parts) ──
items: z.array(purchaseOrderItemSchema).optional(), items: z.array(purchaseOrderItemSchema).optional(),

View File

@ -43,6 +43,12 @@ type VehicleComboboxProps = {
disabled?: boolean disabled?: boolean
} }
function normalizeTextValue(value: unknown): string {
if (typeof value === "string") return value
if (typeof value === "number") return String(value)
return ""
}
function VehicleCombobox({ function VehicleCombobox({
value, value,
onChange, onChange,
@ -59,14 +65,14 @@ function VehicleCombobox({
// Local state keeps the input text in sync even when the form value // Local state keeps the input text in sync even when the form value
// is changed externally (e.g., cascade reset or edit-mode population). // is changed externally (e.g., cascade reset or edit-mode population).
const [inputText, setInputText] = useState(value ?? "") const [inputText, setInputText] = useState(() => normalizeTextValue(value))
useEffect(() => { useEffect(() => {
setInputText(value ?? "") setInputText(normalizeTextValue(value))
}, [value]) }, [value])
// Client-side filtering of suggestions based on what's been typed // 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) => const filtered = options.filter((opt) =>
!normalizedInput || opt.toLowerCase().includes(normalizedInput), !normalizedInput || opt.toLowerCase().includes(normalizedInput),
) )
@ -80,7 +86,7 @@ function VehicleCombobox({
<div ref={anchorRef}> <div ref={anchorRef}>
<Combobox <Combobox
value={inputText || null} value={normalizeTextValue(inputText) || null}
onValueChange={(val) => { onValueChange={(val) => {
const str = val !== null ? String(val) : "" const str = val !== null ? String(val) : ""
setInputText(str) setInputText(str)
@ -89,14 +95,15 @@ function VehicleCombobox({
disabled={disabled} disabled={disabled}
onInputValueChange={(text, { reason }) => { onInputValueChange={(text, { reason }) => {
if (reason === "input-change") { if (reason === "input-change") {
setInputText(text) const next = normalizeTextValue(text)
onChange(text) setInputText(next)
onChange(next)
} }
}} }}
> >
<ComboboxInput <ComboboxInput
placeholder={placeholder} placeholder={placeholder}
showClear={!!inputText} showClear={normalizeTextValue(inputText).length > 0}
onBlur={onBlur} onBlur={onBlur}
aria-invalid={!!error || undefined} aria-invalid={!!error || undefined}
@ -307,7 +314,6 @@ export function RhfVehicleIdentityField() {
<VehicleCombobox <VehicleCombobox
label="Year" label="Year"
placeholder="e.g. 2024" placeholder="e.g. 2024"
required
value={yearValue} value={yearValue}
onChange={handleYearChange} onChange={handleYearChange}
onBlur={yearCtrl.field.onBlur} onBlur={yearCtrl.field.onBlur}

View File

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

View File

@ -6,20 +6,20 @@ export const relationFieldSchema = z
export const vehicleFormSchema = z.object({ export const vehicleFormSchema = z.object({
// ── Relations ── // ── Relations ──
shop_type_id: relationFieldSchema, shop_type_id: relationFieldSchema.refine((val) => !!val?.value, "Shop type is required"),
vehicle_body_type_id: relationFieldSchema, vehicle_body_type_id: relationFieldSchema.refine((val) => !!val?.value, "Vehicle body type is required"),
vehicle_fuel_type_id: relationFieldSchema, vehicle_fuel_type_id: relationFieldSchema,
vehicle_transmission_id: relationFieldSchema, vehicle_transmission_id: relationFieldSchema,
vehicle_color_id: relationFieldSchema, vehicle_color_id: relationFieldSchema,
customer_id: relationFieldSchema, customer_id: relationFieldSchema,
// ── Vehicle identity ── // ── Vehicle identity ──
make: z.string().optional(), make: z.string().min(1, "Make is required").max(50, "Make must be at most 50 characters"),
model: z.string().optional(), model: z.string().min(1, "Model is required").max(50, "Model must be at most 50 characters"),
year: z.string().optional(), year: z.string().optional(),
sub_model: z.string().optional(), sub_model: z.string().optional(),
// ── License & identifiers ── // ── 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(), vin_number: z.string().optional(),
// ── Technical specs ── // ── Technical specs ──

View File

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

View File

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