update forms
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
dd32658500
commit
38565298fc
14
.github/skills/crud-page/SKILL.md
vendored
14
.github/skills/crud-page/SKILL.md
vendored
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
</> )
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,22 +30,41 @@ export function BillActions({ billId }: BillActionsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="ghost" size="icon">
|
<DropdownMenuTrigger asChild>
|
||||||
<Ellipsis className="size-4" />
|
<Button variant="ghost" size="icon">
|
||||||
</Button>
|
<Ellipsis className="size-4" />
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={handleEdit}>
|
<DropdownMenuContent align="end">
|
||||||
<Pencil className="size-4" />
|
<DropdownMenuItem onClick={() => editDialog.open(billId)}>
|
||||||
Edit
|
<Pencil className="size-4" />
|
||||||
</DropdownMenuItem>
|
Edit
|
||||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
</DropdownMenuItem>
|
||||||
<Trash2 className="size-4" />
|
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||||
Delete
|
<Trash2 className="size-4" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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,22 +30,41 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="ghost" size="icon">
|
<DropdownMenuTrigger asChild>
|
||||||
<Ellipsis className="size-4" />
|
<Button variant="ghost" size="icon">
|
||||||
</Button>
|
<Ellipsis className="size-4" />
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={handleEdit}>
|
<DropdownMenuContent align="end">
|
||||||
<Pencil className="size-4" />
|
<DropdownMenuItem onClick={() => editDialog.open(creditNoteId)}>
|
||||||
Edit
|
<Pencil className="size-4" />
|
||||||
</DropdownMenuItem>
|
Edit
|
||||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
</DropdownMenuItem>
|
||||||
<Trash2 className="size-4" />
|
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||||
Delete
|
<Trash2 className="size-4" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,22 +45,41 @@ export function CustomerActions({ customerId }: CustomerActionsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="ghost" size="icon">
|
<DropdownMenuTrigger asChild>
|
||||||
<Ellipsis className="size-4" />
|
<Button variant="ghost" size="icon">
|
||||||
</Button>
|
<Ellipsis className="size-4" />
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={handleEdit}>
|
<DropdownMenuContent align="end">
|
||||||
<Pencil className="size-4" />
|
<DropdownMenuItem onClick={() => editDialog.open(customerId)}>
|
||||||
Edit
|
<Pencil className="size-4" />
|
||||||
</DropdownMenuItem>
|
Edit
|
||||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
</DropdownMenuItem>
|
||||||
<Trash2 className="size-4" />
|
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||||
Delete
|
<Trash2 className="size-4" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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,22 +30,41 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="ghost" size="icon">
|
<DropdownMenuTrigger asChild>
|
||||||
<Ellipsis className="size-4" />
|
<Button variant="ghost" size="icon">
|
||||||
</Button>
|
<Ellipsis className="size-4" />
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={handleEdit}>
|
<DropdownMenuContent align="end">
|
||||||
<Pencil className="size-4" />
|
<DropdownMenuItem onClick={() => editDialog.open(expenseId)}>
|
||||||
Edit
|
<Pencil className="size-4" />
|
||||||
</DropdownMenuItem>
|
Edit
|
||||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
</DropdownMenuItem>
|
||||||
<Trash2 className="size-4" />
|
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||||
Delete
|
<Trash2 className="size-4" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => ({
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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,29 +61,48 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
|
|||||||
const transition = status ? STATUS_TRANSITIONS[status] : undefined
|
const transition = status ? STATUS_TRANSITIONS[status] : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="ghost" size="icon">
|
<DropdownMenuTrigger asChild>
|
||||||
<Ellipsis className="size-4" />
|
<Button variant="ghost" size="icon">
|
||||||
</Button>
|
<Ellipsis className="size-4" />
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={handleEdit}>
|
<DropdownMenuContent align="end">
|
||||||
<Pencil className="size-4" />
|
<DropdownMenuItem onClick={() => editDialog.open(inspectionId)}>
|
||||||
Edit
|
<Pencil className="size-4" />
|
||||||
</DropdownMenuItem>
|
Edit
|
||||||
{transition && (
|
|
||||||
<DropdownMenuItem onClick={() => handleStatusChange(transition.next)}>
|
|
||||||
<transition.icon className="size-4" />
|
|
||||||
{transition.label}
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
{transition && (
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuItem onClick={() => handleStatusChange(transition.next)}>
|
||||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
<transition.icon className="size-4" />
|
||||||
<Trash2 className="size-4" />
|
{transition.label}
|
||||||
Delete
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
</DropdownMenuContent>
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenu>
|
<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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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?" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 ──
|
||||||
|
|||||||
@ -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 />}
|
||||||
|
|||||||
27
apps/dashboard/modules/vendors/vendor.schema.ts
vendored
27
apps/dashboard/modules/vendors/vendor.schema.ts
vendored
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user