368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import { AlertTriangle, Plus, Save } from "lucide-react"
|
|
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
|
import { FieldGroup } from "@/shared/components/ui/field"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
import {
|
|
Rhform,
|
|
RhfAsyncSelectField,
|
|
RhfSelectField,
|
|
RhfTextField,
|
|
RhfTextareaField,
|
|
RhfAutoGenerateField,
|
|
RhfDateField,
|
|
} from "@/shared/components/form"
|
|
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
|
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
|
import { getTodayDate, toId, toRelation } from "@/shared/lib/utils"
|
|
import { useAuthApi } from "@/shared/useApi"
|
|
import {
|
|
BillStatus,
|
|
DiscountType,
|
|
BILL_ROUTES,
|
|
DEPARTMENT_ROUTES,
|
|
JOB_CARD_ROUTES,
|
|
PAYMENT_TERM_ROUTES,
|
|
PURCHASE_ORDER_ROUTES,
|
|
VENDOR_ROUTES,
|
|
TAX_ROUTES,
|
|
} from "@garage/api"
|
|
import { toast } from "sonner"
|
|
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
|
|
import { ServicesSelectorField } from "@/modules/services/services-selector-field"
|
|
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field"
|
|
import { useFormContext } from "react-hook-form"
|
|
|
|
import { billFormSchema, type BillFormValues } from "./bill.schema"
|
|
import { BillFormSummary } from "./bill-form-summary"
|
|
|
|
export type BillFormProps = {
|
|
resourceId?: string | null
|
|
initialData?: unknown
|
|
onSuccess?: () => void
|
|
}
|
|
|
|
const DEFAULT_VALUES: BillFormValues = {
|
|
title: "",
|
|
vendor: null,
|
|
vendor_address: null,
|
|
purchase_order: null,
|
|
job_card: null,
|
|
payment_term: null,
|
|
department: null,
|
|
tax: null,
|
|
bill_number: "",
|
|
bill_date: getTodayDate(),
|
|
bill_due_date: "",
|
|
status: "draft",
|
|
discount: "no",
|
|
discount_amount: undefined,
|
|
notes: "",
|
|
part_items: [],
|
|
service_items: [],
|
|
expense_items: [],
|
|
}
|
|
|
|
const STATUS_OPTIONS = BillStatus.map((value) => ({
|
|
value,
|
|
label: value.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
}))
|
|
|
|
const DISCOUNT_OPTIONS = DiscountType.map((value) => ({
|
|
value,
|
|
label: value.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
}))
|
|
|
|
const mapLookupOption = (item: any) => ({
|
|
value: String(item.id),
|
|
label: item.name ?? item.title ?? `#${item.id}`,
|
|
})
|
|
|
|
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
|
|
|
function mapToFormValues(data: unknown): BillFormValues {
|
|
const d = (data as any)?.data ?? data ?? {}
|
|
|
|
return {
|
|
title: d.title || "",
|
|
vendor: toRelation(d.vendor_id, d.vendor?.name ?? d.vendor_name),
|
|
vendor_address: toRelation(d.vendor_address_id, d.vendor_address?.address),
|
|
purchase_order: toRelation(d.purchase_order_id, d.purchase_order?.order_number ?? d.purchase_order_number),
|
|
job_card: toRelation(d.job_card_id, d.job_card?.order_number ?? d.job_card_number),
|
|
payment_term: toRelation(d.payment_terms_id, d.payment_terms?.name ?? d.payment_terms_name),
|
|
department: toRelation(d.department_id, d.department?.name ?? d.department_name),
|
|
tax: toRelation(d.tax_id, d.tax?.name ? `${d.tax.name} (${d.tax.rate}%)` : d.tax_title),
|
|
bill_number: d.bill_number || "",
|
|
bill_date: d.bill_date ? d.bill_date.split("T")[0] : "",
|
|
bill_due_date: d.bill_due_date ? d.bill_due_date.split("T")[0] : "",
|
|
status: d.status || "draft",
|
|
discount: d.discount_type || "no",
|
|
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
|
notes: d.notes || "",
|
|
part_items: (d.parts ?? []).map((p: any) => ({
|
|
part_id: p.part_id ?? p.id,
|
|
title: p.part?.name ?? p.part_name ?? p.title ?? "",
|
|
quantity: Number(p.quantity) || 1,
|
|
rate: Number(p.rate) || 0,
|
|
discount_amount: p.discount_amount != null ? Number(p.discount_amount) : undefined,
|
|
description: p.description ?? "",
|
|
})),
|
|
service_items: (d.services ?? []).map((s: any) => ({
|
|
service_id: s.service_id ?? s.id,
|
|
title: s.service?.name ?? s.service_name ?? s.title ?? "",
|
|
quantity: Number(s.quantity) || 1,
|
|
rate: Number(s.rate) || 0,
|
|
discount_amount: s.discount_amount != null ? Number(s.discount_amount) : undefined,
|
|
description: s.description ?? "",
|
|
})),
|
|
expense_items: (d.expenses ?? []).map((e: any) => ({
|
|
expense_id: e.expense_id ?? e.id,
|
|
title: e.expense?.title ?? e.expense_title ?? e.title ?? "",
|
|
quantity: Number(e.quantity) || 1,
|
|
rate: Number(e.rate) || 0,
|
|
discount_amount: e.discount_amount != null ? Number(e.discount_amount) : undefined,
|
|
description: e.description ?? "",
|
|
})),
|
|
}
|
|
}
|
|
|
|
function mapFormToPayload(values: BillFormValues) {
|
|
return {
|
|
title: values.title,
|
|
vendor_id: toId(values.vendor),
|
|
vendor_address_id: toId(values.vendor_address),
|
|
purchase_order_id: toId(values.purchase_order),
|
|
job_card_id: toId(values.job_card),
|
|
payment_terms_id: toId(values.payment_term),
|
|
department_id: toId(values.department),
|
|
tax_id: toId(values.tax) ? Number(toId(values.tax)) : undefined,
|
|
bill_number: values.bill_number || undefined,
|
|
bill_date: values.bill_date || undefined,
|
|
bill_due_date: values.bill_due_date || undefined,
|
|
status: values.status || undefined,
|
|
discount_type: values.discount || undefined,
|
|
discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
|
|
notes: values.notes || undefined,
|
|
part_items: (values.part_items ?? []).map((item) => ({
|
|
part_id: item.part_id,
|
|
quantity: item.quantity,
|
|
rate: item.rate,
|
|
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
|
description: item.description || undefined,
|
|
})),
|
|
service_items: (values.service_items ?? []).map((item) => ({
|
|
service_id: item.service_id,
|
|
quantity: item.quantity,
|
|
rate: item.rate,
|
|
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
|
description: item.description || undefined,
|
|
})),
|
|
expense_items: (values.expense_items ?? []).map((item) => ({
|
|
expense_id: item.expense_id,
|
|
quantity: item.quantity,
|
|
rate: item.rate,
|
|
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
|
description: item.description || undefined,
|
|
})),
|
|
}
|
|
}
|
|
|
|
// ── Transaction-level discount field (conditional) ──
|
|
|
|
function TransactionDiscountField() {
|
|
const { watch } = useFormContext<BillFormValues>()
|
|
const discount = watch("discount")
|
|
if (discount !== "transaction_level") return null
|
|
return (
|
|
<RhfTextField
|
|
name="discount_amount"
|
|
label="Discount Amount"
|
|
type="number"
|
|
placeholder="0.00"
|
|
/>
|
|
)
|
|
}
|
|
|
|
// ── Component ──
|
|
|
|
export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps) {
|
|
const api = useAuthApi()
|
|
|
|
const { form, isEditing } = useResourceForm<BillFormValues, any>({
|
|
schema: billFormSchema,
|
|
defaultValues: DEFAULT_VALUES,
|
|
resourceId,
|
|
initialData,
|
|
queryKey: [BILL_ROUTES.BY_ID, resourceId],
|
|
mapToFormValues,
|
|
})
|
|
|
|
const discount = form.watch("discount")
|
|
const isLineItemDiscount = discount === "line_item_level"
|
|
|
|
const { mutate, error, isPending } = useFormMutation(form, {
|
|
mutationFn: (values: BillFormValues) => {
|
|
const payload = mapFormToPayload(values)
|
|
const promise = (isEditing && resourceId
|
|
? api.bills.update(resourceId, payload)
|
|
: api.bills.create(payload)) as Promise<any>
|
|
toast.promise(promise, {
|
|
loading: isEditing ? "Updating bill..." : "Creating bill...",
|
|
success: isEditing ? "Bill updated successfully" : "Bill created successfully",
|
|
error: isEditing ? "Failed to update bill" : "Failed to create bill",
|
|
})
|
|
return promise
|
|
},
|
|
onSuccess: () => {
|
|
form.reset()
|
|
onSuccess?.()
|
|
},
|
|
})
|
|
|
|
return (
|
|
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
|
{error && (
|
|
<Alert variant="destructive" className="mb-4">
|
|
<AlertTriangle className="me-2 h-4 w-4" />
|
|
<AlertTitle>
|
|
{isEditing ? "Failed to update bill" : "Failed to create bill"}
|
|
</AlertTitle>
|
|
{error.message}
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
|
|
|
{/* ── Main column (9/12) ── */}
|
|
<div className="lg:col-span-9">
|
|
<FieldGroup>
|
|
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<RhfAutoGenerateField autoFetch table="bills" name="bill_number" label="Bill Number" placeholder="e.g. BILL-001" />
|
|
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<RhfSelectField name="discount" label="Discount Type" options={DISCOUNT_OPTIONS} />
|
|
</div>
|
|
|
|
<TransactionDiscountField />
|
|
|
|
<PartsSelectorField<BillFormValues, "part_items"> name="part_items" showDiscount={isLineItemDiscount} />
|
|
<ServicesSelectorField<BillFormValues, "service_items"> name="service_items" showDiscount={isLineItemDiscount} />
|
|
<ExpenseItemsSelectorField<BillFormValues, "expense_items"> name="expense_items" showDiscount={isLineItemDiscount} />
|
|
|
|
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes" rows={3} />
|
|
|
|
<Button type="submit" variant="default" disabled={isPending}>
|
|
{isEditing ? <Save /> : <Plus />}
|
|
{isPending
|
|
? (isEditing ? "Updating..." : "Creating...")
|
|
: (isEditing ? "Update Bill" : "Create Bill")}
|
|
</Button>
|
|
</FieldGroup>
|
|
</div>
|
|
|
|
{/* ── Sidebar column (3/12) ── */}
|
|
<div className="lg:col-span-3">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Details</CardTitle>
|
|
</CardHeader>
|
|
<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_due_date" label="Due Date" />
|
|
</div>
|
|
|
|
<RhfAsyncSelectField
|
|
name="vendor"
|
|
label="Vendor"
|
|
placeholder="Select vendor"
|
|
queryKey={[VENDOR_ROUTES.INDEX]}
|
|
listFn={() => api.vendors.list()}
|
|
mapOption={mapLookupOption}
|
|
{...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}
|
|
/>
|
|
|
|
<RhfAsyncSelectField
|
|
name="department"
|
|
label="Department"
|
|
placeholder="Select department"
|
|
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
|
listFn={() => api.departments.list()}
|
|
mapOption={mapLookupOption}
|
|
{...STORE_OBJECT}
|
|
/>
|
|
|
|
<RhfAsyncSelectField
|
|
name="payment_term"
|
|
label="Payment Terms"
|
|
placeholder="Select payment terms"
|
|
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
|
|
listFn={() => api.paymentTerms.list()}
|
|
mapOption={mapLookupOption}
|
|
{...STORE_OBJECT}
|
|
/>
|
|
|
|
<RhfAsyncSelectField
|
|
name="job_card"
|
|
label="Job Card"
|
|
placeholder="Select job card"
|
|
queryKey={[JOB_CARD_ROUTES.INDEX]}
|
|
listFn={() => api.jobCards.list()}
|
|
mapOption={(item: any) => ({
|
|
value: String(item.id),
|
|
label: item.order_number || item.estimate_number || `#${item.id}`,
|
|
})}
|
|
{...STORE_OBJECT}
|
|
/>
|
|
|
|
<RhfAsyncSelectField
|
|
name="purchase_order"
|
|
label="Purchase Order"
|
|
placeholder="Select purchase order"
|
|
queryKey={[PURCHASE_ORDER_ROUTES.INDEX]}
|
|
listFn={() => api.purchaseOrders.list()}
|
|
mapOption={(item: any) => ({
|
|
value: String(item.id),
|
|
label: item.order_number || item.title || `#${item.id}`,
|
|
})}
|
|
{...STORE_OBJECT}
|
|
/>
|
|
</FieldGroup>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="mt-4">
|
|
<BillFormSummary />
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</Rhform>
|
|
)
|
|
}
|
|
|
|
|
|
|
|
|