From f17dd1486c176e91bd6c059045232d8b372dbf68 Mon Sep 17 00:00:00 2001 From: Mohammad Khyata Date: Thu, 16 Apr 2026 11:42:11 +0300 Subject: [PATCH] fix invoice informations --- .../sales/invoice/[id]/page.tsx | 10 +- .../create-invoice-from-estimate-button.tsx | 91 +- .../estimate-expense-items-section.tsx | 5 +- .../estimates/estimate-general-info.tsx | 42 +- .../estimates/estimate-parts-section.tsx | 5 +- .../estimates/estimate-services-section.tsx | 5 +- .../invoices/invoice-expenses-section.tsx | 103 ++ .../modules/invoices/invoice-form.tsx | 230 ++++- .../modules/invoices/invoice-general-info.tsx | 264 ++++- .../invoices/invoice-parts-section.tsx | 103 ++ .../invoices/invoice-services-section.tsx | 103 ++ .../modules/invoices/invoice.schema.ts | 48 +- packages/api/open-api/schema.json | 900 +++++++++++++++++- packages/api/package.json | 11 +- packages/api/postman/collection.json | 107 ++- packages/api/src/clients/estimates.ts | 8 +- packages/api/src/infra/client.ts | 55 +- packages/api/src/infra/logger.ts | 32 + packages/api/types/index.ts | 489 +++++++++- pnpm-lock.yaml | 146 ++- 20 files changed, 2579 insertions(+), 178 deletions(-) create mode 100644 apps/dashboard/modules/invoices/invoice-expenses-section.tsx create mode 100644 apps/dashboard/modules/invoices/invoice-parts-section.tsx create mode 100644 apps/dashboard/modules/invoices/invoice-services-section.tsx create mode 100644 packages/api/src/infra/logger.ts diff --git a/apps/dashboard/app/(authenticated)/sales/invoice/[id]/page.tsx b/apps/dashboard/app/(authenticated)/sales/invoice/[id]/page.tsx index 362593d..9a51427 100644 --- a/apps/dashboard/app/(authenticated)/sales/invoice/[id]/page.tsx +++ b/apps/dashboard/app/(authenticated)/sales/invoice/[id]/page.tsx @@ -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 ( - +
+ + + + +
) } diff --git a/apps/dashboard/modules/estimates/create-invoice-from-estimate-button.tsx b/apps/dashboard/modules/estimates/create-invoice-from-estimate-button.tsx index f02048d..91d629f 100644 --- a/apps/dashboard/modules/estimates/create-invoice-from-estimate-button.tsx +++ b/apps/dashboard/modules/estimates/create-invoice-from-estimate-button.tsx @@ -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) { +function mapEstimateToInvoiceInitialData( + estimate: Record, + 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 ( diff --git a/apps/dashboard/modules/estimates/estimate-expense-items-section.tsx b/apps/dashboard/modules/estimates/estimate-expense-items-section.tsx index 16a9228..4b8f071 100644 --- a/apps/dashboard/modules/estimates/estimate-expense-items-section.tsx +++ b/apps/dashboard/modules/estimates/estimate-expense-items-section.tsx @@ -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 ( @@ -112,7 +113,7 @@ export function EstimateExpenseItemsSection({ estimateId }: { estimateId: string - Item + Item Qty Rate Description diff --git a/apps/dashboard/modules/estimates/estimate-general-info.tsx b/apps/dashboard/modules/estimates/estimate-general-info.tsx index e05445f..f3e2fe2 100644 --- a/apps/dashboard/modules/estimates/estimate-general-info.tsx +++ b/apps/dashboard/modules/estimates/estimate-general-info.tsx @@ -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) {
- + + + diff --git a/apps/dashboard/modules/estimates/estimate-parts-section.tsx b/apps/dashboard/modules/estimates/estimate-parts-section.tsx index 7b3745a..7e7ccb3 100644 --- a/apps/dashboard/modules/estimates/estimate-parts-section.tsx +++ b/apps/dashboard/modules/estimates/estimate-parts-section.tsx @@ -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 ( @@ -111,7 +112,7 @@ export function EstimatePartsSection({ estimateId }: { estimateId: string }) {
- Part + Part Qty Rate Description diff --git a/apps/dashboard/modules/estimates/estimate-services-section.tsx b/apps/dashboard/modules/estimates/estimate-services-section.tsx index 0f08570..ddab262 100644 --- a/apps/dashboard/modules/estimates/estimate-services-section.tsx +++ b/apps/dashboard/modules/estimates/estimate-services-section.tsx @@ -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 ( @@ -112,7 +113,7 @@ export function EstimateServicesSection({ estimateId }: { estimateId: string })
- Service + Service Qty Rate Description diff --git a/apps/dashboard/modules/invoices/invoice-expenses-section.tsx b/apps/dashboard/modules/invoices/invoice-expenses-section.tsx new file mode 100644 index 0000000..b7e024c --- /dev/null +++ b/apps/dashboard/modules/invoices/invoice-expenses-section.tsx @@ -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 ( + + + + + Expenses + + + +
+
+ + + Description + Quantity + Rate + Amount + + + + {expenses.map((expense) => { + const qty = parseFloat(String(expense.quantity)) + const rate = parseFloat(String(expense.rate)) + const amount = qty * rate + return ( + + + {expense.description || `Expense #${expense.expense_item_id}`} + + + {formatNumber(qty)} + + + {formatCurrency(rate)} + + + {formatCurrency(amount)} + + + ) + })} + + + Subtotal + + + {formatCurrency(subtotal)} + + + +
+ + +
+ ) +} diff --git a/apps/dashboard/modules/invoices/invoice-form.tsx b/apps/dashboard/modules/invoices/invoice-form.tsx index 0270ab8..18dc8a8 100644 --- a/apps/dashboard/modules/invoices/invoice-form.tsx +++ b/apps/dashboard/modules/invoices/invoice-form.tsx @@ -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() + const hasInsurance = watch("has_insurance") + + if (!hasInsurance) return null + + return ( + + 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
- - + +
+ +
+ + +
+ +
+ + +
+ +
+ name="customer" /> + +
+ +
+ + name="invoice_to" + label="Invoice To" + placeholder="Select billing contact..." + /> + api.departments.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} />
- - + api.estimates.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.title||item.estimate_number || `#${item.id}`, + })} + {...STORE_OBJECT} + /> + api.paymentTerms.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + />
- - + api.invoiceSequences.list()} + mapOption={(item: any) => ({ + value: String(item.id), + label: item.title || `#${item.id}`, + })} + {...STORE_OBJECT} + /> +
- api.departments.list()} - mapOption={mapLookupOption} - {...STORE_OBJECT} - /> +
+ api.paymentModes.list()} + mapOption={mapLookupOption} + {...STORE_OBJECT} + /> + +
+ + + + + + name="parts" /> + name="services" /> + name="expense_items" />