fix invoice informations

This commit is contained in:
2026-04-16 11:42:11 +03:00
parent 973149e974
commit f17dd1486c
20 changed files with 2579 additions and 178 deletions
@@ -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>
)
}
+207 -23
View File
@@ -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 }