Tightened frontend zod schemas where backend required fields the frontend marked optional, and matched enum/format constraints. Schemas: - vehicle-document: document_type required - parts: shop_type, category, unit_type, department, sku required - inspections: customer, vehicle, department, inspection_category, employee, order_number, date, time required - appointments: service_writer required, cross-field to_time > from_time - vendor-credits: vendor + vendor_credit_date required - job-cards: documents[].document_type_id required - expense-items: category, unit_type, department, sku required - inventory-adjustments: reference_number, date required - tasks: task_type, task_section required - shop-timings: in_time, out_time enforce HH:MM:SS regex - vendor-credit: subject + vendor + vendor_credit_date required Settings: - shop-type: shop_type + note required - insurance-types: description field added, required - make-and-models: shop_type required, year 1900..2100 - departments: assignment_type required enum (AssignmentType) - company: website URL validation, latitude/longitude range, first_day_of_work enum, string max lengths - configurations: 4 fields enum-typed (was raw strings) Inline forms: - job-card service/part/expense-item: relations required (part/service/expense_item/department) - job-card recommendation: max 255 - vehicles/inline-forms/shop-type: shop_type + note required - vehicles/inline-forms/body-type: shop_type_id pulled from parent form context, guard on submit - vehicles/inline-forms/color: code field added, required - services/inline-forms/department: assignment_type required enum - tasks/task-section: arrangement required integer - invoices/invoice-edit: status + discount enum-typed Auth: - login: password min 6 (was 8; backend allows 6) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import React from "react"
|
|
import { AlertTriangle, Plus, Save } from "lucide-react"
|
|
import { z } from "zod"
|
|
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
|
import { FieldGroup } from "@/shared/components/ui/field"
|
|
import {
|
|
Rhform,
|
|
RhfTextField,
|
|
RhfTextareaField,
|
|
RhfAsyncSelectField,
|
|
} from "@/shared/components/form"
|
|
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
|
|
import { toast } from "sonner"
|
|
import { useAuthApi } from "@/shared/useApi"
|
|
import { formatTaxLabel } from "@/shared/utils/formatters"
|
|
import { useForm } from "react-hook-form"
|
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
import { DEPARTMENT_ROUTES, TAX_ROUTES } from "@garage/api"
|
|
import { useJobCard } from "./job-card-context"
|
|
|
|
// ── Schema ──
|
|
|
|
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 jobCardExpenseItemFormSchema = z.object({
|
|
expense_item: requiredRelationFieldSchema,
|
|
department: requiredRelationFieldSchema,
|
|
tax: relationFieldSchema.optional(),
|
|
quantity: z.coerce.number().min(1, "Quantity is required"),
|
|
rate: z.coerce.number().min(0, "Rate is required"),
|
|
discount_amount: z.coerce.number().min(0).optional(),
|
|
chart_of_account: z.string().optional(),
|
|
description: z.string().optional(),
|
|
})
|
|
|
|
type JobCardExpenseItemFormValues = z.infer<typeof jobCardExpenseItemFormSchema>
|
|
|
|
// ── Props ──
|
|
|
|
export type JobCardExpenseItemFormProps = {
|
|
jobCardId: string
|
|
jobCardExpenseItemId?: number | null
|
|
initialData?: unknown
|
|
onSuccess?: () => void
|
|
onCancel?: () => void
|
|
}
|
|
|
|
const DEFAULT_VALUES: JobCardExpenseItemFormValues = {
|
|
expense_item: null,
|
|
department: null,
|
|
tax: null,
|
|
quantity: 1,
|
|
rate: 0,
|
|
discount_amount: undefined,
|
|
chart_of_account: "",
|
|
description: "",
|
|
}
|
|
|
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
|
|
|
function mapDepartmentOption(item: any) {
|
|
const id = item?.id ?? item?.department_id
|
|
return {
|
|
value: String(id),
|
|
label: item?.name ?? item?.title ?? item?.department_name ?? String(id),
|
|
}
|
|
}
|
|
|
|
function mapToFormValues(data: unknown): JobCardExpenseItemFormValues {
|
|
const d = (data as any) ?? {}
|
|
return {
|
|
expense_item: d.expense_item
|
|
? { value: String(d.expense_item.id), label: d.expense_item.item_name ?? String(d.expense_item.id) }
|
|
: null,
|
|
department: (d.department || d.department_id != null)
|
|
? mapDepartmentOption(d.department ?? { id: d.department_id, name: d.department_name ?? d.department_title })
|
|
: null,
|
|
tax: d.tax_id != null
|
|
? { value: String(d.tax_id), label: formatTaxLabel(d.tax, String(d.tax_id)) }
|
|
: null,
|
|
quantity: d.quantity ?? 1,
|
|
rate: d.rate != null ? Number(d.rate) : 0,
|
|
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
|
chart_of_account: d.chart_of_account ?? "",
|
|
description: d.description ?? "",
|
|
}
|
|
}
|
|
|
|
function mapJobCardDepartmentToRelation(jobCard: unknown) {
|
|
const d = (jobCard as any) ?? {}
|
|
const departmentId = d.department_id ?? d.department?.id
|
|
if (departmentId == null) {
|
|
return null
|
|
}
|
|
return {
|
|
value: String(departmentId),
|
|
label: d.department?.name ?? d.department_name ?? d.department_title ?? String(departmentId),
|
|
}
|
|
}
|
|
|
|
// ── Component ──
|
|
|
|
export function JobCardExpenseItemForm({
|
|
jobCardId,
|
|
jobCardExpenseItemId,
|
|
initialData,
|
|
onSuccess,
|
|
onCancel,
|
|
}: JobCardExpenseItemFormProps) {
|
|
const api = useAuthApi()
|
|
const jobCard = useJobCard()
|
|
const isEditing = !!jobCardExpenseItemId
|
|
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
|
|
|
|
const form = useForm<JobCardExpenseItemFormValues>({
|
|
resolver: zodResolver(jobCardExpenseItemFormSchema) as any,
|
|
defaultValues: initialData
|
|
? mapToFormValues(initialData)
|
|
: {
|
|
...DEFAULT_VALUES,
|
|
department: mapJobCardDepartmentToRelation(jobCard),
|
|
},
|
|
})
|
|
|
|
const [error, setError] = React.useState<string | null>(null)
|
|
const [isPending, setIsPending] = React.useState(false)
|
|
|
|
async function handleSubmit(values: JobCardExpenseItemFormValues) {
|
|
setError(null)
|
|
setIsPending(true)
|
|
try {
|
|
if (isEditing && jobCardExpenseItemId) {
|
|
await toast.promise(
|
|
api.jobCards.updateExpenseItem(jobCardId, {
|
|
job_card_expense_item_id: jobCardExpenseItemId,
|
|
quantity: values.quantity,
|
|
rate: values.rate,
|
|
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
|
|
description: values.description || undefined,
|
|
}),
|
|
{
|
|
loading: "Updating expense item...",
|
|
success: "Expense item updated successfully",
|
|
error: "Failed to update expense item",
|
|
}
|
|
)
|
|
} else {
|
|
if (!values.department?.value) {
|
|
setError("Department is required")
|
|
return
|
|
}
|
|
|
|
await toast.promise(
|
|
api.jobCards.addExpenseItem(jobCardId, {
|
|
expense_item_id: values.expense_item ? Number(values.expense_item.value) : undefined,
|
|
department_id: Number(values.department.value),
|
|
tax_id: values.tax ? Number(values.tax.value) : undefined,
|
|
quantity: values.quantity,
|
|
rate: values.rate,
|
|
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
|
|
chart_of_account: values.chart_of_account || undefined,
|
|
description: values.description || undefined,
|
|
}),
|
|
{
|
|
loading: "Adding expense item...",
|
|
success: "Expense item added successfully",
|
|
error: "Failed to add expense item",
|
|
}
|
|
)
|
|
}
|
|
form.reset()
|
|
onSuccess?.()
|
|
} catch (err: any) {
|
|
setError(err?.message ?? "An unexpected error occurred")
|
|
} finally {
|
|
setIsPending(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Rhform form={form} onSubmit={handleSubmit}>
|
|
{error && (
|
|
<Alert variant="destructive" className="mb-4">
|
|
<AlertTriangle className="me-2 h-4 w-4" />
|
|
<AlertTitle>
|
|
{isEditing ? "Failed to update expense item" : "Failed to add expense item"}
|
|
</AlertTitle>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
<FieldGroup>
|
|
{!isEditing && (
|
|
<RhfAsyncSelectField
|
|
name="expense_item"
|
|
label="Expense Item"
|
|
placeholder="Select expense item"
|
|
required
|
|
queryKey={["expense-items"]}
|
|
listFn={() => api.expenses.listItems()}
|
|
mapOption={(item: any) => ({
|
|
value: String(item.id),
|
|
label: item.item_name ?? item.title ?? String(item.id),
|
|
})}
|
|
{...STORE_OBJECT}
|
|
/>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<RhfTextField
|
|
name="quantity"
|
|
label="Quantity"
|
|
type="number"
|
|
placeholder="1"
|
|
required
|
|
/>
|
|
<RhfTextField
|
|
name="rate"
|
|
label="Rate"
|
|
type="number"
|
|
placeholder="0.00"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{isLineItemDiscount && (
|
|
<RhfTextField
|
|
name="discount_amount"
|
|
label="Discount Amount"
|
|
type="number"
|
|
placeholder="0.00"
|
|
/>
|
|
)}
|
|
|
|
{!isEditing && (
|
|
<>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<RhfAsyncSelectField
|
|
name="department"
|
|
label="Department"
|
|
placeholder="Select department"
|
|
required
|
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
|
listFn={() => api.departments.list()}
|
|
mapOption={mapDepartmentOption}
|
|
createForm={(props) => <DepartmentInlineForm {...props} />}
|
|
createLabel="Department"
|
|
{...STORE_OBJECT}
|
|
/>
|
|
<RhfAsyncSelectField
|
|
name="tax"
|
|
label="Tax"
|
|
placeholder="Select tax rate"
|
|
queryKey={[TAX_ROUTES.INDEX]}
|
|
listFn={() => api.taxes.list()}
|
|
mapOption={(item: any) => ({
|
|
value: String(item.id),
|
|
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
|
|
})}
|
|
{...STORE_OBJECT}
|
|
/>
|
|
</div>
|
|
|
|
<RhfTextField
|
|
name="chart_of_account"
|
|
label="Chart of Account"
|
|
placeholder="e.g. COA-400"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<RhfTextareaField
|
|
name="description"
|
|
label="Description"
|
|
placeholder="Optional notes"
|
|
/>
|
|
</FieldGroup>
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
{onCancel && (
|
|
<Button type="button" variant="outline" onClick={onCancel}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
<Button type="submit" disabled={isPending}>
|
|
{isPending ? (
|
|
isEditing ? "Saving..." : "Adding..."
|
|
) : isEditing ? (
|
|
<>
|
|
<Save className="me-2 h-4 w-4" />
|
|
Save Changes
|
|
</>
|
|
) : (
|
|
<>
|
|
<Plus className="me-2 h-4 w-4" />
|
|
Add Expense Item
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</Rhform>
|
|
)
|
|
}
|