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
@@ -1,5 +1,8 @@
import { getServerApi } from '@garage/api/server'
import { InvoiceGeneralInfo } from '@/modules/invoices/invoice-general-info'
import { InvoicePartsSection } from '@/modules/invoices/invoice-parts-section'
import { InvoiceServicesSection } from '@/modules/invoices/invoice-services-section'
import { InvoiceExpensesSection } from '@/modules/invoices/invoice-expenses-section'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function InvoiceDetailPage(props: { params: Promise<{ id: string }> }) {
@@ -14,7 +17,12 @@ export default async function InvoiceDetailPage(props: { params: Promise<{ id: s
return (
<DashboardPage header={null}>
<InvoiceGeneralInfo invoice={data} />
<div className="grid gap-6">
<InvoiceGeneralInfo invoice={data} />
<InvoicePartsSection parts={data.invoice_parts} />
<InvoiceServicesSection services={data.invoice_services} />
<InvoiceExpensesSection expenses={data.invoice_expenses} />
</div>
</DashboardPage>
)
}
@@ -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>
)
}
+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 }