garage-erp/apps/dashboard/modules/job-cards/job-card-expense-item-form.tsx
2026-04-23 14:38:41 +03:00

281 lines
11 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 { 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 jobCardExpenseItemFormSchema = z.object({
expense_item: relationFieldSchema,
department: relationFieldSchema.optional(),
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 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
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: null,
tax: d.tax_id != null
? { value: String(d.tax_id), label: d.tax ? `${d.tax.title} (${d.tax.rate}%)` : 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 ?? "",
}
}
// ── 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,
})
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 {
await toast.promise(
api.jobCards.addExpenseItem(jobCardId, {
expense_item_id: values.expense_item ? Number(values.expense_item.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
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"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.name ?? String(item.id),
})}
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>
)
}