fix invoice informations
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { FileText } from "lucide-react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -12,37 +13,107 @@ import {
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { InvoiceForm } from "@/modules/invoices/invoice-form"
|
||||
import { toRelation } from "@/shared/lib/utils"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { ESTIMATE_ROUTES } from "@garage/api"
|
||||
import { useEstimate } from "./estimate-context"
|
||||
|
||||
/**
|
||||
* Maps an Estimate data object to an InvoiceFormValues shape so that
|
||||
* useResourceForm's shallow spread correctly pre-fills all relational fields.
|
||||
*/
|
||||
function mapEstimateToInvoiceInitialData(estimate: Record<string, any>) {
|
||||
function mapEstimateToInvoiceInitialData(
|
||||
estimate: Record<string, any>,
|
||||
services: any[],
|
||||
parts: any[],
|
||||
expenseItems: any[],
|
||||
) {
|
||||
return {
|
||||
subject: estimate.title ?? "",
|
||||
notes: estimate.footer ?? "",
|
||||
|
||||
// Relation fields — must be { value, label } objects for RhfAsyncSelectField
|
||||
customer: toRelation(estimate.customer_id, estimate.customer_name),
|
||||
vehicle: toRelation(estimate.vehicle_id, estimate.vehicle_name),
|
||||
department: toRelation(estimate.department_id, estimate.department_name),
|
||||
estimate: toRelation(estimate.id, estimate.title ?? `#${estimate.estimate_number ?? estimate.id}`),
|
||||
customer: toRelation(
|
||||
estimate.customer_id,
|
||||
estimate.customer
|
||||
? `${estimate.customer.first_name ?? ""} ${estimate.customer.last_name ?? ""}`.trim()
|
||||
: undefined
|
||||
),
|
||||
vehicle: toRelation(
|
||||
estimate.vehicle_id,
|
||||
estimate.vehicle
|
||||
? `${estimate.vehicle.make ?? ""} ${estimate.vehicle.model ?? ""} (${estimate.vehicle.registration_number ?? ""})`.trim()
|
||||
: undefined
|
||||
),
|
||||
department: toRelation(estimate.department_id, estimate.department?.name),
|
||||
|
||||
invoice_number: "",
|
||||
invoice_date: estimate.date ?? "",
|
||||
due_date: "",
|
||||
status: "draft" as const,
|
||||
|
||||
services: services.map((s: any) => ({
|
||||
service_id: s.service_id ?? s.id,
|
||||
title: s.service.labor_name ?? s.title ?? "",
|
||||
quantity: Number(s.quantity) || 1,
|
||||
rate: Number(s.rate) || 0,
|
||||
description: s.description ?? "",
|
||||
})),
|
||||
parts: parts.map((p: any) => ({
|
||||
part_id: p.part_id ?? p.id,
|
||||
title: p.part.title ?? "",
|
||||
quantity: Number(p.quantity) || 1,
|
||||
rate: Number(p.rate) || 0,
|
||||
description: p.description ?? "",
|
||||
})),
|
||||
expense_items: expenseItems.map((e: any) => ({
|
||||
expense_id: e.expense_item_id ?? e.id,
|
||||
title: e.expense_item.item_name ?? e.title ?? "",
|
||||
quantity: Number(e.quantity) || 1,
|
||||
rate: Number(e.rate) || 0,
|
||||
description: e.description ?? "",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateInvoiceFromEstimateButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const estimateContext = useEstimate()
|
||||
const api = useAuthApi()
|
||||
|
||||
const estimateId = estimateContext?.id ?? ""
|
||||
|
||||
const { data: servicesData } = useQuery({
|
||||
queryKey: [ESTIMATE_ROUTES.SERVICES, estimateId, "for-invoice"],
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listServices(estimateId)
|
||||
return ((res as any)?.data ?? []) as any[]
|
||||
},
|
||||
enabled: open && !!estimateId,
|
||||
})
|
||||
|
||||
const { data: partsData } = useQuery({
|
||||
queryKey: [ESTIMATE_ROUTES.PARTS, estimateId, "for-invoice"],
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listParts(estimateId)
|
||||
return ((res as any)?.data ?? []) as any[]
|
||||
},
|
||||
enabled: open && !!estimateId,
|
||||
})
|
||||
|
||||
const { data: expenseItemsData } = useQuery({
|
||||
queryKey: [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId, "for-invoice"],
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listExpenseItems(estimateId)
|
||||
return ((res as any)?.data ?? []) as any[]
|
||||
},
|
||||
enabled: open && !!estimateId,
|
||||
})
|
||||
|
||||
if (!estimateContext) return null
|
||||
|
||||
const initialData = estimateContext.data
|
||||
? mapEstimateToInvoiceInitialData(estimateContext.data)
|
||||
? mapEstimateToInvoiceInitialData(
|
||||
estimateContext.data,
|
||||
servicesData ?? [],
|
||||
partsData ?? [],
|
||||
expenseItemsData ?? [],
|
||||
)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
|
||||
@@ -37,6 +37,7 @@ type ExpenseLine = {
|
||||
quantity: number
|
||||
rate: number | string
|
||||
description?: string
|
||||
expense_item: any
|
||||
}
|
||||
|
||||
type SelectedExpenseItem = {
|
||||
@@ -92,7 +93,7 @@ export function EstimateExpenseItemsSection({ estimateId }: { estimateId: string
|
||||
}
|
||||
|
||||
const getDisplayName = (item: ExpenseLine) =>
|
||||
item.item_name ?? item.title ?? `Item #${item.expense_item_id ?? item.id}`
|
||||
item.expense_item.item_name ?? item.expense_item.title ?? `Item #${item.expense_item_id ?? item.id}`
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -112,7 +113,7 @@ export function EstimateExpenseItemsSection({ estimateId }: { estimateId: string
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead className="w-100">Item</TableHead>
|
||||
<TableHead className="w-24">Qty</TableHead>
|
||||
<TableHead className="w-28">Rate</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { formatDate } from "@/shared/utils/formatters"
|
||||
|
||||
type EstimateData = {
|
||||
id?: number
|
||||
@@ -43,10 +44,13 @@ type EstimateData = {
|
||||
remark?: string
|
||||
created_at?: string
|
||||
}[]
|
||||
// Joined fields that may come from the API
|
||||
customer_name?: string
|
||||
vehicle_name?: string
|
||||
department_name?: string
|
||||
// Nested relation objects from the API
|
||||
customer?: { id?: number; first_name?: string; last_name?: string }
|
||||
vehicle?: { id?: number; registration_number?: string; make?: string; model?: string }
|
||||
department?: { id?: number; name?: string }
|
||||
insurance_type?: { id?: number; title?: string }
|
||||
insurer?: { id?: number; first_name?: string; last_name?: string }
|
||||
service_writer?: { id?: number; first_name?: string; last_name?: string }
|
||||
}
|
||||
|
||||
type EstimateGeneralInfoProps = {
|
||||
@@ -92,7 +96,7 @@ export function EstimateGeneralInfo({ estimate }: EstimateGeneralInfoProps) {
|
||||
<InfoItem icon={Hash} label="Estimate #" value={estimate.estimate_number} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Calendar} label="Date" value={estimate.date} />
|
||||
<InfoItem icon={Calendar} label="Date" value={formatDate(estimate.date)} />
|
||||
<InfoItem
|
||||
icon={Shield}
|
||||
label="Insurance"
|
||||
@@ -121,17 +125,39 @@ export function EstimateGeneralInfo({ estimate }: EstimateGeneralInfoProps) {
|
||||
<InfoItem
|
||||
icon={User}
|
||||
label="Customer"
|
||||
value={estimate.customer_name || (estimate.customer_id ? `#${estimate.customer_id}` : null)}
|
||||
value={
|
||||
estimate.customer
|
||||
? `${estimate.customer.first_name ?? ""} ${estimate.customer.last_name ?? ""}`.trim()
|
||||
: estimate.customer_id ? `#${estimate.customer_id}` : null
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Car}
|
||||
label="Vehicle"
|
||||
value={estimate.vehicle_name || (estimate.vehicle_id ? `#${estimate.vehicle_id}` : null)}
|
||||
value={
|
||||
estimate.vehicle
|
||||
? `${estimate.vehicle.make ?? ""} ${estimate.vehicle.model ?? ""} (${estimate.vehicle.registration_number ?? ""})`.trim()
|
||||
: estimate.vehicle_id ? `#${estimate.vehicle_id}` : null
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={estimate.department_name || (estimate.department_id ? `#${estimate.department_id}` : null)}
|
||||
value={estimate.department?.name ?? (estimate.department_id ? `#${estimate.department_id}` : null)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Shield}
|
||||
label="Insurance Type"
|
||||
value={estimate.insurance_type?.title ?? null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={User}
|
||||
label="Service Writer"
|
||||
value={
|
||||
estimate.service_writer
|
||||
? `${estimate.service_writer.first_name ?? ""} ${estimate.service_writer.last_name ?? ""}`.trim()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -36,6 +36,7 @@ type PartLine = {
|
||||
quantity: number
|
||||
rate: number | string
|
||||
description?: string
|
||||
part:any
|
||||
}
|
||||
|
||||
type SelectedPart = {
|
||||
@@ -91,7 +92,7 @@ export function EstimatePartsSection({ estimateId }: { estimateId: string }) {
|
||||
}
|
||||
|
||||
const getDisplayName = (item: PartLine) =>
|
||||
item.title ?? `Part #${item.part_id ?? item.id}`
|
||||
item.part.title ?? `Part #${item.part_id ?? item.id}`
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -111,7 +112,7 @@ export function EstimatePartsSection({ estimateId }: { estimateId: string }) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Part</TableHead>
|
||||
<TableHead className="w-100">Part</TableHead>
|
||||
<TableHead className="w-24">Qty</TableHead>
|
||||
<TableHead className="w-28">Rate</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
|
||||
@@ -37,6 +37,7 @@ type ServiceLine = {
|
||||
quantity: number
|
||||
rate: number | string
|
||||
description?: string
|
||||
service:any
|
||||
}
|
||||
|
||||
type SelectedService = {
|
||||
@@ -92,7 +93,7 @@ export function EstimateServicesSection({ estimateId }: { estimateId: string })
|
||||
}
|
||||
|
||||
const getDisplayName = (item: ServiceLine) =>
|
||||
item.labor_name ?? item.title ?? `Service #${item.service_id ?? item.id}`
|
||||
item.service.labor_name ?? item.service.title ?? `Service #${item.service_id ?? item.id}`
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -112,7 +113,7 @@ export function EstimateServicesSection({ estimateId }: { estimateId: string })
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead className="w-100">Service</TableHead>
|
||||
<TableHead className="w-24">Qty</TableHead>
|
||||
<TableHead className="w-28">Rate</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import { Receipt } from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { formatCurrency, formatNumber } from "@/shared/utils/formatters"
|
||||
|
||||
type InvoiceExpense = {
|
||||
id: number
|
||||
invoice_id: number
|
||||
expense_item_id: number
|
||||
quantity: string | number
|
||||
rate: string | number
|
||||
description?: string
|
||||
chart_of_account?: string
|
||||
department_id?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type InvoiceExpensesSectionProps = {
|
||||
expenses?: InvoiceExpense[]
|
||||
}
|
||||
|
||||
export function InvoiceExpensesSection({ expenses = [] }: InvoiceExpensesSectionProps) {
|
||||
if (!expenses || expenses.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const subtotal = expenses.reduce((sum, expense) => {
|
||||
const qty = parseFloat(String(expense.quantity))
|
||||
const rate = parseFloat(String(expense.rate))
|
||||
return sum + (qty * rate)
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="size-4" />
|
||||
Expenses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Quantity</TableHead>
|
||||
<TableHead className="text-right">Rate</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{expenses.map((expense) => {
|
||||
const qty = parseFloat(String(expense.quantity))
|
||||
const rate = parseFloat(String(expense.rate))
|
||||
const amount = qty * rate
|
||||
return (
|
||||
<TableRow key={expense.id}>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{expense.description || `Expense #${expense.expense_item_id}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(qty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(rate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatCurrency(amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell colSpan={3} className="text-right">
|
||||
Subtotal
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(subtotal)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -12,20 +12,36 @@ import {
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfAutoGenerateField,
|
||||
RhfCheckboxField,
|
||||
RhfDateField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toRelation, toId } from "@/shared/lib/utils"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import {
|
||||
invoiceFormSchema,
|
||||
type InvoiceFormValues,
|
||||
} from "./invoice.schema"
|
||||
import { INVOICE_ROUTES, DEPARTMENT_ROUTES, InvoiceStatus } from "@garage/api"
|
||||
import {
|
||||
INVOICE_ROUTES,
|
||||
DEPARTMENT_ROUTES,
|
||||
ESTIMATE_ROUTES,
|
||||
PAYMENT_TERM_ROUTES,
|
||||
INVOICE_SEQUENCE_ROUTES,
|
||||
PAYMENT_MODE_ROUTES,
|
||||
CUSTOMER_ROUTES,
|
||||
InvoiceStatus,
|
||||
InvoiceDiscount,
|
||||
} from "@garage/api"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
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"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
@@ -34,6 +50,11 @@ const STATUS_OPTIONS = InvoiceStatus.map((v) => ({
|
||||
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const DISCOUNT_OPTIONS = InvoiceDiscount.map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type InvoiceFormProps = {
|
||||
@@ -49,11 +70,26 @@ const DEFAULT_VALUES: InvoiceFormValues = {
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
department: null,
|
||||
estimate: null,
|
||||
payment_terms: null,
|
||||
invoice_sequence: null,
|
||||
payment_mode: null,
|
||||
insurer: null,
|
||||
invoice_to: null,
|
||||
invoice_number: "",
|
||||
invoice_title: "",
|
||||
invoice_date: "",
|
||||
due_date: "",
|
||||
status: "draft",
|
||||
kms_in: undefined,
|
||||
has_insurance: false,
|
||||
discount: "no",
|
||||
deposit_to: "",
|
||||
notes: "",
|
||||
terms_and_conditions: "",
|
||||
parts: [],
|
||||
services: [],
|
||||
expense_items: [],
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
@@ -66,11 +102,44 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
customer: toRelation(d.customer_id, d.customer_name),
|
||||
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
|
||||
department: toRelation(d.department_id, d.department_name),
|
||||
estimate: toRelation(d.estimate_id, d.estimate_number ?? d.estimate_title),
|
||||
payment_terms: toRelation(d.payment_terms_id, d.payment_terms_name),
|
||||
invoice_sequence: toRelation(d.invoice_sequence_id, d.invoice_sequence_title ?? d.invoice_sequence?.title),
|
||||
payment_mode: toRelation(d.payment_mode_id, d.payment_mode_name),
|
||||
insurer: toRelation(d.insurer_id, d.insurer_name),
|
||||
invoice_to: toRelation(d.invoice_to_id, d.invoice_to_name),
|
||||
invoice_number: d.invoice_number || "",
|
||||
invoice_date: d.invoice_date || "",
|
||||
due_date: d.due_date || "",
|
||||
invoice_title: d.invoice_title || "",
|
||||
invoice_date: d.invoice_date ? d.invoice_date.split("T")[0] : "",
|
||||
due_date: d.due_date ? d.due_date.split("T")[0] : "",
|
||||
status: d.status || "draft",
|
||||
kms_in: d.kms_in ? Number(d.kms_in) : undefined,
|
||||
has_insurance: d.has_insurance ?? false,
|
||||
discount: d.discount || "no",
|
||||
deposit_to: d.deposit_to || "",
|
||||
notes: d.notes || "",
|
||||
terms_and_conditions: d.terms_and_conditions || "",
|
||||
parts: (d.invoice_parts ?? d.parts ?? []).map((p: any) => ({
|
||||
part_id: p.part_id ?? p.id,
|
||||
title: p.part?.title ?? p.title ?? "",
|
||||
quantity: Number(p.quantity) || 1,
|
||||
rate: Number(p.rate) || 0,
|
||||
description: p.description ?? "",
|
||||
})),
|
||||
services: (d.invoice_services ?? d.services ?? []).map((s: any) => ({
|
||||
service_id: s.service_id ?? s.id,
|
||||
title: s.service?.labor_name ?? s.labor_name ?? s.title ?? "",
|
||||
quantity: Number(s.quantity) || 1,
|
||||
rate: Number(s.rate) || 0,
|
||||
description: s.description ?? "",
|
||||
})),
|
||||
expense_items: (d.invoice_expenses ?? d.expenses ?? []).map((e: any) => ({
|
||||
expense_id: e.expense_id ?? e.id,
|
||||
title: e.expense?.item_name ?? e.item_name ?? e.title ?? "",
|
||||
quantity: Number(e.quantity) || 1,
|
||||
rate: Number(e.rate) || 0,
|
||||
description: e.description ?? "",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,11 +149,41 @@ function mapFormToPayload(values: InvoiceFormValues) {
|
||||
customer_id: toId(values.customer),
|
||||
vehicle_id: toId(values.vehicle),
|
||||
department_id: toId(values.department),
|
||||
estimate_id: toId(values.estimate),
|
||||
payment_terms_id: toId(values.payment_terms),
|
||||
invoice_sequence_id: toId(values.invoice_sequence),
|
||||
payment_mode_id: toId(values.payment_mode),
|
||||
insurer_id: toId(values.insurer),
|
||||
invoice_to_id: toId(values.invoice_to),
|
||||
invoice_number: values.invoice_number || undefined,
|
||||
invoice_title: values.invoice_title || undefined,
|
||||
invoice_date: values.invoice_date || undefined,
|
||||
due_date: values.due_date || undefined,
|
||||
status: values.status || undefined,
|
||||
kms_in: values.kms_in || undefined,
|
||||
has_insurance: values.has_insurance,
|
||||
discount: values.discount || undefined,
|
||||
deposit_to: values.deposit_to || undefined,
|
||||
notes: values.notes || undefined,
|
||||
terms_and_conditions: values.terms_and_conditions || undefined,
|
||||
parts: (values.parts ?? []).map((item) => ({
|
||||
part_id: item.part_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
services: (values.services ?? []).map((item) => ({
|
||||
service_id: item.service_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
expenses: (values.expense_items ?? []).map((item) => ({
|
||||
expense_id: item.expense_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +191,28 @@ function mapFormToPayload(values: InvoiceFormValues) {
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
label: item.name ?? item.title ?? `#${item.id}`,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Insurance fields (conditionally rendered) ──
|
||||
|
||||
function InsurerField() {
|
||||
const { watch } = useFormContext<InvoiceFormValues>()
|
||||
const hasInsurance = watch("has_insurance")
|
||||
|
||||
if (!hasInsurance) return null
|
||||
|
||||
return (
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "insurer">
|
||||
name="insurer"
|
||||
label="Insurer"
|
||||
placeholder="Select insurer..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormProps) {
|
||||
@@ -146,36 +262,104 @@ 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 table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" />
|
||||
<RhfSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
options={STATUS_OPTIONS}
|
||||
<RhfAutoGenerateField autoFetch table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" />
|
||||
<RhfTextField name="invoice_title" label="Invoice Title" placeholder="e.g. Tax Invoice" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
|
||||
<RhfSelectField name="discount" label="Discount" options={DISCOUNT_OPTIONS} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfDateField name="invoice_date" label="Invoice Date" />
|
||||
<RhfDateField name="due_date" label="Due Date" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "invoice_to">
|
||||
name="invoice_to"
|
||||
label="Invoice To"
|
||||
placeholder="Select billing contact..."
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="invoice_date" label="Invoice Date" type="date" />
|
||||
<RhfTextField name="due_date" label="Due Date" type="date" />
|
||||
<RhfAsyncSelectField
|
||||
name="estimate"
|
||||
label="Estimate"
|
||||
placeholder="Select estimate"
|
||||
queryKey={[ESTIMATE_ROUTES.INDEX]}
|
||||
listFn={() => api.estimates.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title||item.estimate_number || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="payment_terms"
|
||||
label="Payment Terms"
|
||||
placeholder="Select payment terms"
|
||||
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentTerms.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
<RhfAsyncSelectField
|
||||
name="invoice_sequence"
|
||||
label="Invoice Sequence"
|
||||
placeholder="Select sequence"
|
||||
queryKey={[INVOICE_SEQUENCE_ROUTES.INDEX]}
|
||||
listFn={() => api.invoiceSequences.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="kms_in" label="KMs In" type="number" placeholder="e.g. 50000" />
|
||||
</div>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="payment_mode"
|
||||
label="Payment Mode"
|
||||
placeholder="Select payment mode"
|
||||
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentModes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="deposit_to" label="Deposit To" placeholder="e.g. Main Account" />
|
||||
</div>
|
||||
|
||||
<RhfCheckboxField name="has_insurance" label="Has Insurance" />
|
||||
<InsurerField />
|
||||
|
||||
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes" rows={3} />
|
||||
<RhfTextareaField name="terms_and_conditions" label="Terms & Conditions" rows={3} />
|
||||
|
||||
<PartsSelectorField<InvoiceFormValues, "parts"> name="parts" />
|
||||
<ServicesSelectorField<InvoiceFormValues, "services"> name="services" />
|
||||
<ExpenseItemsSelectorField<InvoiceFormValues, "expense_items"> name="expense_items" />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
Building2,
|
||||
CircleDollarSign,
|
||||
Clock,
|
||||
Mail,
|
||||
Phone,
|
||||
DollarSign,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
@@ -16,21 +19,38 @@ import {
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { formatDate, formatCurrency, formatEnum, formatNumber } from "@/shared/utils/formatters"
|
||||
|
||||
type InvoiceData = {
|
||||
id?: number
|
||||
subject?: string
|
||||
invoice_number?: string
|
||||
invoice_title?: string
|
||||
invoice_date?: string
|
||||
due_date?: string
|
||||
status?: string
|
||||
notes?: string
|
||||
terms_and_conditions?: string
|
||||
customer_name?: string
|
||||
customer_id?: number
|
||||
customer?: any
|
||||
vehicle_name?: string
|
||||
vehicle_id?: number
|
||||
vehicle?: any
|
||||
department_name?: string
|
||||
department_id?: number
|
||||
payment_terms_id?: number
|
||||
payment_mode_id?: number
|
||||
amount?: number | string | null
|
||||
received_payment?: number | string | null
|
||||
discount?: string
|
||||
has_insurance?: number | boolean
|
||||
insurer_id?: number | null
|
||||
insurer?: any
|
||||
kms_in?: number | null
|
||||
invoice_to_id?: number | null
|
||||
billing_address_id?: number | null
|
||||
delivery_address_id?: number | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
@@ -72,8 +92,12 @@ const statusColorMap: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
const customer = invoice.customer || {}
|
||||
const vehicle = invoice.vehicle || {}
|
||||
const insurer = invoice.insurer || {}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="grid gap-6">
|
||||
{/* Invoice Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -89,12 +113,12 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
)}
|
||||
{invoice.status && (
|
||||
<Badge variant={statusColorMap[invoice.status] as any ?? "outline"}>
|
||||
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||
{formatEnum(invoice.status)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Invoice Number"
|
||||
@@ -103,59 +127,209 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Invoice Date"
|
||||
value={invoice.invoice_date}
|
||||
value={formatDate(invoice.invoice_date)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Due Date"
|
||||
value={invoice.due_date}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="Created"
|
||||
value={invoice.created_at ? new Date(invoice.created_at).toLocaleDateString() : null}
|
||||
value={formatDate(invoice.due_date)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Relations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CircleDollarSign className="size-4" />
|
||||
Related Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Users}
|
||||
label="Customer"
|
||||
value={invoice.customer_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Car}
|
||||
label="Vehicle"
|
||||
value={invoice.vehicle_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={invoice.department_name}
|
||||
/>
|
||||
</div>
|
||||
{/* Customer & Vehicle Information */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Customer Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="size-4" />
|
||||
Customer Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Users}
|
||||
label="Customer Name"
|
||||
value={customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : invoice.customer_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Mail}
|
||||
label="Email"
|
||||
value={customer.email}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Phone}
|
||||
label="Phone"
|
||||
value={customer.phone}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Phone}
|
||||
label="Alternate Phone"
|
||||
value={customer.alternate_phone}
|
||||
/>
|
||||
</div>
|
||||
{customer.address_line_1 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Address</span>
|
||||
<p className="text-sm">
|
||||
{customer.address_line_1}
|
||||
{customer.address_line_2 ? `, ${customer.address_line_2}` : ""}
|
||||
<br />
|
||||
{customer.city ? `${customer.city}` : ""}
|
||||
{customer.zip_code ? `, ${customer.zip_code}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vehicle Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Car className="size-4" />
|
||||
Vehicle Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Car}
|
||||
label="Vehicle"
|
||||
value={vehicle.make && vehicle.model ? `${vehicle.make} ${vehicle.model}` : invoice.vehicle_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="License Plate"
|
||||
value={vehicle.license_plate}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="VIN"
|
||||
value={vehicle.vin_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Engine Number"
|
||||
value={vehicle.engine_number}
|
||||
/>
|
||||
</div>
|
||||
{vehicle.mileage && (
|
||||
<>
|
||||
<Separator />
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="Mileage"
|
||||
value={formatNumber(vehicle.mileage)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Payment & Insurance Information */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Payment Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="size-4" />
|
||||
Payment Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={CircleDollarSign}
|
||||
label="Amount"
|
||||
value={invoice.amount ? formatCurrency(invoice.amount) : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={CircleDollarSign}
|
||||
label="Received Payment"
|
||||
value={invoice.received_payment ? formatCurrency(invoice.received_payment) : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Discount"
|
||||
value={invoice.discount}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Insurance & Additional Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" />
|
||||
Additional Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={invoice.department_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Has Insurance"
|
||||
value={invoice.has_insurance ? "Yes" : "No"}
|
||||
/>
|
||||
{invoice.has_insurance && insurer.id && (
|
||||
<InfoItem
|
||||
icon={Users}
|
||||
label="Insurer"
|
||||
value={insurer.name}
|
||||
/>
|
||||
)}
|
||||
{invoice.kms_in && (
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="KMs In"
|
||||
value={formatNumber(invoice.kms_in)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Notes & Terms */}
|
||||
{(invoice.notes || invoice.terms_and_conditions) && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{invoice.notes && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Notes</span>
|
||||
<p className="text-sm">{invoice.notes}</p>
|
||||
</div>
|
||||
</>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{invoice.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{invoice.terms_and_conditions && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Terms & Conditions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{invoice.terms_and_conditions}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import { Wrench } from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { formatCurrency, formatNumber } from "@/shared/utils/formatters"
|
||||
|
||||
type InvoicePart = {
|
||||
id: number
|
||||
invoice_id: number
|
||||
part_id: number
|
||||
quantity: string | number
|
||||
rate: string | number
|
||||
description?: string
|
||||
chart_of_account?: string
|
||||
department_id?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type InvoicePartsSectionProps = {
|
||||
parts?: InvoicePart[]
|
||||
}
|
||||
|
||||
export function InvoicePartsSection({ parts = [] }: InvoicePartsSectionProps) {
|
||||
if (!parts || parts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const subtotal = parts.reduce((sum, part) => {
|
||||
const qty = parseFloat(String(part.quantity))
|
||||
const rate = parseFloat(String(part.rate))
|
||||
return sum + (qty * rate)
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wrench className="size-4" />
|
||||
Parts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Quantity</TableHead>
|
||||
<TableHead className="text-right">Rate</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parts.map((part) => {
|
||||
const qty = parseFloat(String(part.quantity))
|
||||
const rate = parseFloat(String(part.rate))
|
||||
const amount = qty * rate
|
||||
return (
|
||||
<TableRow key={part.id}>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{part.description || `Part #${part.part_id}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(qty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(rate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatCurrency(amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell colSpan={3} className="text-right">
|
||||
Subtotal
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(subtotal)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import { Briefcase } from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { formatCurrency, formatNumber } from "@/shared/utils/formatters"
|
||||
|
||||
type InvoiceService = {
|
||||
id: number
|
||||
invoice_id: number
|
||||
service_id: number
|
||||
quantity: string | number
|
||||
rate: string | number
|
||||
description?: string
|
||||
chart_of_account?: string
|
||||
department_id?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type InvoiceServicesSectionProps = {
|
||||
services?: InvoiceService[]
|
||||
}
|
||||
|
||||
export function InvoiceServicesSection({ services = [] }: InvoiceServicesSectionProps) {
|
||||
if (!services || services.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const subtotal = services.reduce((sum, service) => {
|
||||
const qty = parseFloat(String(service.quantity))
|
||||
const rate = parseFloat(String(service.rate))
|
||||
return sum + (qty * rate)
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Briefcase className="size-4" />
|
||||
Services
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Quantity</TableHead>
|
||||
<TableHead className="text-right">Rate</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map((service) => {
|
||||
const qty = parseFloat(String(service.quantity))
|
||||
const rate = parseFloat(String(service.rate))
|
||||
const amount = qty * rate
|
||||
return (
|
||||
<TableRow key={service.id}>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{service.description || `Service #${service.service_id}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(qty)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(rate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatCurrency(amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell colSpan={3} className="text-right">
|
||||
Subtotal
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(subtotal)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,30 @@ const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
const invoicePartItemSchema = z.object({
|
||||
part_id: z.number(),
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const invoiceServiceItemSchema = z.object({
|
||||
service_id: z.number(),
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const invoiceExpenseItemSchema = z.object({
|
||||
expense_id: z.number(),
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const invoiceFormSchema = z.object({
|
||||
// ── Required fields ──
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
@@ -12,16 +36,36 @@ const invoiceFormSchema = z.object({
|
||||
customer: relationFieldSchema,
|
||||
vehicle: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
estimate: relationFieldSchema,
|
||||
payment_terms: relationFieldSchema,
|
||||
invoice_sequence: relationFieldSchema,
|
||||
payment_mode: relationFieldSchema,
|
||||
insurer: relationFieldSchema,
|
||||
invoice_to: relationFieldSchema,
|
||||
|
||||
// ── Optional fields ──
|
||||
invoice_number: z.string().optional(),
|
||||
invoice_title: z.string().optional(),
|
||||
invoice_date: z.string().optional(),
|
||||
due_date: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
kms_in: z.coerce.number().optional(),
|
||||
has_insurance: z.boolean().default(false),
|
||||
discount: z.string().optional(),
|
||||
deposit_to: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
terms_and_conditions: z.string().optional(),
|
||||
|
||||
// ── Line items ──
|
||||
parts: z.array(invoicePartItemSchema).optional(),
|
||||
services: z.array(invoiceServiceItemSchema).optional(),
|
||||
expense_items: z.array(invoiceExpenseItemSchema).optional(),
|
||||
})
|
||||
|
||||
type InvoiceFormValues = z.infer<typeof invoiceFormSchema>
|
||||
type InvoicePartItem = z.infer<typeof invoicePartItemSchema>
|
||||
type InvoiceServiceItem = z.infer<typeof invoiceServiceItemSchema>
|
||||
type InvoiceExpenseItem = z.infer<typeof invoiceExpenseItemSchema>
|
||||
|
||||
export { invoiceFormSchema, relationFieldSchema }
|
||||
export type { InvoiceFormValues }
|
||||
export { invoiceFormSchema, relationFieldSchema, invoicePartItemSchema, invoiceServiceItemSchema, invoiceExpenseItemSchema }
|
||||
export type { InvoiceFormValues, InvoicePartItem, InvoiceServiceItem, InvoiceExpenseItem }
|
||||
|
||||
Reference in New Issue
Block a user