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
|
||||
6. Render with `Rhform` + `RhfTextField` / `RhfSelectField` / `RhfAsyncSelectField` etc.
|
||||
7. Include error alert, submit button with loading/edit states
|
||||
8. For every required schema/backend field, pass `required` to the rendered form field component so required UI indicators and consistency are preserved
|
||||
|
||||
### Step 5: Create Page Component
|
||||
|
||||
@ -181,6 +182,19 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
|
||||
| `RhfDateField` | Date picker — see date-time-pickers skill |
|
||||
| `RhfTimeField` | Time picker — see date-time-pickers skill |
|
||||
|
||||
### Required Prop Rule (Important)
|
||||
|
||||
When a field is required by schema or backend validation, always pass the `required` prop on the matching form component.
|
||||
|
||||
- Applies to all controls that support it, including custom selectors (for example `RhfCustomerSelectField`, `RhfVehicleSelectField`) and standard fields (`RhfTextField`, `RhfSelectField`, etc.)
|
||||
- Do not rely on schema-only required validation; keep UI required indicator (`*`) in sync with validation requirements
|
||||
|
||||
```tsx
|
||||
<RhfTextField name="title" label="Title" required />
|
||||
<RhfCustomerSelectField name="customer" required />
|
||||
<RhfVehicleSelectField name="vehicle" required customer_id={customer?.value} />
|
||||
```
|
||||
|
||||
### Imports Cheat Sheet
|
||||
|
||||
```tsx
|
||||
|
||||
@ -4,6 +4,8 @@ import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -15,6 +17,8 @@ import { Ellipsis, Pencil, Trash2, CheckCircle, Unlink } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { APPOINTMENT_ROUTES } from "@garage/api"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { AppointmentForm } from "./appointment-form"
|
||||
|
||||
type AppointmentActionsProps = {
|
||||
appointmentId: string
|
||||
@ -27,10 +31,7 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }:
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/calendar/appointment/${appointmentId}/edit`)
|
||||
}
|
||||
const editDialog = useFormDialog("appointment-details-edit")
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsLoading(true)
|
||||
@ -82,7 +83,7 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }:
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<DropdownMenuItem onClick={() => editDialog.open(appointmentId)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@ -120,5 +121,21 @@ export function AppointmentActions({ appointmentId, currentStatus, jobCardId }:
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl lg:min-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Edit Appointment</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<AppointmentForm
|
||||
resourceId={editDialog.resourceId}
|
||||
onSuccess={() => {
|
||||
editDialog.close()
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</> )
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -10,6 +12,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { BillForm } from "./bill-form"
|
||||
|
||||
type BillActionsProps = {
|
||||
billId: string
|
||||
@ -18,10 +22,7 @@ type BillActionsProps = {
|
||||
export function BillActions({ billId }: BillActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/purchase/bill/${billId}/edit`)
|
||||
}
|
||||
const editDialog = useFormDialog("bill-details-edit")
|
||||
|
||||
const handleDelete = async () => {
|
||||
await api.bills.destroy(billId)
|
||||
@ -29,6 +30,7 @@ export function BillActions({ billId }: BillActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
@ -36,7 +38,7 @@ export function BillActions({ billId }: BillActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<DropdownMenuItem onClick={() => editDialog.open(billId)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@ -46,5 +48,23 @@ export function BillActions({ billId }: BillActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl lg:min-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Edit Bill</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<BillForm
|
||||
resourceId={editDialog.resourceId}
|
||||
onSuccess={() => {
|
||||
editDialog.close()
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -61,6 +61,7 @@ const DEFAULT_VALUES: BillFormValues = {
|
||||
discount: "no",
|
||||
discount_amount: undefined,
|
||||
notes: "",
|
||||
label_ids: [],
|
||||
part_items: [],
|
||||
service_items: [],
|
||||
expense_items: [],
|
||||
@ -102,6 +103,7 @@ function mapToFormValues(data: unknown): BillFormValues {
|
||||
discount: d.discount_type || "no",
|
||||
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
||||
notes: d.notes || "",
|
||||
label_ids: Array.isArray(d.label_ids) ? d.label_ids.map((id: any) => Number(id)).filter((id: number) => Number.isFinite(id)) : [],
|
||||
part_items: (d.parts ?? []).map((p: any) => ({
|
||||
part_id: p.part_id ?? p.id,
|
||||
title: p.part?.name ?? p.part_name ?? p.title ?? "",
|
||||
@ -146,6 +148,7 @@ function mapFormToPayload(values: BillFormValues) {
|
||||
discount_type: values.discount || undefined,
|
||||
discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
|
||||
notes: values.notes || undefined,
|
||||
label_ids: values.label_ids?.length ? values.label_ids : undefined,
|
||||
part_items: (values.part_items ?? []).map((item) => ({
|
||||
part_id: item.part_id,
|
||||
quantity: item.quantity,
|
||||
@ -276,7 +279,7 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<RhfDateField name="bill_date" label="Bill Date" />
|
||||
<RhfDateField name="bill_date" label="Bill Date" required />
|
||||
<RhfDateField name="bill_due_date" label="Due Date" />
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,9 +1,25 @@
|
||||
import { z } from "zod"
|
||||
import { BillStatus, DiscountType } from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const optionalDateSchema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().date().optional(),
|
||||
)
|
||||
|
||||
const optionalStringMax255Schema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().max(255, "Must be at most 255 characters").optional(),
|
||||
)
|
||||
|
||||
const requiredDateSchema = z
|
||||
.string()
|
||||
.min(1, "Bill date is required")
|
||||
.date("Enter a valid date")
|
||||
|
||||
const billPartItemSchema = z.object({
|
||||
part_id: z.number(),
|
||||
title: z.string(),
|
||||
@ -33,7 +49,7 @@ const billExpenseItemSchema = z.object({
|
||||
|
||||
const billFormSchema = z.object({
|
||||
// ── Required ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
title: z.string().trim().min(1, "Title is required").max(255, "Title must be at most 255 characters"),
|
||||
|
||||
// ── Relations ──
|
||||
vendor: relationFieldSchema,
|
||||
@ -45,13 +61,14 @@ const billFormSchema = z.object({
|
||||
tax: relationFieldSchema,
|
||||
|
||||
// ── Optional fields ──
|
||||
bill_number: z.string().optional(),
|
||||
bill_date: z.string().optional(),
|
||||
bill_due_date: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
discount: z.string().optional(),
|
||||
bill_number: optionalStringMax255Schema,
|
||||
bill_date: requiredDateSchema,
|
||||
bill_due_date: optionalDateSchema,
|
||||
status: z.enum(BillStatus).optional(),
|
||||
discount: z.enum(DiscountType).optional(),
|
||||
discount_amount: z.coerce.number().min(0).optional(),
|
||||
notes: z.string().optional(),
|
||||
label_ids: z.array(z.number()).optional(),
|
||||
|
||||
// ── Line items ──
|
||||
part_items: z.array(billPartItemSchema).optional(),
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -10,6 +12,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { CreditNoteForm } from "./credit-note-form"
|
||||
|
||||
type CreditNoteActionsProps = {
|
||||
creditNoteId: string
|
||||
@ -18,10 +22,7 @@ type CreditNoteActionsProps = {
|
||||
export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/credit-notes/${creditNoteId}/edit`)
|
||||
}
|
||||
const editDialog = useFormDialog("credit-note-details-edit")
|
||||
|
||||
const handleDelete = async () => {
|
||||
await api.creditNotes.destroy(creditNoteId)
|
||||
@ -29,6 +30,7 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
@ -36,7 +38,7 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<DropdownMenuItem onClick={() => editDialog.open(creditNoteId)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@ -46,5 +48,23 @@ export function CreditNoteActions({ creditNoteId }: CreditNoteActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl lg:min-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Edit Credit Note</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<CreditNoteForm
|
||||
resourceId={editDialog.resourceId}
|
||||
onSuccess={() => {
|
||||
editDialog.close()
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
RhfSelectField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
@ -150,7 +151,7 @@ export function CreditNoteForm({ resourceId, initialData, onSuccess }: CreditNot
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="date" label="Date" type="date" />
|
||||
<RhfDateField name="date" label="Date" required />
|
||||
|
||||
<RhfSelectField
|
||||
name="status"
|
||||
|
||||
@ -1,21 +1,28 @@
|
||||
import { z } from "zod"
|
||||
import { CreditNoteStatus } from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const optionalStringMax255Schema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().max(255, "Must be at most 255 characters").optional(),
|
||||
)
|
||||
|
||||
const creditNoteFormSchema = z.object({
|
||||
// ── Required fields ──
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
subject: z.string().trim().min(1, "Subject is required").max(255, "Subject must be at most 255 characters"),
|
||||
|
||||
// ── Relations ──
|
||||
customer: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
|
||||
// ── Optional fields ──
|
||||
date: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
date: z.string().min(1, "Date is required").date("Enter a valid date"),
|
||||
status: z.enum(CreditNoteStatus).optional(),
|
||||
notes: z.string().optional(),
|
||||
credit_invoice: optionalStringMax255Schema,
|
||||
})
|
||||
|
||||
type CreditNoteFormValues = z.infer<typeof creditNoteFormSchema>
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -12,6 +14,8 @@ import {
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { toast } from "sonner"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { CustomerForm } from "./customer-form"
|
||||
|
||||
type CustomerActionsProps = {
|
||||
customerId: string
|
||||
@ -20,10 +24,7 @@ type CustomerActionsProps = {
|
||||
export function CustomerActions({ customerId }: CustomerActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/customers/${customerId}/edit`)
|
||||
}
|
||||
const editDialog = useFormDialog("customer-details-edit")
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
@ -44,6 +45,7 @@ export function CustomerActions({ customerId }: CustomerActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
@ -51,7 +53,7 @@ export function CustomerActions({ customerId }: CustomerActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<DropdownMenuItem onClick={() => editDialog.open(customerId)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@ -61,5 +63,23 @@ export function CustomerActions({ customerId }: CustomerActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl lg:min-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Edit Customer</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<CustomerForm
|
||||
resourceId={editDialog.resourceId}
|
||||
onSuccess={() => {
|
||||
editDialog.close()
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -51,13 +51,16 @@ const CUSTOMER_DEFAULT_VALUES: CustomerFormValues = {
|
||||
payment_terms: null,
|
||||
country: null,
|
||||
state: null,
|
||||
salutation: "",
|
||||
salutation: "Mr.",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
company_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alternate_phone: "",
|
||||
opening_balance: undefined,
|
||||
credit_limit: undefined,
|
||||
website: "",
|
||||
address_line_1: "",
|
||||
address_line_2: "",
|
||||
city: "",
|
||||
@ -82,6 +85,9 @@ function mapCustomerToFormValues(data: unknown): CustomerFormValues {
|
||||
email: c.email || "",
|
||||
phone: c.phone || "",
|
||||
alternate_phone: c.alternate_phone || "",
|
||||
opening_balance: c.opening_balance ?? undefined,
|
||||
credit_limit: c.credit_limit ?? undefined,
|
||||
website: c.website || "",
|
||||
address_line_1: c.address_line_1 || "",
|
||||
address_line_2: c.address_line_2 || "",
|
||||
city: c.city || "",
|
||||
@ -96,13 +102,16 @@ function mapFormToPayload(values: CustomerFormValues) {
|
||||
payment_terms_id: toId(values.payment_terms),
|
||||
country_id: toId(values.country),
|
||||
state_id: toId(values.state),
|
||||
salutation: values.salutation || undefined,
|
||||
salutation: values.salutation,
|
||||
first_name: values.first_name,
|
||||
last_name: values.last_name,
|
||||
company_name: values.company_name || undefined,
|
||||
email: values.email || undefined,
|
||||
email: values.email,
|
||||
phone: values.phone || undefined,
|
||||
alternate_phone: values.alternate_phone || undefined,
|
||||
opening_balance: values.opening_balance,
|
||||
credit_limit: values.credit_limit,
|
||||
website: values.website || undefined,
|
||||
address_line_1: values.address_line_1 || undefined,
|
||||
address_line_2: values.address_line_2 || undefined,
|
||||
city: values.city || undefined,
|
||||
@ -169,6 +178,7 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
required
|
||||
name="salutation"
|
||||
label="Salutation"
|
||||
placeholder="Select salutation"
|
||||
@ -201,12 +211,19 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
|
||||
|
||||
{/* Contact */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" />
|
||||
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" required />
|
||||
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
|
||||
</div>
|
||||
|
||||
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="0551234567" type="tel" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="opening_balance" label="Opening Balance" placeholder="0" type="number" />
|
||||
<RhfTextField name="credit_limit" label="Credit Limit" placeholder="0" type="number" />
|
||||
</div>
|
||||
|
||||
<RhfTextField name="website" label="Website" placeholder="https://example.com" type="url" />
|
||||
|
||||
{/* Relations */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
|
||||
@ -10,6 +10,30 @@ const relationFieldSchema = z
|
||||
|
||||
type RelationField = z.infer<typeof relationFieldSchema>
|
||||
|
||||
const optionalTrimmedString = (max: number) =>
|
||||
z.preprocess(
|
||||
(value) => {
|
||||
if (typeof value !== "string") {
|
||||
return value
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
return trimmed === "" ? undefined : trimmed
|
||||
},
|
||||
z.string().max(max).optional(),
|
||||
)
|
||||
|
||||
const optionalInteger = z.preprocess(
|
||||
(value) => {
|
||||
if (value === "" || value == null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Number(value)
|
||||
},
|
||||
z.number().int().optional(),
|
||||
)
|
||||
|
||||
const customerFormSchema = z.object({
|
||||
// ── Relations (stored as objects, mapped to IDs on submit) ──
|
||||
customer_type: relationFieldSchema.refine((val) => !!val?.value, "Customer type is required"),
|
||||
@ -19,23 +43,32 @@ const customerFormSchema = z.object({
|
||||
state: relationFieldSchema,
|
||||
|
||||
// ── Basic info ──
|
||||
salutation: z.string().optional(),
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
company_name: z.string().optional(),
|
||||
salutation: z.string().trim().min(1, "Salutation is required").max(50, "Salutation cannot exceed 50 characters"),
|
||||
first_name: z.string().trim().min(1, "First name is required").max(50, "First name cannot exceed 50 characters"),
|
||||
last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name cannot exceed 50 characters"),
|
||||
company_name: optionalTrimmedString(50),
|
||||
|
||||
// ── Contact ──
|
||||
email: z
|
||||
.union([z.string().email("Enter a valid email address"), z.literal("")])
|
||||
.optional(),
|
||||
phone: z.string().optional(),
|
||||
alternate_phone: z.string().optional(),
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Email is required")
|
||||
.email("Enter a valid email address")
|
||||
.max(100, "Email cannot exceed 100 characters"),
|
||||
phone: optionalTrimmedString(20),
|
||||
alternate_phone: optionalTrimmedString(20),
|
||||
opening_balance: optionalInteger,
|
||||
credit_limit: optionalInteger,
|
||||
website: optionalTrimmedString(255).refine(
|
||||
(value) => !value || z.string().url().safeParse(value).success,
|
||||
"Enter a valid website URL",
|
||||
),
|
||||
|
||||
// ── Address ──
|
||||
address_line_1: z.string().optional(),
|
||||
address_line_2: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
zip_code: z.string().optional(),
|
||||
address_line_1: optionalTrimmedString(255),
|
||||
address_line_2: optionalTrimmedString(255),
|
||||
city: optionalTrimmedString(100),
|
||||
zip_code: optionalTrimmedString(20),
|
||||
})
|
||||
|
||||
type CustomerFormValues = z.infer<typeof customerFormSchema>
|
||||
|
||||
@ -11,6 +11,8 @@ import {
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
RhfAutoGenerateField,
|
||||
RhfSelectField,
|
||||
RhfTextareaField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
@ -22,11 +24,12 @@ import {
|
||||
estimateFormSchema,
|
||||
type EstimateFormValues,
|
||||
} from "./estimate.schema"
|
||||
import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||
import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES, DiscountType } from "@garage/api"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
import { RhfLabelPickerField, type LabelItem } from "@/modules/labels/rhf-label-picker-field"
|
||||
import { RhfCustomerRemarksField } from "./rhf-customer-remarks-field"
|
||||
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@ -44,9 +47,16 @@ const DEFAULT_VALUES: EstimateFormValues = {
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
department: null,
|
||||
insurance_type: null,
|
||||
insurer: null,
|
||||
service_writer: null,
|
||||
estimate_number: "",
|
||||
date: "",
|
||||
has_insurance: false,
|
||||
enable_digital_authorisation: false,
|
||||
footer: "",
|
||||
discount: "no",
|
||||
discount_amount: undefined,
|
||||
remarks: [],
|
||||
labels: [],
|
||||
}
|
||||
@ -61,9 +71,24 @@ function mapToFormValues(data: unknown): EstimateFormValues {
|
||||
customer: toRelation(d.customer_id, d.customer_name),
|
||||
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
|
||||
department: toRelation(d.department_id, d.department_name),
|
||||
insurance_type: toRelation(d.insurance_type_id, d.insurance_type_title ?? d.insurance_type_name ?? d.insurance_type?.title),
|
||||
insurer: toRelation(
|
||||
d.insurer_id,
|
||||
d.insurer_name
|
||||
?? [d.insurer?.first_name, d.insurer?.last_name].filter(Boolean).join(" ")
|
||||
?? d.insurer?.company_name,
|
||||
),
|
||||
service_writer: toRelation(
|
||||
d.service_writer_id,
|
||||
d.service_writer_name ?? [d.service_writer?.first_name, d.service_writer?.last_name].filter(Boolean).join(" "),
|
||||
),
|
||||
estimate_number: d.estimate_number || "",
|
||||
date: d.date ? d.date.split("T")[0] : "",
|
||||
has_insurance: d.has_insurance ?? false,
|
||||
enable_digital_authorisation: d.enable_digital_authorisation ?? false,
|
||||
footer: d.footer ?? "",
|
||||
discount: d.discount ?? d.discount_type ?? "no",
|
||||
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
||||
remarks: Array.isArray(d.remarks)
|
||||
? d.remarks.map((r: any) => (typeof r === "string" ? r : (r?.remark ?? ""))).filter(Boolean)
|
||||
: [],
|
||||
@ -81,9 +106,16 @@ function mapFormToPayload(values: EstimateFormValues) {
|
||||
customer_id: toId(values.customer),
|
||||
vehicle_id: toId(values.vehicle),
|
||||
department_id: toId(values.department),
|
||||
insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null,
|
||||
insurer_id: toId(values.insurer),
|
||||
service_writer_id: toId(values.service_writer),
|
||||
estimate_number: values.estimate_number || undefined,
|
||||
date: values.date || undefined,
|
||||
has_insurance: values.has_insurance,
|
||||
enable_digital_authorisation: values.enable_digital_authorisation,
|
||||
footer: values.footer || undefined,
|
||||
discount: values.discount || undefined,
|
||||
discount_amount: values.discount_amount,
|
||||
remarks: values.remarks?.filter(Boolean) ?? [],
|
||||
label_ids: values.labels?.map((l) => l.id) ?? [],
|
||||
}
|
||||
@ -98,6 +130,11 @@ const mapLookupOption = (item: any) => ({
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
const DISCOUNT_OPTIONS = DiscountType.map((value) => ({
|
||||
value,
|
||||
label: value.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFormProps) {
|
||||
@ -116,8 +153,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
|
||||
mutationFn: (values: EstimateFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = (isEditing && resourceId
|
||||
? api.estimates.update(resourceId, payload)
|
||||
: api.estimates.create(payload)) as Promise<any>
|
||||
? api.estimates.update(resourceId, payload as any)
|
||||
: api.estimates.create(payload as any)) as Promise<any>
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating estimate..." : "Creating estimate...",
|
||||
success: isEditing ? "Estimate updated successfully" : "Estimate created successfully",
|
||||
@ -149,8 +186,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
|
||||
<RhfTextField name="title" label="Title" placeholder="Estimate title" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
<RhfCustomerSelectField name="customer" required />
|
||||
<RhfVehicleSelectField name="vehicle" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
@ -164,11 +201,39 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfDateField name="date" label="Date" />
|
||||
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" />
|
||||
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" required />
|
||||
</div>
|
||||
|
||||
<RhfCheckboxField name="has_insurance" label="Has Insurance" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<RhfAsyncSelectField
|
||||
name="insurance_type"
|
||||
label="Insurance Type"
|
||||
placeholder="Select insurance type"
|
||||
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
|
||||
listFn={() => api.insuranceTypes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfCustomerSelectField
|
||||
name="insurer"
|
||||
label="Insurer"
|
||||
placeholder="Search insurer..."
|
||||
customerType="Insurer"
|
||||
/>
|
||||
<RhfEmployeeSelectField name="service_writer" label="Service Writer" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField name="discount" label="Discount" options={DISCOUNT_OPTIONS} />
|
||||
<RhfTextField name="discount_amount" label="Discount Amount" type="number" placeholder="0.00" />
|
||||
</div>
|
||||
|
||||
<RhfCheckboxField name="enable_digital_authorisation" label="Enable Digital Authorisation" />
|
||||
|
||||
<RhfTextareaField name="footer" label="Footer" placeholder="Footer notes for this estimate" />
|
||||
|
||||
<RhfCustomerRemarksField name="remarks" />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
|
||||
@ -1,22 +1,49 @@
|
||||
import { z } from "zod"
|
||||
import { DiscountType } from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => {
|
||||
if (!value) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "This field is required",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const optionalNumberSchema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.coerce.number().min(0).optional(),
|
||||
)
|
||||
|
||||
const optionalDateSchema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().date().optional(),
|
||||
)
|
||||
|
||||
const estimateFormSchema = z.object({
|
||||
// ── Required fields ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
title: z.string().trim().min(1, "Title is required").max(255, "Title must be at most 255 characters"),
|
||||
|
||||
// ── Relations ──
|
||||
customer: relationFieldSchema,
|
||||
vehicle: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
customer: requiredRelationFieldSchema,
|
||||
vehicle: requiredRelationFieldSchema,
|
||||
department: requiredRelationFieldSchema,
|
||||
insurance_type: relationFieldSchema,
|
||||
insurer: relationFieldSchema,
|
||||
service_writer: relationFieldSchema,
|
||||
|
||||
// ── Optional fields ──
|
||||
estimate_number: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
has_insurance: z.boolean().default(false),
|
||||
estimate_number: z.string().trim().min(1, "Estimate number is required").max(255, "Estimate number must be at most 255 characters"),
|
||||
date: optionalDateSchema,
|
||||
has_insurance: z.boolean().optional(),
|
||||
enable_digital_authorisation: z.boolean().optional(),
|
||||
footer: z.string().optional(),
|
||||
discount: z.enum(DiscountType).optional(),
|
||||
discount_amount: optionalNumberSchema,
|
||||
|
||||
// ── Remarks (array of strings) ──
|
||||
remarks: z.array(z.string()).optional(),
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -10,6 +12,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { ExpenseForm } from "./expense-form"
|
||||
|
||||
type ExpenseActionsProps = {
|
||||
expenseId: string
|
||||
@ -18,10 +22,7 @@ type ExpenseActionsProps = {
|
||||
export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/purchase/expense/${expenseId}/edit`)
|
||||
}
|
||||
const editDialog = useFormDialog("expense-details-edit")
|
||||
|
||||
const handleDelete = async () => {
|
||||
await api.expenses.destroy(expenseId)
|
||||
@ -29,6 +30,7 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
@ -36,7 +38,7 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<DropdownMenuItem onClick={() => editDialog.open(expenseId)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@ -46,5 +48,23 @@ export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl lg:min-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Edit Expense</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<ExpenseForm
|
||||
resourceId={editDialog.resourceId}
|
||||
onSuccess={() => {
|
||||
editDialog.close()
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -314,6 +314,7 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
|
||||
name="job_card"
|
||||
label="Job Card"
|
||||
placeholder="Select job card"
|
||||
required
|
||||
queryKey={[JOB_CARD_ROUTES.INDEX]}
|
||||
listFn={() => api.jobCards.list()}
|
||||
mapOption={(item: any) => ({
|
||||
|
||||
@ -1,9 +1,29 @@
|
||||
import { z } from "zod"
|
||||
import { ExpenseStatus, InvoiceDiscount } from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => {
|
||||
if (!value?.value) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "This field is required",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const optionalDateSchema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().date().optional(),
|
||||
)
|
||||
|
||||
const optionalStringMax255Schema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().max(255, "Must be at most 255 characters").optional(),
|
||||
)
|
||||
|
||||
const labelItemSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
@ -22,21 +42,22 @@ const expenseLineItemSchema = z.object({
|
||||
|
||||
const expenseFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
job_card: relationFieldSchema,
|
||||
job_card: requiredRelationFieldSchema,
|
||||
category: relationFieldSchema,
|
||||
vendor: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
tax: relationFieldSchema,
|
||||
|
||||
// ── Basic info ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
invoice_number: z.string().optional(),
|
||||
expense_date: z.string().optional(),
|
||||
title: z.string().trim().min(1, "Title is required").max(255, "Title must be at most 255 characters"),
|
||||
invoice_number: optionalStringMax255Schema,
|
||||
expense_date: optionalDateSchema,
|
||||
notes: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
status: z.enum(ExpenseStatus).optional(),
|
||||
paid_through: z.coerce.number().int().optional(),
|
||||
|
||||
// ── Discount / Tax ──
|
||||
discount: z.string().optional(),
|
||||
discount: z.enum(InvoiceDiscount).optional(),
|
||||
discount_amount: z.coerce.number().min(0).optional(),
|
||||
|
||||
labels: z.array(labelItemSchema).optional(),
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -12,6 +14,8 @@ import {
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2, Play, CheckCircle2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { InspectionForm } from "./inspection-form"
|
||||
|
||||
type InspectionActionsProps = {
|
||||
inspectionId: string
|
||||
@ -27,10 +31,7 @@ const STATUS_TRANSITIONS: Record<string, { next: string; label: string; icon: ty
|
||||
export function InspectionActions({ inspectionId, status, onStatusChange }: InspectionActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/inspections/${inspectionId}/edit`)
|
||||
}
|
||||
const editDialog = useFormDialog("inspection-details-edit")
|
||||
|
||||
const handleDelete = async () => {
|
||||
const promise = api.inspections.destroy(inspectionId)
|
||||
@ -60,6 +61,7 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
|
||||
const transition = status ? STATUS_TRANSITIONS[status] : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
@ -67,7 +69,7 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<DropdownMenuItem onClick={() => editDialog.open(inspectionId)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@ -84,5 +86,23 @@ export function InspectionActions({ inspectionId, status, onStatusChange }: Insp
|
||||
</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 />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAutoGenerateField autoFetch table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" />
|
||||
<RhfAutoGenerateField autoFetch table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" required />
|
||||
<RhfTextField name="invoice_title" label="Invoice Title" placeholder="e.g. Tax Invoice" />
|
||||
</div>
|
||||
|
||||
@ -338,8 +338,8 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<RhfDateField name="invoice_date" label="Invoice Date" />
|
||||
<RhfDateField name="due_date" label="Due Date" />
|
||||
<RhfDateField name="invoice_date" label="Invoice Date" required />
|
||||
<RhfDateField name="due_date" label="Due Date" required />
|
||||
</div>
|
||||
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" />
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import { z } from "zod"
|
||||
import { InvoiceDiscount, InvoiceStatus } from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const requiredDateSchema = z
|
||||
.string()
|
||||
.min(1, "This field is required")
|
||||
.date("Enter a valid date")
|
||||
|
||||
const invoicePartItemSchema = z.object({
|
||||
part_id: z.number(),
|
||||
title: z.string(),
|
||||
@ -33,7 +39,7 @@ const invoiceExpenseItemSchema = z.object({
|
||||
|
||||
const invoiceFormSchema = z.object({
|
||||
// ── Required fields ──
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
subject: z.string().trim().min(1, "Subject is required").max(255, "Subject must be at most 255 characters"),
|
||||
|
||||
// ── Relations ──
|
||||
customer: relationFieldSchema,
|
||||
@ -47,14 +53,14 @@ const invoiceFormSchema = z.object({
|
||||
invoice_to: relationFieldSchema,
|
||||
|
||||
// ── Optional fields ──
|
||||
invoice_number: z.string().optional(),
|
||||
invoice_number: z.string().trim().min(1, "Invoice number is required").max(255, "Invoice number must be at most 255 characters"),
|
||||
invoice_title: z.string().optional(),
|
||||
invoice_date: z.string().optional(),
|
||||
due_date: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
invoice_date: requiredDateSchema,
|
||||
due_date: requiredDateSchema,
|
||||
status: z.enum(InvoiceStatus).optional(),
|
||||
kms_in: z.coerce.number().optional(),
|
||||
has_insurance: z.boolean().default(false),
|
||||
discount: z.string().optional(),
|
||||
discount: z.enum(InvoiceDiscount).optional(),
|
||||
discount_amount: z.coerce.number().min(0).optional(),
|
||||
tax: relationFieldSchema,
|
||||
deposit_to: z.string().optional(),
|
||||
@ -65,6 +71,19 @@ const invoiceFormSchema = z.object({
|
||||
parts: z.array(invoicePartItemSchema).optional(),
|
||||
services: z.array(invoiceServiceItemSchema).optional(),
|
||||
expense_items: z.array(invoiceExpenseItemSchema).optional(),
|
||||
}).superRefine((values, ctx) => {
|
||||
if (values.invoice_date && values.due_date) {
|
||||
const invoiceDate = new Date(values.invoice_date)
|
||||
const dueDate = new Date(values.due_date)
|
||||
|
||||
if (!Number.isNaN(invoiceDate.getTime()) && !Number.isNaN(dueDate.getTime()) && dueDate < invoiceDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["due_date"],
|
||||
message: "Due date must be on or after invoice date",
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type InvoiceFormValues = z.infer<typeof invoiceFormSchema>
|
||||
|
||||
@ -60,6 +60,7 @@ const DEFAULT_VALUES: JobCardFormValues = {
|
||||
title: "",
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
estimate: null,
|
||||
department: null,
|
||||
service_writer: null,
|
||||
primary_technician: null,
|
||||
@ -73,6 +74,7 @@ const DEFAULT_VALUES: JobCardFormValues = {
|
||||
estimate_to: "Customer",
|
||||
tax_inclusive: "Tax Inclusive",
|
||||
discount_type: "no",
|
||||
discount_amount: undefined,
|
||||
discount_at: "inclusive_of_tax",
|
||||
order_date: new Date().toISOString().split("T")[0],
|
||||
check_in_date: "",
|
||||
@ -88,7 +90,11 @@ const DEFAULT_VALUES: JobCardFormValues = {
|
||||
enable_parts_issuing: false,
|
||||
enable_digital_authorisation: false,
|
||||
footer: "",
|
||||
attachments: "",
|
||||
customer_remarks: [],
|
||||
documents: [],
|
||||
attachment_files: [],
|
||||
label_ids: [],
|
||||
labels: [],
|
||||
}
|
||||
|
||||
@ -111,6 +117,7 @@ function mapToFormValues(data: unknown): JobCardFormValues {
|
||||
title: d.title || "",
|
||||
customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined),
|
||||
vehicle: toRelation(d.vehicle_id, d.vehicle ? (d.vehicle.plate_number ?? `${d.vehicle.make ?? ""} ${d.vehicle.model ?? ""}`.trim()) : undefined),
|
||||
estimate: toRelation(d.estimate_id, d.estimate?.estimate_number),
|
||||
department: toRelation(d.department_id, d.department?.name),
|
||||
service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined),
|
||||
primary_technician: toRelation(d.primary_technician_id, d.primary_technician ? `${d.primary_technician.first_name} ${d.primary_technician.last_name}` : undefined),
|
||||
@ -124,6 +131,7 @@ function mapToFormValues(data: unknown): JobCardFormValues {
|
||||
estimate_to: d.estimate_to || "Customer",
|
||||
tax_inclusive: d.tax_inclusive || "Tax Inclusive",
|
||||
discount_type: d.discount_type || "no",
|
||||
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
||||
discount_at: d.discount_at || "inclusive_of_tax",
|
||||
order_date: mapDate(d.order_date),
|
||||
check_in_date: mapDate(d.check_in_date),
|
||||
@ -138,11 +146,19 @@ function mapToFormValues(data: unknown): JobCardFormValues {
|
||||
enable_parts_issuing: d.enable_parts_issuing ?? false,
|
||||
enable_digital_authorisation: d.enable_digital_authorisation ?? false,
|
||||
footer: d.footer || "",
|
||||
attachments: d.attachments || "",
|
||||
customer_remarks: Array.isArray(d.customer_remarks)
|
||||
? d.customer_remarks.map((r: any) =>
|
||||
typeof r === "string" ? r : (r?.remark ?? "")
|
||||
).filter(Boolean)
|
||||
: [],
|
||||
documents: Array.isArray(d.documents) ? d.documents : [],
|
||||
attachment_files: Array.isArray(d.attachment_files) ? d.attachment_files : [],
|
||||
label_ids: Array.isArray(d.label_ids)
|
||||
? d.label_ids
|
||||
.map((id: any) => Number(id))
|
||||
.filter((id: number) => Number.isFinite(id))
|
||||
: [],
|
||||
labels: (d.labels ?? []).map((l: any) => ({
|
||||
id: l.id,
|
||||
title: l.title,
|
||||
@ -156,6 +172,7 @@ function mapFormToPayload(values: JobCardFormValues) {
|
||||
title: values.title,
|
||||
customer_id: toId(values.customer),
|
||||
vehicle_id: toId(values.vehicle),
|
||||
estimate_id: toId(values.estimate) ?? undefined,
|
||||
department_id: toId(values.department),
|
||||
service_writer_id: toId(values.service_writer),
|
||||
primary_technician_id: toId(values.primary_technician),
|
||||
@ -169,6 +186,7 @@ function mapFormToPayload(values: JobCardFormValues) {
|
||||
estimate_to: values.estimate_to || undefined,
|
||||
tax_inclusive: values.tax_inclusive || undefined,
|
||||
discount_type: values.discount_type || undefined,
|
||||
discount_amount: values.discount_amount,
|
||||
discount_at: values.discount_at || undefined,
|
||||
order_date: values.order_date || undefined,
|
||||
check_in_date: values.check_in_date || undefined,
|
||||
@ -183,8 +201,11 @@ function mapFormToPayload(values: JobCardFormValues) {
|
||||
enable_parts_issuing: values.enable_parts_issuing,
|
||||
enable_digital_authorisation: values.enable_digital_authorisation,
|
||||
footer: values.footer || undefined,
|
||||
attachments: values.attachments || undefined,
|
||||
customer_remarks: values.customer_remarks?.filter(Boolean) ?? [],
|
||||
label_ids: values.labels?.map((l) => l.id),
|
||||
documents: values.documents,
|
||||
attachment_files: values.attachment_files,
|
||||
label_ids: values.labels?.map((l) => l.id) ?? values.label_ids,
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,8 +296,8 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
||||
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" disabled={!customer?.value} customer_id={customer?.value} />
|
||||
<RhfCustomerSelectField required name="customer" />
|
||||
<RhfVehicleSelectField required name="vehicle" disabled={!customer?.value} customer_id={customer?.value} />
|
||||
</div>
|
||||
|
||||
<RhfCheckboxField name="has_insurance" label="Has Insurance Work?" />
|
||||
|
||||
@ -12,6 +12,19 @@ const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const optionalStringMax255 = z.string().max(255).optional()
|
||||
const optionalTimeString = z.string().max(50).optional()
|
||||
|
||||
const documentSchema = z.object({
|
||||
document_type_id: z.coerce.number().int().optional(),
|
||||
customer_id: z.coerce.number().int().optional(),
|
||||
vehicle_id: z.coerce.number().int().optional(),
|
||||
document_number: z.string().optional(),
|
||||
show_in_invoice: z.boolean().optional(),
|
||||
show_in_estimate: z.boolean().optional(),
|
||||
show_in_statement: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// ── Job Card Statuses ──
|
||||
|
||||
export const JOB_CARD_STATUS_OPTIONS = JobCardStatus.map((v) => ({
|
||||
@ -48,11 +61,12 @@ const FUEL_LEVEL_OPTIONS = FuelLevel.map((v) => ({
|
||||
|
||||
const jobCardFormSchema = z.object({
|
||||
// ── Required fields ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
title: z.string().min(1, "Title is required").max(255, "Title must be at most 255 characters"),
|
||||
|
||||
// ── Relations ──
|
||||
customer: relationFieldSchema,
|
||||
vehicle: relationFieldSchema,
|
||||
estimate: relationFieldSchema.optional(),
|
||||
department: relationFieldSchema,
|
||||
service_writer: relationFieldSchema,
|
||||
primary_technician: relationFieldSchema,
|
||||
@ -62,28 +76,29 @@ const jobCardFormSchema = z.object({
|
||||
tax: relationFieldSchema,
|
||||
|
||||
// ── Numbers & identifiers ──
|
||||
order_number: z.string().optional(),
|
||||
estimate_number: z.string().optional(),
|
||||
order_number: optionalStringMax255,
|
||||
estimate_number: optionalStringMax255,
|
||||
|
||||
// ── Status & settings ──
|
||||
status: z.string().optional(),
|
||||
estimate_to: z.string().optional(),
|
||||
tax_inclusive: z.string().optional(),
|
||||
discount_type: z.string().optional(),
|
||||
discount_at: z.string().optional(),
|
||||
status: z.enum(JobCardStatus).optional(),
|
||||
estimate_to: z.enum(EstimateTo).optional(),
|
||||
tax_inclusive: z.enum(TaxInclusive).optional(),
|
||||
discount_type: z.enum(DiscountType).optional(),
|
||||
discount_amount: z.coerce.number().min(0).optional(),
|
||||
discount_at: z.enum(DiscountAt).optional(),
|
||||
|
||||
// ── Dates & times ──
|
||||
order_date: z.string().optional(),
|
||||
check_in_date: z.string().optional(),
|
||||
check_in_time: z.string().optional(),
|
||||
check_in_time: optionalTimeString,
|
||||
start_date: z.string().optional(),
|
||||
start_time: z.string().optional(),
|
||||
start_time: optionalTimeString,
|
||||
delivery_date: z.string().optional(),
|
||||
delivery_time: z.string().optional(),
|
||||
delivery_time: optionalTimeString,
|
||||
|
||||
// ── Vehicle state ──
|
||||
km_in: z.string().optional(),
|
||||
fuel_level: z.string().optional(),
|
||||
km_in: z.union([z.string(), z.number()]).optional(),
|
||||
fuel_level: optionalTimeString,
|
||||
|
||||
// ── Boolean options ──
|
||||
has_insurance: z.boolean().optional(),
|
||||
@ -92,9 +107,12 @@ const jobCardFormSchema = z.object({
|
||||
|
||||
// ── Notes ──
|
||||
footer: z.string().optional(),
|
||||
attachments: z.string().optional(),
|
||||
|
||||
// ── Customer Remarks ──
|
||||
customer_remarks: z.array(z.string()).optional(),
|
||||
documents: z.array(documentSchema).optional(),
|
||||
attachment_files: z.array(z.unknown()).max(20, "Attachment files cannot exceed 20 files").optional(),
|
||||
|
||||
// ── Labels ──
|
||||
labels: z
|
||||
@ -106,6 +124,23 @@ const jobCardFormSchema = z.object({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
label_ids: z.array(z.coerce.number().int()).optional(),
|
||||
}).superRefine((values, ctx) => {
|
||||
if (!values.customer) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["customer"],
|
||||
message: "Customer is required",
|
||||
})
|
||||
}
|
||||
|
||||
if (!values.vehicle) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["vehicle"],
|
||||
message: "Vehicle is required",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
|
||||
|
||||
@ -63,7 +63,7 @@ const DEFAULT_VALUES: PaymentMadeFormValues & { details: Array<{ bill_id?: strin
|
||||
payment_date: getTodayDate(),
|
||||
paid_through: "",
|
||||
notes: "",
|
||||
details: []
|
||||
details: [{ amount_paid: 0 }]
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
@ -79,14 +79,14 @@ function mapToFormValues(data: unknown): typeof DEFAULT_VALUES {
|
||||
vendor: toRelation(d.vendor_id, d.vendor?.company_name ?? d.vendor?.name ?? d.vendor_name),
|
||||
employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : d.employee_name),
|
||||
payment_mode: toRelation(paymentModeId, paymentModeLabel),
|
||||
payment_for: d.payment_for || "",
|
||||
payment_for: d.payment_for || "bill",
|
||||
amount: d.payment_made ? String(d.payment_made) : "",
|
||||
payment_number: d.payment_number || "",
|
||||
payment_reference: d.payment_reference || "",
|
||||
payment_date: d.payment_date || "",
|
||||
paid_through: d.paid_through || "-",
|
||||
notes: d.notes || "-",
|
||||
details: [{ bill_id: d.bill_id, amount_paid: d.amount_paid }],
|
||||
details: [{ bill_id: d.bill_id, expense_id: d.expense_id, amount_paid: d.amount_paid ?? 0 }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -249,6 +249,7 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, ex
|
||||
name="payment_mode"
|
||||
label="Payment Mode"
|
||||
placeholder="Select payment mode"
|
||||
required
|
||||
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentModes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
|
||||
@ -1,23 +1,61 @@
|
||||
import { z } from "zod"
|
||||
import { PaymentFor } from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => {
|
||||
if (!value?.value) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "This field is required",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const optionalStringMax255Schema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().max(255, "Must be at most 255 characters").optional(),
|
||||
)
|
||||
|
||||
const detailsItemSchema = z.object({
|
||||
bill_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
expense_id: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
amount_paid: z.coerce.number().min(0).optional(),
|
||||
})
|
||||
|
||||
const paymentMadeFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
vendor: relationFieldSchema,
|
||||
employee: relationFieldSchema,
|
||||
payment_mode: relationFieldSchema,
|
||||
payment_mode: requiredRelationFieldSchema,
|
||||
|
||||
// ── Payment info ──
|
||||
amount: z.string().min(1, "Amount is required"),
|
||||
payment_for: z.string().min(1, "Payment for is required"),
|
||||
payment_number: z.string().optional(),
|
||||
payment_reference: z.string().optional(),
|
||||
payment_date: z.string().min(1, "Payment date is required"),
|
||||
paid_through: z.string().optional(),
|
||||
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
||||
payment_for: z.enum(PaymentFor),
|
||||
payment_number: optionalStringMax255Schema,
|
||||
payment_reference: optionalStringMax255Schema,
|
||||
payment_date: z.string().min(1, "Payment date is required").date("Enter a valid date"),
|
||||
paid_through: optionalStringMax255Schema,
|
||||
notes: z.string().optional(),
|
||||
details: z.array(detailsItemSchema).min(1, "At least one payment detail is required"),
|
||||
}).superRefine((values, ctx) => {
|
||||
const hasVendor = Boolean(values.vendor?.value)
|
||||
const hasEmployee = Boolean(values.employee?.value)
|
||||
|
||||
if (!hasVendor && !hasEmployee) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["vendor"],
|
||||
message: "Vendor or employee is required",
|
||||
})
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["employee"],
|
||||
message: "Vendor or employee is required",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type PaymentMadeFormValues = z.infer<typeof paymentMadeFormSchema>
|
||||
|
||||
@ -157,6 +157,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
|
||||
name="customer"
|
||||
label="Customer"
|
||||
placeholder="Select customer"
|
||||
required
|
||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||
listFn={() => api.customers.list()}
|
||||
mapOption={(item: any) => ({
|
||||
@ -193,6 +194,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
|
||||
name="payment_mode"
|
||||
label="Payment Mode"
|
||||
placeholder="Select payment mode"
|
||||
required
|
||||
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentModes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
|
||||
@ -4,17 +4,38 @@ const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => {
|
||||
if (!value?.value) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "This field is required",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const optionalDateSchema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().date().optional(),
|
||||
)
|
||||
|
||||
const optionalStringMax255Schema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().max(255, "Must be at most 255 characters").optional(),
|
||||
)
|
||||
|
||||
const paymentReceivedFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
job_card: relationFieldSchema,
|
||||
payment_mode: relationFieldSchema,
|
||||
customer: relationFieldSchema,
|
||||
payment_mode: requiredRelationFieldSchema,
|
||||
customer: requiredRelationFieldSchema,
|
||||
|
||||
// ── Payment info ──
|
||||
amount_received: z.string().min(1, "Amount is required"),
|
||||
payment_number: z.string().optional(),
|
||||
payment_date: z.string().min(1, "Payment date is required"),
|
||||
note: z.string().optional(),
|
||||
amount_received: z.coerce.number().min(0, "Amount received must be 0 or more"),
|
||||
payment_number: optionalStringMax255Schema,
|
||||
payment_date: optionalDateSchema,
|
||||
reference_date: optionalDateSchema,
|
||||
deposit_to: optionalStringMax255Schema,
|
||||
note: optionalStringMax255Schema,
|
||||
})
|
||||
|
||||
type PaymentReceivedFormValues = z.infer<typeof paymentReceivedFormSchema>
|
||||
|
||||
@ -8,6 +8,7 @@ import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfSelectField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
@ -25,8 +26,9 @@ import {
|
||||
purchaseOrderFormSchema,
|
||||
type PurchaseOrderFormValues,
|
||||
} from "./purchase-order.schema"
|
||||
import { VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||
import { VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES, InvoiceDiscount } from "@garage/api"
|
||||
import { getFullName } from "@/shared/utils/getFullName"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@ -47,6 +49,10 @@ const DEFAULT_VALUES: PurchaseOrderFormValues = {
|
||||
order_date: new Date().toISOString().split("T")[0],
|
||||
delivery_date: "",
|
||||
notes: "",
|
||||
terms_and_conditions: "",
|
||||
discount_type: "no",
|
||||
discount_amount: undefined,
|
||||
label_ids: [],
|
||||
items: [],
|
||||
}
|
||||
|
||||
@ -64,7 +70,11 @@ function mapToFormValues(data: unknown): PurchaseOrderFormValues {
|
||||
delivery_date: d.delivery_date || "",
|
||||
order_number: d.order_number || "" as any,
|
||||
notes: d.notes || "",
|
||||
items: (d.parts ?? []).map((p: any) => ({
|
||||
terms_and_conditions: d.terms_and_conditions || "",
|
||||
discount_type: d.discount_type || "no",
|
||||
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
||||
label_ids: Array.isArray(d.label_ids) ? d.label_ids.map((id: any) => Number(id)).filter((id: number) => Number.isFinite(id)) : [],
|
||||
items: (d.parts ?? d.items ?? []).map((p: any) => ({
|
||||
part_id: p.part_id ?? p.id,
|
||||
title: p.part?.title ?? p.title ?? "",
|
||||
quantity: p.quantity ?? 1,
|
||||
@ -84,6 +94,10 @@ function mapFormToPayload(values: PurchaseOrderFormValues) {
|
||||
order_number: values.order_number,
|
||||
delivery_date: values.delivery_date || undefined,
|
||||
notes: values.notes || undefined,
|
||||
terms_and_conditions: values.terms_and_conditions || undefined,
|
||||
discount_type: values.discount_type || undefined,
|
||||
discount_amount: values.discount_type === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
|
||||
label_ids: values.label_ids?.length ? values.label_ids : undefined,
|
||||
items: (values.items ?? []).map((item) => ({
|
||||
part_id: item.part_id,
|
||||
quantity: item.quantity,
|
||||
@ -93,6 +107,27 @@ function mapFormToPayload(values: PurchaseOrderFormValues) {
|
||||
}
|
||||
}
|
||||
|
||||
const DISCOUNT_OPTIONS = InvoiceDiscount.map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
function TransactionDiscountField() {
|
||||
const { watch } = useFormContext<PurchaseOrderFormValues>()
|
||||
const discount = watch("discount_type")
|
||||
|
||||
if (discount !== "transaction_level") return null
|
||||
|
||||
return (
|
||||
<RhfTextField
|
||||
name="discount_amount"
|
||||
label="Discount Amount"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared mapOption for async selects ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
@ -156,17 +191,27 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
||||
<FieldGroup>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
|
||||
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" />
|
||||
<RhfAutoGenerateField autoFetch table="purchase-order" name="order_number" label="Order Number" placeholder="Enter purchase order number" required />
|
||||
<RhfDateField name="order_date" label="Order Date" />
|
||||
<RhfDateField name="delivery_date" label="Delivery Date" />
|
||||
</div>
|
||||
|
||||
<RhfSelectField
|
||||
name="discount_type"
|
||||
label="Discount Type"
|
||||
placeholder="Select discount type"
|
||||
options={DISCOUNT_OPTIONS}
|
||||
/>
|
||||
|
||||
<TransactionDiscountField />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vendor"
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
required
|
||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||
listFn={() => api.vendors.list()}
|
||||
mapOption={mapVendorOption}
|
||||
@ -185,6 +230,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
required
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
@ -196,6 +242,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
||||
name="job_card"
|
||||
label="Job Card"
|
||||
placeholder="Select job card"
|
||||
required
|
||||
queryKey={[JOB_CARD_ROUTES.INDEX]}
|
||||
listFn={() => api.jobCards.list()}
|
||||
mapOption={(item: any) => ({
|
||||
@ -206,6 +253,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
||||
/>
|
||||
|
||||
<RhfTextareaField name="notes" label="Notes" rows={3} />
|
||||
<RhfTextareaField name="terms_and_conditions" label="Terms & Conditions" rows={3} />
|
||||
|
||||
<PartsSelectorField<PurchaseOrderFormValues, "items"> name="items" />
|
||||
|
||||
|
||||
@ -1,9 +1,34 @@
|
||||
import { z } from "zod"
|
||||
import { InvoiceDiscount } from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const requiredRelationFieldSchema = relationFieldSchema.superRefine((value, ctx) => {
|
||||
if (!value?.value) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "This field is required",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const optionalDateSchema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().date().optional(),
|
||||
)
|
||||
|
||||
const optionalStringMax255Schema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().max(255, "Must be at most 255 characters").optional(),
|
||||
)
|
||||
|
||||
const optionalNumberSchema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.coerce.number().min(0).optional(),
|
||||
)
|
||||
|
||||
const purchaseOrderItemSchema = z.object({
|
||||
part_id: z.number(),
|
||||
title: z.string(),
|
||||
@ -14,16 +39,20 @@ const purchaseOrderItemSchema = z.object({
|
||||
|
||||
const purchaseOrderFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
vendor: relationFieldSchema,
|
||||
job_card: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
vendor: requiredRelationFieldSchema,
|
||||
job_card: requiredRelationFieldSchema,
|
||||
department: requiredRelationFieldSchema,
|
||||
|
||||
// ── Basic info ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
order_date: z.string().optional(),
|
||||
delivery_date: z.string().optional(),
|
||||
title: optionalStringMax255Schema,
|
||||
order_date: optionalDateSchema,
|
||||
delivery_date: optionalDateSchema,
|
||||
notes: z.string().optional(),
|
||||
order_number: z.string().min(1, "Order number is required"),
|
||||
terms_and_conditions: z.string().optional(),
|
||||
discount_type: z.enum(InvoiceDiscount).optional(),
|
||||
discount_amount: optionalNumberSchema,
|
||||
label_ids: z.array(z.number()).optional(),
|
||||
order_number: z.string().trim().min(1, "Order number is required").max(255, "Order number must be at most 255 characters"),
|
||||
|
||||
// ── Items (parts) ──
|
||||
items: z.array(purchaseOrderItemSchema).optional(),
|
||||
|
||||
@ -43,6 +43,12 @@ type VehicleComboboxProps = {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function normalizeTextValue(value: unknown): string {
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number") return String(value)
|
||||
return ""
|
||||
}
|
||||
|
||||
function VehicleCombobox({
|
||||
value,
|
||||
onChange,
|
||||
@ -59,14 +65,14 @@ function VehicleCombobox({
|
||||
|
||||
// Local state keeps the input text in sync even when the form value
|
||||
// is changed externally (e.g., cascade reset or edit-mode population).
|
||||
const [inputText, setInputText] = useState(value ?? "")
|
||||
const [inputText, setInputText] = useState(() => normalizeTextValue(value))
|
||||
|
||||
useEffect(() => {
|
||||
setInputText(value ?? "")
|
||||
setInputText(normalizeTextValue(value))
|
||||
}, [value])
|
||||
|
||||
// Client-side filtering of suggestions based on what's been typed
|
||||
const normalizedInput = inputText.toLowerCase()
|
||||
const normalizedInput = normalizeTextValue(inputText).toLowerCase()
|
||||
const filtered = options.filter((opt) =>
|
||||
!normalizedInput || opt.toLowerCase().includes(normalizedInput),
|
||||
)
|
||||
@ -80,7 +86,7 @@ function VehicleCombobox({
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
|
||||
value={inputText || null}
|
||||
value={normalizeTextValue(inputText) || null}
|
||||
onValueChange={(val) => {
|
||||
const str = val !== null ? String(val) : ""
|
||||
setInputText(str)
|
||||
@ -89,14 +95,15 @@ function VehicleCombobox({
|
||||
disabled={disabled}
|
||||
onInputValueChange={(text, { reason }) => {
|
||||
if (reason === "input-change") {
|
||||
setInputText(text)
|
||||
onChange(text)
|
||||
const next = normalizeTextValue(text)
|
||||
setInputText(next)
|
||||
onChange(next)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={!!inputText}
|
||||
showClear={normalizeTextValue(inputText).length > 0}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={!!error || undefined}
|
||||
|
||||
@ -307,7 +314,6 @@ export function RhfVehicleIdentityField() {
|
||||
<VehicleCombobox
|
||||
label="Year"
|
||||
placeholder="e.g. 2024"
|
||||
required
|
||||
value={yearValue}
|
||||
onChange={handleYearChange}
|
||||
onBlur={yearCtrl.field.onBlur}
|
||||
|
||||
@ -169,6 +169,7 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
{/* Associations */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
required
|
||||
name="shop_type_id"
|
||||
label="Shop Type"
|
||||
placeholder="Select shop type"
|
||||
@ -180,6 +181,7 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
required
|
||||
name="vehicle_body_type_id"
|
||||
label="Body Type"
|
||||
placeholder="Select body type"
|
||||
@ -249,6 +251,7 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
{/* License & identifiers */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
required
|
||||
name="license_plate"
|
||||
label="License Plate"
|
||||
placeholder="e.g. ABC-123"
|
||||
|
||||
@ -6,20 +6,20 @@ export const relationFieldSchema = z
|
||||
|
||||
export const vehicleFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
shop_type_id: relationFieldSchema,
|
||||
vehicle_body_type_id: relationFieldSchema,
|
||||
shop_type_id: relationFieldSchema.refine((val) => !!val?.value, "Shop type is required"),
|
||||
vehicle_body_type_id: relationFieldSchema.refine((val) => !!val?.value, "Vehicle body type is required"),
|
||||
vehicle_fuel_type_id: relationFieldSchema,
|
||||
vehicle_transmission_id: relationFieldSchema,
|
||||
vehicle_color_id: relationFieldSchema,
|
||||
customer_id: relationFieldSchema,
|
||||
// ── Vehicle identity ──
|
||||
make: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
make: z.string().min(1, "Make is required").max(50, "Make must be at most 50 characters"),
|
||||
model: z.string().min(1, "Model is required").max(50, "Model must be at most 50 characters"),
|
||||
year: z.string().optional(),
|
||||
sub_model: z.string().optional(),
|
||||
|
||||
// ── License & identifiers ──
|
||||
license_plate: z.string().optional(),
|
||||
license_plate: z.string().min(1, "License plate is required").max(50, "License plate must be at most 50 characters"),
|
||||
vin_number: z.string().optional(),
|
||||
|
||||
// ── Technical specs ──
|
||||
|
||||
@ -107,11 +107,11 @@ export function VendorForm({ resourceId, initialData, onSuccess }: VendorFormPro
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="first_name" label="First Name" placeholder="John" required />
|
||||
<RhfTextField name="last_name" label="Last Name" placeholder="Doe" />
|
||||
<RhfTextField name="last_name" label="Last Name" placeholder="Doe" required />
|
||||
</div>
|
||||
|
||||
<RhfTextField name="company_name" label="Company Name" placeholder="Acme Supplies" />
|
||||
<RhfTextField name="email" label="Email" placeholder="vendor@example.com" type="email" />
|
||||
<RhfTextField name="company_name" label="Company Name" placeholder="Acme Supplies" required />
|
||||
<RhfTextField name="email" label="Email" placeholder="vendor@example.com" type="email" required />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
|
||||
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"
|
||||
|
||||
const optionalStringMaxSchema = (max: number) =>
|
||||
z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.string().max(max, `Must be at most ${max} characters`).optional(),
|
||||
)
|
||||
|
||||
const optionalNumberSchema = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : value),
|
||||
z.coerce.number().int().optional(),
|
||||
)
|
||||
|
||||
const vendorFormSchema = z.object({
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().optional(),
|
||||
company_name: z.string().optional(),
|
||||
email: z
|
||||
.union([z.string().email("Enter a valid email address"), z.literal("")])
|
||||
.optional(),
|
||||
first_name: z.string().trim().min(1, "First name is required").max(50, "First name must be at most 50 characters"),
|
||||
last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name must be at most 50 characters"),
|
||||
company_name: z.string().trim().min(1, "Company name is required").max(50, "Company name must be at most 50 characters"),
|
||||
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address").max(100, "Email must be at most 100 characters"),
|
||||
salutation: optionalStringMaxSchema(50),
|
||||
phone: optionalStringMaxSchema(20),
|
||||
alternate_phone: optionalStringMaxSchema(20),
|
||||
opening_balance: optionalNumberSchema,
|
||||
credit_limit: optionalNumberSchema,
|
||||
website: optionalStringMaxSchema(255),
|
||||
})
|
||||
|
||||
type VendorFormValues = z.infer<typeof vendorFormSchema>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user