fix invoice informations

This commit is contained in:
Mohammad Khyata 2026-04-16 11:42:11 +03:00
parent 973149e974
commit f17dd1486c
20 changed files with 2579 additions and 178 deletions

View File

@ -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>
)
}

View File

@ -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 (

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
)
}

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 />}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 }

View File

@ -462,8 +462,8 @@
],
"summary": "GET /api/profile",
"responses": {
"200": {
"description": "OK",
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
@ -21668,6 +21668,290 @@
}
},
"/api/estimates/{id}": {
"get": {
"tags": [
"Estimates"
],
"summary": "Display the specified estimate.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
},
"customer_id": {
"type": "integer"
},
"vehicle_id": {
"type": "integer"
},
"department_id": {
"type": "integer"
},
"estimate_number": {
"type": "string"
},
"date": {
"type": "string"
},
"has_insurance": {
"type": "boolean"
},
"enable_digital_authorisation": {
"type": "boolean"
},
"insurance_type_id": {
"type": "integer"
},
"insurer_id": {
"type": "integer"
},
"service_writer_id": {
"type": "integer"
},
"footer": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"labels": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
},
"color_code": {
"type": "string"
},
"pivot": {
"type": "object",
"properties": {
"estimate_id": {
"type": "integer"
},
"label_id": {
"type": "integer"
}
}
}
}
}
},
"customer_remarks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"estimate_id": {
"type": "integer"
},
"remark": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
}
},
"customer": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
}
}
},
"vehicle": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"registration_number": {
"type": "string"
},
"make": {
"type": "string"
},
"model": {
"type": "string"
}
}
},
"department": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
},
"insurance_type": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
}
}
},
"insurer": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
}
}
},
"service_writer": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
}
}
}
}
}
}
},
"example": {
"data": {
"id": 1,
"title": "Estimate for Toyota Camry",
"customer_id": 1,
"vehicle_id": 1,
"department_id": 1,
"estimate_number": "EST-001",
"date": "2026-03-31",
"has_insurance": false,
"enable_digital_authorisation": false,
"insurance_type_id": 1,
"insurer_id": 2,
"service_writer_id": 1,
"footer": "Thank you for your business.",
"created_at": "2026-03-31T10:00:00.000000Z",
"updated_at": "2026-03-31T10:00:00.000000Z",
"labels": [
{
"id": 1,
"title": "Urgent",
"color_code": "#FF0000",
"pivot": {
"estimate_id": 1,
"label_id": 1
}
}
],
"customer_remarks": [
{
"id": 1,
"estimate_id": 1,
"remark": "Oil change recommended.",
"created_at": "2026-03-31T10:00:00.000000Z",
"updated_at": "2026-03-31T10:00:00.000000Z"
}
],
"customer": {
"id": 1,
"first_name": "John",
"last_name": "Doe"
},
"vehicle": {
"id": 1,
"registration_number": "ABC-1234",
"make": "Toyota",
"model": "Camry"
},
"department": {
"id": 1,
"name": "Service"
},
"insurance_type": {
"id": 1,
"title": "Comprehensive"
},
"insurer": {
"id": 2,
"first_name": "Alex",
"last_name": "Insurer"
},
"service_writer": {
"id": 1,
"first_name": "Sam",
"last_name": "Writer"
}
}
}
}
}
}
}
},
"put": {
"tags": [
"Estimates"
@ -42973,6 +43257,24 @@
"estimate_id": {
"type": "integer"
},
"kms_in": {
"type": "integer"
},
"has_insurance": {
"type": "boolean"
},
"insurer_id": {
"type": "integer"
},
"invoice_to_id": {
"type": "integer"
},
"billing_address_id": {
"type": "integer"
},
"delivery_address_id": {
"type": "integer"
},
"invoice_date": {
"type": "string"
},
@ -42988,17 +43290,195 @@
"invoice_number": {
"type": "string"
},
"invoice_title": {
"type": "string"
},
"department_id": {
"type": "integer"
},
"notes": {
"type": "string"
},
"terms_and_conditions": {
"type": "string"
},
"status": {
"type": "string"
},
"received_payment": {
"type": "boolean"
},
"payment_mode_id": {
"type": "integer"
},
"deposit_to": {
"type": "string"
},
"amount": {
"type": "integer"
},
"discount": {
"type": "string"
},
"inspection_categories": {
"type": "array",
"items": {
"type": "object",
"properties": {
"inspection_category_id": {
"type": "integer"
},
"rate_type": {
"type": "string"
},
"labor_rate": {
"type": "integer"
},
"working_hours": {
"type": "integer"
},
"labor_hours": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
},
"parts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"part_id": {
"type": "integer"
},
"quantity": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
},
"expenses": {
"type": "array",
"items": {
"type": "object",
"properties": {
"expense_id": {
"type": "integer"
},
"quantity": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
},
"services": {
"type": "array",
"items": {
"type": "object",
"properties": {
"service_id": {
"type": "integer"
},
"rate_type": {
"type": "string"
},
"labor_rate_id": {
"type": "integer"
},
"working_hours": {
"type": "integer"
},
"labor_hours": {
"type": "integer"
},
"quantity": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
},
"service_groups": {
"type": "array",
"items": {
"type": "object",
"properties": {
"service_group_id": {
"type": "integer"
},
"rate_type": {
"type": "string"
},
"labor_rate_id": {
"type": "integer"
},
"working_hours": {
"type": "integer"
},
"labor_hours": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
}
}
},
@ -43007,22 +43487,94 @@
"customer_id": 1,
"vehicle_id": 1,
"estimate_id": 1,
"kms_in": 50000,
"has_insurance": false,
"insurer_id": 2,
"invoice_to_id": 1,
"billing_address_id": 10,
"delivery_address_id": 11,
"invoice_date": "2026-03-31",
"due_date": "2026-04-14",
"payment_terms_id": 1,
"invoice_sequence_id": 1,
"invoice_number": "INV-001",
"invoice_title": "Tax Invoice",
"department_id": 1,
"notes": "string",
"notes": "Vehicle service and parts",
"terms_and_conditions": "Payment due in 14 days.",
"status": "draft",
"discount": "no"
"received_payment": false,
"payment_mode_id": 1,
"deposit_to": "Main Account",
"amount": 2500,
"discount": "no",
"inspection_categories": [
{
"inspection_category_id": 1,
"rate_type": "flat_rate",
"labor_rate": 500,
"working_hours": 1,
"labor_hours": 1,
"rate": 500,
"chart_of_account": "4000",
"description": "General inspection",
"department_id": 1
}
],
"parts": [
{
"part_id": 1,
"quantity": 2,
"rate": 150,
"chart_of_account": "4100",
"description": "Oil filter",
"department_id": 1
}
],
"expenses": [
{
"expense_id": 1,
"quantity": 1,
"rate": 100,
"chart_of_account": "4200",
"description": "Shop supplies",
"department_id": 1
}
],
"services": [
{
"service_id": 1,
"rate_type": "hourly",
"labor_rate_id": 1,
"working_hours": 2,
"labor_hours": 2,
"quantity": 1,
"rate": 800,
"chart_of_account": "4300",
"description": "Engine service",
"department_id": 1
}
],
"service_groups": [
{
"service_group_id": 1,
"rate_type": "flat_rate",
"labor_rate_id": 1,
"working_hours": 1,
"labor_hours": 1,
"rate": 600,
"chart_of_account": "4400",
"description": "Major service package",
"department_id": 1
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
@ -43098,7 +43650,7 @@
"type": "string"
},
"received_payment": {
"type": "integer"
"type": "boolean"
},
"payment_mode_id": {
"type": "integer"
@ -43119,25 +43671,253 @@
"updated_at": {
"type": "string",
"format": "date-time"
},
"customer": {
"type": "object",
"properties": {
"id": {
"type": "integer"
}
}
},
"vehicle": {
"type": "object",
"properties": {
"id": {
"type": "integer"
}
}
},
"invoice_sequence": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
}
}
},
"department": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
},
"invoice_inspection_categories": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"invoice_id": {
"type": "integer"
},
"inspection_category_id": {
"type": "integer"
},
"rate_type": {
"type": "string"
},
"labor_rate": {
"type": "integer"
},
"working_hours": {
"type": "integer"
},
"labor_hours": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
},
"invoice_parts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"invoice_id": {
"type": "integer"
},
"part_id": {
"type": "integer"
},
"quantity": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
},
"invoice_expenses": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"invoice_id": {
"type": "integer"
},
"expense_id": {
"type": "integer"
},
"quantity": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
},
"invoice_services": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"invoice_id": {
"type": "integer"
},
"service_id": {
"type": "integer"
},
"rate_type": {
"type": "string"
},
"labor_rate_id": {
"type": "integer"
},
"working_hours": {
"type": "integer"
},
"labor_hours": {
"type": "integer"
},
"quantity": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
},
"invoice_service_groups": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"invoice_id": {
"type": "integer"
},
"service_group_id": {
"type": "integer"
},
"rate_type": {
"type": "string"
},
"labor_rate_id": {
"type": "integer"
},
"working_hours": {
"type": "integer"
},
"labor_hours": {
"type": "integer"
},
"rate": {
"type": "integer"
},
"chart_of_account": {
"type": "string"
},
"description": {
"type": "string"
},
"department_id": {
"type": "integer"
}
}
}
}
}
}
}
},
"example": {
"message": "Operation completed successfully.",
"message": "Invoice created successfully.",
"data": {
"id": 1,
"subject": "Invoice for Job Card 001",
"subject": "Invoice for Service",
"customer_id": 1,
"vehicle_id": 1,
"estimate_id": 1,
"kms_in": 50000,
"has_insurance": false,
"insurer_id": 1,
"insurer_id": 2,
"invoice_to_id": 1,
"billing_address_id": 1,
"delivery_address_id": 1,
"billing_address_id": 10,
"delivery_address_id": 11,
"invoice_date": "2026-03-31",
"due_date": "2026-04-14",
"payment_terms_id": 1,
@ -43145,16 +43925,100 @@
"invoice_number": "INV-001",
"invoice_title": "Tax Invoice",
"department_id": 1,
"notes": "string",
"terms_and_conditions": "string",
"notes": "Vehicle service and parts",
"terms_and_conditions": "Payment due in 14 days.",
"status": "draft",
"received_payment": 0,
"received_payment": false,
"payment_mode_id": 1,
"deposit_to": "string",
"amount": 0,
"deposit_to": "Main Account",
"amount": 2500,
"discount": "no",
"created_at": "2026-03-31T10:00:00.000000Z",
"updated_at": "2026-03-31T10:00:00.000000Z"
"updated_at": "2026-03-31T10:00:00.000000Z",
"customer": {
"id": 1
},
"vehicle": {
"id": 1
},
"invoice_sequence": {
"id": 1,
"title": "INV"
},
"department": {
"id": 1,
"name": "Service"
},
"invoice_inspection_categories": [
{
"id": 1,
"invoice_id": 1,
"inspection_category_id": 1,
"rate_type": "flat_rate",
"labor_rate": 500,
"working_hours": 1,
"labor_hours": 1,
"rate": 500,
"chart_of_account": "4000",
"description": "General inspection",
"department_id": 1
}
],
"invoice_parts": [
{
"id": 1,
"invoice_id": 1,
"part_id": 1,
"quantity": 2,
"rate": 150,
"chart_of_account": "4100",
"description": "Oil filter",
"department_id": 1
}
],
"invoice_expenses": [
{
"id": 1,
"invoice_id": 1,
"expense_id": 1,
"quantity": 1,
"rate": 100,
"chart_of_account": "4200",
"description": "Shop supplies",
"department_id": 1
}
],
"invoice_services": [
{
"id": 1,
"invoice_id": 1,
"service_id": 1,
"rate_type": "hourly",
"labor_rate_id": 1,
"working_hours": 2,
"labor_hours": 2,
"quantity": 1,
"rate": 800,
"chart_of_account": "4300",
"description": "Engine service",
"department_id": 1
}
],
"invoice_service_groups": [
{
"id": 1,
"invoice_id": 1,
"service_group_id": 1,
"rate_type": "flat_rate",
"labor_rate_id": 1,
"working_hours": 1,
"labor_hours": 1,
"rate": 600,
"chart_of_account": "4400",
"description": "Major service package",
"department_id": 1
}
]
}
}
}

View File

@ -23,7 +23,8 @@
"check-types": "echo \"No typecheck configured for @garage/api\""
},
"dependencies": {
"openapi-fetch": "^0.14.0"
"openapi-fetch": "^0.14.0",
"pino": "^10.3.1"
},
"devDependencies": {
"openapi-typescript": "^7.10.1"
@ -33,7 +34,11 @@
"server-only": "*"
},
"peerDependenciesMeta": {
"next": { "optional": true },
"server-only": { "optional": true }
"next": {
"optional": true
},
"server-only": {
"optional": true
}
}
}

View File

@ -1,6 +1,6 @@
{
"info": {
"_postman_id": "351b2d30-cb82-4904-9a47-0d356181a8ea",
"_postman_id": "38ac2be3-7ab6-4dcc-ad76-6112212395af",
"name": "Reparee Collection",
"description": "Auto-generated from OpenAPI spec. Import storage/app/openapi-default.json for the full schema.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
@ -135,7 +135,7 @@
},
"response": [
{
"name": "200 OK",
"name": "201 Created",
"originalRequest": {
"auth": {
"type": "bearer",
@ -169,8 +169,8 @@
]
}
},
"status": "OK",
"code": 200,
"status": "Created",
"code": 201,
"_postman_previewlanguage": "json",
"header": [
{
@ -14953,6 +14953,93 @@
}
]
},
{
"name": "Display the specified estimate.",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{auth_token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{base_url}}/api/estimates/{{id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"estimates",
"{{id}}"
]
}
},
"response": [
{
"name": "200 OK",
"originalRequest": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{auth_token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{base_url}}/api/estimates/{{id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"estimates",
"{{id}}"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"data\": {\n \"id\": 1,\n \"title\": \"Estimate for Toyota Camry\",\n \"customer_id\": 1,\n \"vehicle_id\": 1,\n \"department_id\": 1,\n \"estimate_number\": \"EST-001\",\n \"date\": \"2026-03-31\",\n \"has_insurance\": false,\n \"enable_digital_authorisation\": false,\n \"insurance_type_id\": 1,\n \"insurer_id\": 2,\n \"service_writer_id\": 1,\n \"footer\": \"Thank you for your business.\",\n \"created_at\": \"2026-03-31T10:00:00.000000Z\",\n \"updated_at\": \"2026-03-31T10:00:00.000000Z\",\n \"labels\": [\n {\n \"id\": 1,\n \"title\": \"Urgent\",\n \"color_code\": \"#FF0000\",\n \"pivot\": {\n \"estimate_id\": 1,\n \"label_id\": 1\n }\n }\n ],\n \"customer_remarks\": [\n {\n \"id\": 1,\n \"estimate_id\": 1,\n \"remark\": \"Oil change recommended.\",\n \"created_at\": \"2026-03-31T10:00:00.000000Z\",\n \"updated_at\": \"2026-03-31T10:00:00.000000Z\"\n }\n ],\n \"customer\": {\n \"id\": 1,\n \"first_name\": \"John\",\n \"last_name\": \"Doe\"\n },\n \"vehicle\": {\n \"id\": 1,\n \"registration_number\": \"ABC-1234\",\n \"make\": \"Toyota\",\n \"model\": \"Camry\"\n },\n \"department\": {\n \"id\": 1,\n \"name\": \"Service\"\n },\n \"insurance_type\": {\n \"id\": 1,\n \"title\": \"Comprehensive\"\n },\n \"insurer\": {\n \"id\": 2,\n \"first_name\": \"Alex\",\n \"last_name\": \"Insurer\"\n },\n \"service_writer\": {\n \"id\": 1,\n \"first_name\": \"Sam\",\n \"last_name\": \"Writer\"\n }\n }\n}"
}
]
},
{
"name": "Store a newly created estimate.",
"request": {
@ -33569,7 +33656,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"subject\": \"Invoice for Service\",\n \"customer_id\": 1,\n \"vehicle_id\": 1,\n \"estimate_id\": 1,\n \"invoice_date\": \"2026-03-31\",\n \"due_date\": \"2026-04-14\",\n \"payment_terms_id\": 1,\n \"invoice_sequence_id\": 1,\n \"invoice_number\": \"INV-001\",\n \"department_id\": 1,\n \"notes\": \"string\",\n \"status\": \"draft\",\n \"discount\": \"no\"\n}",
"raw": "{\n \"subject\": \"Invoice for Service\",\n \"customer_id\": 1,\n \"vehicle_id\": 1,\n \"estimate_id\": 1,\n \"kms_in\": 50000,\n \"has_insurance\": false,\n \"insurer_id\": 2,\n \"invoice_to_id\": 1,\n \"billing_address_id\": 10,\n \"delivery_address_id\": 11,\n \"invoice_date\": \"2026-03-31\",\n \"due_date\": \"2026-04-14\",\n \"payment_terms_id\": 1,\n \"invoice_sequence_id\": 1,\n \"invoice_number\": \"INV-001\",\n \"invoice_title\": \"Tax Invoice\",\n \"department_id\": 1,\n \"notes\": \"Vehicle service and parts\",\n \"terms_and_conditions\": \"Payment due in 14 days.\",\n \"status\": \"draft\",\n \"received_payment\": false,\n \"payment_mode_id\": 1,\n \"deposit_to\": \"Main Account\",\n \"amount\": 2500,\n \"discount\": \"no\",\n \"inspection_categories\": [\n {\n \"inspection_category_id\": 1,\n \"rate_type\": \"flat_rate\",\n \"labor_rate\": 500,\n \"working_hours\": 1,\n \"labor_hours\": 1,\n \"rate\": 500,\n \"chart_of_account\": \"4000\",\n \"description\": \"General inspection\",\n \"department_id\": 1\n }\n ],\n \"parts\": [\n {\n \"part_id\": 1,\n \"quantity\": 2,\n \"rate\": 150,\n \"chart_of_account\": \"4100\",\n \"description\": \"Oil filter\",\n \"department_id\": 1\n }\n ],\n \"expenses\": [\n {\n \"expense_id\": 1,\n \"quantity\": 1,\n \"rate\": 100,\n \"chart_of_account\": \"4200\",\n \"description\": \"Shop supplies\",\n \"department_id\": 1\n }\n ],\n \"services\": [\n {\n \"service_id\": 1,\n \"rate_type\": \"hourly\",\n \"labor_rate_id\": 1,\n \"working_hours\": 2,\n \"labor_hours\": 2,\n \"quantity\": 1,\n \"rate\": 800,\n \"chart_of_account\": \"4300\",\n \"description\": \"Engine service\",\n \"department_id\": 1\n }\n ],\n \"service_groups\": [\n {\n \"service_group_id\": 1,\n \"rate_type\": \"flat_rate\",\n \"labor_rate_id\": 1,\n \"working_hours\": 1,\n \"labor_hours\": 1,\n \"rate\": 600,\n \"chart_of_account\": \"4400\",\n \"description\": \"Major service package\",\n \"department_id\": 1\n }\n ]\n}",
"options": {
"raw": {
"language": "json"
@ -33589,7 +33676,7 @@
},
"response": [
{
"name": "200 OK",
"name": "201 Created",
"originalRequest": {
"auth": {
"type": "bearer",
@ -33614,7 +33701,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"subject\": \"Invoice for Service\",\n \"customer_id\": 1,\n \"vehicle_id\": 1,\n \"estimate_id\": 1,\n \"invoice_date\": \"2026-03-31\",\n \"due_date\": \"2026-04-14\",\n \"payment_terms_id\": 1,\n \"invoice_sequence_id\": 1,\n \"invoice_number\": \"INV-001\",\n \"department_id\": 1,\n \"notes\": \"string\",\n \"status\": \"draft\",\n \"discount\": \"no\"\n}",
"raw": "{\n \"subject\": \"Invoice for Service\",\n \"customer_id\": 1,\n \"vehicle_id\": 1,\n \"estimate_id\": 1,\n \"kms_in\": 50000,\n \"has_insurance\": false,\n \"insurer_id\": 2,\n \"invoice_to_id\": 1,\n \"billing_address_id\": 10,\n \"delivery_address_id\": 11,\n \"invoice_date\": \"2026-03-31\",\n \"due_date\": \"2026-04-14\",\n \"payment_terms_id\": 1,\n \"invoice_sequence_id\": 1,\n \"invoice_number\": \"INV-001\",\n \"invoice_title\": \"Tax Invoice\",\n \"department_id\": 1,\n \"notes\": \"Vehicle service and parts\",\n \"terms_and_conditions\": \"Payment due in 14 days.\",\n \"status\": \"draft\",\n \"received_payment\": false,\n \"payment_mode_id\": 1,\n \"deposit_to\": \"Main Account\",\n \"amount\": 2500,\n \"discount\": \"no\",\n \"inspection_categories\": [\n {\n \"inspection_category_id\": 1,\n \"rate_type\": \"flat_rate\",\n \"labor_rate\": 500,\n \"working_hours\": 1,\n \"labor_hours\": 1,\n \"rate\": 500,\n \"chart_of_account\": \"4000\",\n \"description\": \"General inspection\",\n \"department_id\": 1\n }\n ],\n \"parts\": [\n {\n \"part_id\": 1,\n \"quantity\": 2,\n \"rate\": 150,\n \"chart_of_account\": \"4100\",\n \"description\": \"Oil filter\",\n \"department_id\": 1\n }\n ],\n \"expenses\": [\n {\n \"expense_id\": 1,\n \"quantity\": 1,\n \"rate\": 100,\n \"chart_of_account\": \"4200\",\n \"description\": \"Shop supplies\",\n \"department_id\": 1\n }\n ],\n \"services\": [\n {\n \"service_id\": 1,\n \"rate_type\": \"hourly\",\n \"labor_rate_id\": 1,\n \"working_hours\": 2,\n \"labor_hours\": 2,\n \"quantity\": 1,\n \"rate\": 800,\n \"chart_of_account\": \"4300\",\n \"description\": \"Engine service\",\n \"department_id\": 1\n }\n ],\n \"service_groups\": [\n {\n \"service_group_id\": 1,\n \"rate_type\": \"flat_rate\",\n \"labor_rate_id\": 1,\n \"working_hours\": 1,\n \"labor_hours\": 1,\n \"rate\": 600,\n \"chart_of_account\": \"4400\",\n \"description\": \"Major service package\",\n \"department_id\": 1\n }\n ]\n}",
"options": {
"raw": {
"language": "json"
@ -33632,8 +33719,8 @@
]
}
},
"status": "OK",
"code": 200,
"status": "Created",
"code": 201,
"_postman_previewlanguage": "json",
"header": [
{
@ -33642,7 +33729,7 @@
}
],
"cookie": [],
"body": "{\n \"message\": \"Operation completed successfully.\",\n \"data\": {\n \"id\": 1,\n \"subject\": \"Invoice for Job Card 001\",\n \"customer_id\": 1,\n \"vehicle_id\": 1,\n \"estimate_id\": 1,\n \"kms_in\": 50000,\n \"has_insurance\": false,\n \"insurer_id\": 1,\n \"invoice_to_id\": 1,\n \"billing_address_id\": 1,\n \"delivery_address_id\": 1,\n \"invoice_date\": \"2026-03-31\",\n \"due_date\": \"2026-04-14\",\n \"payment_terms_id\": 1,\n \"invoice_sequence_id\": 1,\n \"invoice_number\": \"INV-001\",\n \"invoice_title\": \"Tax Invoice\",\n \"department_id\": 1,\n \"notes\": \"string\",\n \"terms_and_conditions\": \"string\",\n \"status\": \"draft\",\n \"received_payment\": 0,\n \"payment_mode_id\": 1,\n \"deposit_to\": \"string\",\n \"amount\": 0,\n \"discount\": \"no\",\n \"created_at\": \"2026-03-31T10:00:00.000000Z\",\n \"updated_at\": \"2026-03-31T10:00:00.000000Z\"\n }\n}"
"body": "{\n \"message\": \"Invoice created successfully.\",\n \"data\": {\n \"id\": 1,\n \"subject\": \"Invoice for Service\",\n \"customer_id\": 1,\n \"vehicle_id\": 1,\n \"estimate_id\": 1,\n \"kms_in\": 50000,\n \"has_insurance\": false,\n \"insurer_id\": 2,\n \"invoice_to_id\": 1,\n \"billing_address_id\": 10,\n \"delivery_address_id\": 11,\n \"invoice_date\": \"2026-03-31\",\n \"due_date\": \"2026-04-14\",\n \"payment_terms_id\": 1,\n \"invoice_sequence_id\": 1,\n \"invoice_number\": \"INV-001\",\n \"invoice_title\": \"Tax Invoice\",\n \"department_id\": 1,\n \"notes\": \"Vehicle service and parts\",\n \"terms_and_conditions\": \"Payment due in 14 days.\",\n \"status\": \"draft\",\n \"received_payment\": false,\n \"payment_mode_id\": 1,\n \"deposit_to\": \"Main Account\",\n \"amount\": 2500,\n \"discount\": \"no\",\n \"created_at\": \"2026-03-31T10:00:00.000000Z\",\n \"updated_at\": \"2026-03-31T10:00:00.000000Z\",\n \"customer\": {\n \"id\": 1\n },\n \"vehicle\": {\n \"id\": 1\n },\n \"invoice_sequence\": {\n \"id\": 1,\n \"title\": \"INV\"\n },\n \"department\": {\n \"id\": 1,\n \"name\": \"Service\"\n },\n \"invoice_inspection_categories\": [\n {\n \"id\": 1,\n \"invoice_id\": 1,\n \"inspection_category_id\": 1,\n \"rate_type\": \"flat_rate\",\n \"labor_rate\": 500,\n \"working_hours\": 1,\n \"labor_hours\": 1,\n \"rate\": 500,\n \"chart_of_account\": \"4000\",\n \"description\": \"General inspection\",\n \"department_id\": 1\n }\n ],\n \"invoice_parts\": [\n {\n \"id\": 1,\n \"invoice_id\": 1,\n \"part_id\": 1,\n \"quantity\": 2,\n \"rate\": 150,\n \"chart_of_account\": \"4100\",\n \"description\": \"Oil filter\",\n \"department_id\": 1\n }\n ],\n \"invoice_expenses\": [\n {\n \"id\": 1,\n \"invoice_id\": 1,\n \"expense_id\": 1,\n \"quantity\": 1,\n \"rate\": 100,\n \"chart_of_account\": \"4200\",\n \"description\": \"Shop supplies\",\n \"department_id\": 1\n }\n ],\n \"invoice_services\": [\n {\n \"id\": 1,\n \"invoice_id\": 1,\n \"service_id\": 1,\n \"rate_type\": \"hourly\",\n \"labor_rate_id\": 1,\n \"working_hours\": 2,\n \"labor_hours\": 2,\n \"quantity\": 1,\n \"rate\": 800,\n \"chart_of_account\": \"4300\",\n \"description\": \"Engine service\",\n \"department_id\": 1\n }\n ],\n \"invoice_service_groups\": [\n {\n \"id\": 1,\n \"invoice_id\": 1,\n \"service_group_id\": 1,\n \"rate_type\": \"flat_rate\",\n \"labor_rate_id\": 1,\n \"working_hours\": 1,\n \"labor_hours\": 1,\n \"rate\": 600,\n \"chart_of_account\": \"4400\",\n \"description\": \"Major service package\",\n \"department_id\": 1\n }\n ]\n }\n}"
}
]
},

View File

@ -1,6 +1,6 @@
import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client"
import type { ApiPath } from "../infra/types"
import type { ApiPath, ApiResponse } from "../infra/types"
export const ESTIMATE_ROUTES = {
INDEX: "/api/estimates",
@ -25,8 +25,8 @@ export class EstimatesClient extends CrudClient<
// Note: GET /api/estimates/{id} is not in the OpenAPI schema.
// This method uses a type cast and relies on the backend supporting the route.
async getById(id: string) {
const data = await this.get(ESTIMATE_ROUTES.INDEX, { query: { id } } as never)
return {...data, data: (data as any)?.data?.[0] ?? null }
const data = await this.get(ESTIMATE_ROUTES.BY_ID, { params: { id } })
return data;
}
// ── Estimate Services ──
@ -48,7 +48,7 @@ export class EstimatesClient extends CrudClient<
// ── Estimate Parts ──
async listParts(estimateId: string) {
return this.get(ESTIMATE_ROUTES.PARTS, { params: { id: estimateId } } as never)
return this.get(ESTIMATE_ROUTES.PARTS, { params: { id: estimateId } }) as Promise<ApiResponse<typeof ESTIMATE_ROUTES.PARTS, "get">>
}
async addPart(estimateId: string, payload: { part_id?: number; quantity?: number; rate?: string; description?: string }) {

View File

@ -8,6 +8,7 @@ import type {
} from "./types"
import createClient from "openapi-fetch"
import type { paths } from "../../types/index"
import { logger } from "./logger"
type HttpMethod = "get" | "post" | "put" | "delete" | "patch"
@ -73,6 +74,8 @@ export class ApiClient {
const opts = options as never
const body = (options as Record<string, unknown>).body as never
switch (method) {
case "get":
return this.get(ep, opts) as Promise<ApiResponse<Path, Method>>
@ -96,12 +99,15 @@ export class ApiClient {
options: ApiRequestOptions<Path, "get"> = {} as ApiRequestOptions<Path, "get">,
): Promise<ApiResponse<Path, "get">> {
const requestOptions = this.toFetchOptions(options)
const startTime = Date.now()
logger.debug({ method: "GET", endpoint }, "API request")
try {
const { data, error, response } = await this.client.GET(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "get", data, error, response)
return this.resolveResult(endpoint, "get", data, error, response, startTime)
} catch (err) {
if (err instanceof ApiError) throw err
logger.error({ method: "GET", endpoint, duration: Date.now() - startTime }, "Network error")
throw this.createNetworkError(endpoint, "get")
}
}
@ -112,12 +118,15 @@ export class ApiClient {
options: Omit<ApiRequestOptions<Path, "post">, "body"> = {} as Omit<ApiRequestOptions<Path, "post">, "body">,
): Promise<ApiResponse<Path, "post">> {
const requestOptions = this.toFetchOptions({ ...options, body })
const startTime = Date.now()
logger.debug({ method: "POST", endpoint }, "API request")
try {
const { data, error, response } = await this.client.POST(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "post", data, error, response)
return this.resolveResult(endpoint, "post", data, error, response, startTime)
} catch (err) {
if (err instanceof ApiError) throw err
logger.error({ method: "POST", endpoint, duration: Date.now() - startTime }, "Network error")
throw this.createNetworkError(endpoint, "post")
}
}
@ -128,12 +137,15 @@ export class ApiClient {
options: Omit<ApiRequestOptions<Path, "put">, "body"> = {} as Omit<ApiRequestOptions<Path, "put">, "body">,
): Promise<ApiResponse<Path, "put">> {
const requestOptions = this.toFetchOptions({ ...options, body })
const startTime = Date.now()
logger.debug({ method: "PUT", endpoint }, "API request")
try {
const { data, error, response } = await this.client.PUT(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "put", data, error, response)
return this.resolveResult(endpoint, "put", data, error, response, startTime)
} catch (err) {
if (err instanceof ApiError) throw err
logger.error({ method: "PUT", endpoint, duration: Date.now() - startTime }, "Network error")
throw this.createNetworkError(endpoint, "put")
}
}
@ -143,12 +155,15 @@ export class ApiClient {
options: ApiRequestOptions<Path, "delete"> = {} as ApiRequestOptions<Path, "delete">,
): Promise<ApiResponse<Path, "delete">> {
const requestOptions = this.toFetchOptions(options)
const startTime = Date.now()
logger.debug({ method: "DELETE", endpoint }, "API request")
try {
const { data, error, response } = await this.client.DELETE(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "delete", data, error, response)
return this.resolveResult(endpoint, "delete", data, error, response, startTime)
} catch (err) {
if (err instanceof ApiError) throw err
logger.error({ method: "DELETE", endpoint, duration: Date.now() - startTime }, "Network error")
throw this.createNetworkError(endpoint, "delete")
}
}
@ -159,12 +174,15 @@ export class ApiClient {
options: Omit<ApiRequestOptions<Path, "patch">, "body"> = {} as Omit<ApiRequestOptions<Path, "patch">, "body">,
): Promise<ApiResponse<Path, "patch">> {
const requestOptions = this.toFetchOptions({ ...options, body })
const startTime = Date.now()
logger.debug({ method: "PATCH", endpoint }, "API request")
try {
const { data, error, response } = await this.client.PATCH(endpoint, requestOptions as never)
return this.resolveResult(endpoint, "patch", data, error, response)
return this.resolveResult(endpoint, "patch", data, error, response, startTime)
} catch (err) {
if (err instanceof ApiError) throw err
logger.error({ method: "PATCH", endpoint, duration: Date.now() - startTime }, "Network error")
throw this.createNetworkError(endpoint, "patch")
}
}
@ -179,14 +197,21 @@ export class ApiClient {
headers.set("Accept", "application/json")
// Content-Type is intentionally omitted — fetch sets multipart/form-data + boundary automatically
const startTime = Date.now()
logger.debug({ method: "POST", endpoint }, "API request (form-data)")
const response = await fetch(url, { method: "POST", headers, body: formData })
const text = await response.text()
const data = text ? JSON.parse(text) : null
const duration = Date.now() - startTime
if (!response.ok) {
const level = response.status >= 500 ? "error" : "warn"
logger[level]({ method: "POST", endpoint, status: response.status, duration }, data?.message ?? "API request failed")
throw new ApiError(response.status, response.statusText, endpoint, "post", data)
}
logger.debug({ method: "POST", endpoint, status: response.status, duration }, "API request completed (form-data)")
return data
}
@ -201,14 +226,21 @@ export class ApiClient {
init.body = JSON.stringify(options.body)
}
const startTime = Date.now()
logger.debug({ method, endpoint }, "API request (blob)")
const response = await fetch(url, init)
const duration = Date.now() - startTime
if (!response.ok) {
const text = await response.text()
const data = text ? JSON.parse(text) : null
const level = response.status >= 500 ? "error" : "warn"
logger[level]({ method, endpoint, status: response.status, duration }, data?.message ?? "API request failed")
throw new ApiError(response.status, response.statusText, endpoint, method.toLowerCase(), data)
}
logger.debug({ method, endpoint, status: response.status, duration }, "API request completed (blob)")
return response.blob()
}
@ -250,17 +282,28 @@ export class ApiClient {
data: unknown,
error: unknown,
response: Response,
startTime: number,
): ApiResponse<Path, Method> {
const duration = Date.now() - startTime
if (error !== undefined) {
const payload = this.normalizeErrorPayload(error)
if (response.status >= 500) {
logger.error({ method: method.toUpperCase(), endpoint, status: response.status, duration }, payload?.message ?? "API request failed")
} else {
logger.warn({ method: method.toUpperCase(), endpoint, status: response.status, duration }, payload?.message ?? "API request failed")
}
throw new ApiError(
response.status,
response.statusText,
endpoint,
method,
this.normalizeErrorPayload(error),
payload,
)
}
logger.debug({ method: method.toUpperCase(), endpoint, status: response.status, duration, data }, "API request completed")
return data as ApiResponse<Path, Method>
}

View File

@ -0,0 +1,32 @@
import pino from "pino"
/**
* Isomorphic logger for the API client.
*
* - In Node.js (server / Next.js RSC / server actions): pino writes newline-delimited
* JSON to stdout structured and machine-parseable.
* - In the browser (Next.js client components): pino switches to the browser
* transport automatically and delegates to the appropriate `console.*` method
* (console.debug, console.warn, console.error, ).
*
* Log level defaults:
* development debug (all messages)
* production warn (warnings + errors only)
*
* Override with the NEXT_PUBLIC_LOG_LEVEL env var (e.g. "info").
*/
const level =
(process.env.NEXT_PUBLIC_LOG_LEVEL as pino.Level | undefined) ??
(process.env.NODE_ENV === "production" ? "warn" : "debug")
export const logger = pino({
name: "garage-api",
level,
browser: {
/**
* Pass a plain object to `console.*` so DevTools display
* log fields (method, endpoint, status, duration) as expandable props.
*/
asObject: true,
},
})

View File

@ -95,8 +95,8 @@ export interface paths {
};
requestBody?: never;
responses: {
/** @description OK */
200: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
@ -14538,7 +14538,166 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/** Display the specified estimate. */
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
* "data": {
* "id": 1,
* "title": "Estimate for Toyota Camry",
* "customer_id": 1,
* "vehicle_id": 1,
* "department_id": 1,
* "estimate_number": "EST-001",
* "date": "2026-03-31",
* "has_insurance": false,
* "enable_digital_authorisation": false,
* "insurance_type_id": 1,
* "insurer_id": 2,
* "service_writer_id": 1,
* "footer": "Thank you for your business.",
* "created_at": "2026-03-31T10:00:00.000000Z",
* "updated_at": "2026-03-31T10:00:00.000000Z",
* "labels": [
* {
* "id": 1,
* "title": "Urgent",
* "color_code": "#FF0000",
* "pivot": {
* "estimate_id": 1,
* "label_id": 1
* }
* }
* ],
* "customer_remarks": [
* {
* "id": 1,
* "estimate_id": 1,
* "remark": "Oil change recommended.",
* "created_at": "2026-03-31T10:00:00.000000Z",
* "updated_at": "2026-03-31T10:00:00.000000Z"
* }
* ],
* "customer": {
* "id": 1,
* "first_name": "John",
* "last_name": "Doe"
* },
* "vehicle": {
* "id": 1,
* "registration_number": "ABC-1234",
* "make": "Toyota",
* "model": "Camry"
* },
* "department": {
* "id": 1,
* "name": "Service"
* },
* "insurance_type": {
* "id": 1,
* "title": "Comprehensive"
* },
* "insurer": {
* "id": 2,
* "first_name": "Alex",
* "last_name": "Insurer"
* },
* "service_writer": {
* "id": 1,
* "first_name": "Sam",
* "last_name": "Writer"
* }
* }
* }
*/
"application/json": {
data?: {
id?: number;
title?: string;
customer_id?: number;
vehicle_id?: number;
department_id?: number;
estimate_number?: string;
date?: string;
has_insurance?: boolean;
enable_digital_authorisation?: boolean;
insurance_type_id?: number;
insurer_id?: number;
service_writer_id?: number;
footer?: string;
/** Format: date-time */
created_at?: string;
/** Format: date-time */
updated_at?: string;
labels?: {
id?: number;
title?: string;
color_code?: string;
pivot?: {
estimate_id?: number;
label_id?: number;
};
}[];
customer_remarks?: {
id?: number;
estimate_id?: number;
remark?: string;
/** Format: date-time */
created_at?: string;
/** Format: date-time */
updated_at?: string;
}[];
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;
};
};
};
};
};
};
};
/** Update the specified estimate. */
put: {
parameters: {
@ -29708,15 +29867,87 @@ export interface paths {
* "customer_id": 1,
* "vehicle_id": 1,
* "estimate_id": 1,
* "kms_in": 50000,
* "has_insurance": false,
* "insurer_id": 2,
* "invoice_to_id": 1,
* "billing_address_id": 10,
* "delivery_address_id": 11,
* "invoice_date": "2026-03-31",
* "due_date": "2026-04-14",
* "payment_terms_id": 1,
* "invoice_sequence_id": 1,
* "invoice_number": "INV-001",
* "invoice_title": "Tax Invoice",
* "department_id": 1,
* "notes": "string",
* "notes": "Vehicle service and parts",
* "terms_and_conditions": "Payment due in 14 days.",
* "status": "draft",
* "discount": "no"
* "received_payment": false,
* "payment_mode_id": 1,
* "deposit_to": "Main Account",
* "amount": 2500,
* "discount": "no",
* "inspection_categories": [
* {
* "inspection_category_id": 1,
* "rate_type": "flat_rate",
* "labor_rate": 500,
* "working_hours": 1,
* "labor_hours": 1,
* "rate": 500,
* "chart_of_account": "4000",
* "description": "General inspection",
* "department_id": 1
* }
* ],
* "parts": [
* {
* "part_id": 1,
* "quantity": 2,
* "rate": 150,
* "chart_of_account": "4100",
* "description": "Oil filter",
* "department_id": 1
* }
* ],
* "expenses": [
* {
* "expense_id": 1,
* "quantity": 1,
* "rate": 100,
* "chart_of_account": "4200",
* "description": "Shop supplies",
* "department_id": 1
* }
* ],
* "services": [
* {
* "service_id": 1,
* "rate_type": "hourly",
* "labor_rate_id": 1,
* "working_hours": 2,
* "labor_hours": 2,
* "quantity": 1,
* "rate": 800,
* "chart_of_account": "4300",
* "description": "Engine service",
* "department_id": 1
* }
* ],
* "service_groups": [
* {
* "service_group_id": 1,
* "rate_type": "flat_rate",
* "labor_rate_id": 1,
* "working_hours": 1,
* "labor_hours": 1,
* "rate": 600,
* "chart_of_account": "4400",
* "description": "Major service package",
* "department_id": 1
* }
* ]
* }
*/
"application/json": {
@ -29724,40 +29955,102 @@ export interface paths {
customer_id?: number;
vehicle_id?: number;
estimate_id?: number;
kms_in?: number;
has_insurance?: boolean;
insurer_id?: number;
invoice_to_id?: number;
billing_address_id?: number;
delivery_address_id?: number;
invoice_date?: string;
due_date?: string;
payment_terms_id?: number;
invoice_sequence_id?: number;
invoice_number?: string;
invoice_title?: string;
department_id?: number;
notes?: string;
terms_and_conditions?: string;
status?: string;
received_payment?: boolean;
payment_mode_id?: number;
deposit_to?: string;
amount?: number;
discount?: string;
inspection_categories?: {
inspection_category_id?: number;
rate_type?: string;
labor_rate?: number;
working_hours?: number;
labor_hours?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
parts?: {
part_id?: number;
quantity?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
expenses?: {
expense_id?: number;
quantity?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
services?: {
service_id?: number;
rate_type?: string;
labor_rate_id?: number;
working_hours?: number;
labor_hours?: number;
quantity?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
service_groups?: {
service_group_id?: number;
rate_type?: string;
labor_rate_id?: number;
working_hours?: number;
labor_hours?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
};
};
};
responses: {
/** @description OK */
200: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
* "message": "Operation completed successfully.",
* "message": "Invoice created successfully.",
* "data": {
* "id": 1,
* "subject": "Invoice for Job Card 001",
* "subject": "Invoice for Service",
* "customer_id": 1,
* "vehicle_id": 1,
* "estimate_id": 1,
* "kms_in": 50000,
* "has_insurance": false,
* "insurer_id": 1,
* "insurer_id": 2,
* "invoice_to_id": 1,
* "billing_address_id": 1,
* "delivery_address_id": 1,
* "billing_address_id": 10,
* "delivery_address_id": 11,
* "invoice_date": "2026-03-31",
* "due_date": "2026-04-14",
* "payment_terms_id": 1,
@ -29765,16 +30058,100 @@ export interface paths {
* "invoice_number": "INV-001",
* "invoice_title": "Tax Invoice",
* "department_id": 1,
* "notes": "string",
* "terms_and_conditions": "string",
* "notes": "Vehicle service and parts",
* "terms_and_conditions": "Payment due in 14 days.",
* "status": "draft",
* "received_payment": 0,
* "received_payment": false,
* "payment_mode_id": 1,
* "deposit_to": "string",
* "amount": 0,
* "deposit_to": "Main Account",
* "amount": 2500,
* "discount": "no",
* "created_at": "2026-03-31T10:00:00.000000Z",
* "updated_at": "2026-03-31T10:00:00.000000Z"
* "updated_at": "2026-03-31T10:00:00.000000Z",
* "customer": {
* "id": 1
* },
* "vehicle": {
* "id": 1
* },
* "invoice_sequence": {
* "id": 1,
* "title": "INV"
* },
* "department": {
* "id": 1,
* "name": "Service"
* },
* "invoice_inspection_categories": [
* {
* "id": 1,
* "invoice_id": 1,
* "inspection_category_id": 1,
* "rate_type": "flat_rate",
* "labor_rate": 500,
* "working_hours": 1,
* "labor_hours": 1,
* "rate": 500,
* "chart_of_account": "4000",
* "description": "General inspection",
* "department_id": 1
* }
* ],
* "invoice_parts": [
* {
* "id": 1,
* "invoice_id": 1,
* "part_id": 1,
* "quantity": 2,
* "rate": 150,
* "chart_of_account": "4100",
* "description": "Oil filter",
* "department_id": 1
* }
* ],
* "invoice_expenses": [
* {
* "id": 1,
* "invoice_id": 1,
* "expense_id": 1,
* "quantity": 1,
* "rate": 100,
* "chart_of_account": "4200",
* "description": "Shop supplies",
* "department_id": 1
* }
* ],
* "invoice_services": [
* {
* "id": 1,
* "invoice_id": 1,
* "service_id": 1,
* "rate_type": "hourly",
* "labor_rate_id": 1,
* "working_hours": 2,
* "labor_hours": 2,
* "quantity": 1,
* "rate": 800,
* "chart_of_account": "4300",
* "description": "Engine service",
* "department_id": 1
* }
* ],
* "invoice_service_groups": [
* {
* "id": 1,
* "invoice_id": 1,
* "service_group_id": 1,
* "rate_type": "flat_rate",
* "labor_rate_id": 1,
* "working_hours": 1,
* "labor_hours": 1,
* "rate": 600,
* "chart_of_account": "4400",
* "description": "Major service package",
* "department_id": 1
* }
* ]
* }
* }
*/
@ -29802,7 +30179,7 @@ export interface paths {
notes?: string;
terms_and_conditions?: string;
status?: string;
received_payment?: number;
received_payment?: boolean;
payment_mode_id?: number;
deposit_to?: string;
amount?: number;
@ -29811,6 +30188,80 @@ export interface paths {
created_at?: string;
/** Format: date-time */
updated_at?: string;
customer?: {
id?: number;
};
vehicle?: {
id?: number;
};
invoice_sequence?: {
id?: number;
title?: string;
};
department?: {
id?: number;
name?: string;
};
invoice_inspection_categories?: {
id?: number;
invoice_id?: number;
inspection_category_id?: number;
rate_type?: string;
labor_rate?: number;
working_hours?: number;
labor_hours?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
invoice_parts?: {
id?: number;
invoice_id?: number;
part_id?: number;
quantity?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
invoice_expenses?: {
id?: number;
invoice_id?: number;
expense_id?: number;
quantity?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
invoice_services?: {
id?: number;
invoice_id?: number;
service_id?: number;
rate_type?: string;
labor_rate_id?: number;
working_hours?: number;
labor_hours?: number;
quantity?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
invoice_service_groups?: {
id?: number;
invoice_id?: number;
service_group_id?: number;
rate_type?: string;
labor_rate_id?: number;
working_hours?: number;
labor_hours?: number;
rate?: number;
chart_of_account?: string;
description?: string;
department_id?: number;
}[];
};
};
};

146
pnpm-lock.yaml generated
View File

@ -159,6 +159,9 @@ importers:
openapi-fetch:
specifier: ^0.14.0
version: 0.14.1
pino:
specifier: ^10.3.1
version: 10.3.1
server-only:
specifier: '*'
version: 0.0.1
@ -816,6 +819,9 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@ -2063,6 +2069,10 @@ packages:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
engines: {node: '>= 4.0.0'}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@ -3777,6 +3787,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@ -3903,6 +3917,16 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
pino-abstract-transport@3.0.0:
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
pino-std-serializers@7.1.0:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
pino@10.3.1:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@ -4008,6 +4032,9 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@ -4044,6 +4071,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
radix-ui@1.4.3:
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
peerDependencies:
@ -4146,6 +4176,10 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
@ -4251,6 +4285,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@ -4342,6 +4380,9 @@ packages:
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
engines: {node: '>=10'}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
@ -4356,6 +4397,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sshpk@1.18.0:
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==}
engines: {node: '>=0.10.0'}
@ -4490,6 +4535,10 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
thread-stream@4.0.0:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
throttleit@1.0.1:
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
@ -4872,7 +4921,7 @@ snapshots:
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -5030,7 +5079,7 @@ snapshots:
'@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -5140,7 +5189,7 @@ snapshots:
'@eslint/config-array@0.21.1':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@ -5148,7 +5197,7 @@ snapshots:
'@eslint/config-array@0.21.2':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@ -5164,7 +5213,7 @@ snapshots:
'@eslint/eslintrc@3.3.1':
dependencies:
ajv: 6.12.6
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@ -5178,7 +5227,7 @@ snapshots:
'@eslint/eslintrc@3.3.5':
dependencies:
ajv: 6.14.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@ -5484,6 +5533,8 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@pinojs/redact@0.4.0': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@ -6506,7 +6557,7 @@ snapshots:
'@typescript-eslint/types': 8.50.0
'@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.2)
'@typescript-eslint/visitor-keys': 8.50.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
eslint: 9.39.1(jiti@2.6.1)
typescript: 5.9.2
transitivePeerDependencies:
@ -6518,7 +6569,7 @@ snapshots:
'@typescript-eslint/types': 8.50.0
'@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.50.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
eslint: 9.39.4(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
@ -6528,7 +6579,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.2)
'@typescript-eslint/types': 8.50.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
typescript: 5.9.2
transitivePeerDependencies:
- supports-color
@ -6537,7 +6588,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3)
'@typescript-eslint/types': 8.50.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@ -6560,7 +6611,7 @@ snapshots:
'@typescript-eslint/types': 8.50.0
'@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.2)
'@typescript-eslint/utils': 8.50.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.2)
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
eslint: 9.39.1(jiti@2.6.1)
ts-api-utils: 2.1.0(typescript@5.9.2)
typescript: 5.9.2
@ -6572,7 +6623,7 @@ snapshots:
'@typescript-eslint/types': 8.50.0
'@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.50.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
@ -6587,7 +6638,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.2)
'@typescript-eslint/types': 8.50.0
'@typescript-eslint/visitor-keys': 8.50.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
@ -6602,7 +6653,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3)
'@typescript-eslint/types': 8.50.0
'@typescript-eslint/visitor-keys': 8.50.0
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
@ -6851,6 +6902,8 @@ snapshots:
at-least-node@1.0.0: {}
atomic-sleep@1.0.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@ -6883,7 +6936,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@ -7476,7 +7529,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
eslint: 9.39.4(jiti@2.6.1)
get-tsconfig: 4.13.7
is-bun-module: 2.0.0
@ -7640,7 +7693,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@ -7681,7 +7734,7 @@ snapshots:
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@ -7793,7 +7846,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
@ -7891,7 +7944,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@ -8095,6 +8148,13 @@ snapshots:
jsprim: 2.0.2
sshpk: 1.18.0
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
@ -8708,6 +8768,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
on-exit-leak-free@2.1.2: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@ -8839,6 +8901,26 @@ snapshots:
pify@2.3.0: {}
pino-abstract-transport@3.0.0:
dependencies:
split2: 4.2.0
pino-std-serializers@7.1.0: {}
pino@10.3.1:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pino-std-serializers: 7.1.0
process-warning: 5.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.1
thread-stream: 4.0.0
pkce-challenge@5.0.1: {}
pluralize@8.0.0: {}
@ -8880,6 +8962,8 @@ snapshots:
dependencies:
parse-ms: 4.0.0
process-warning@5.0.0: {}
process@0.11.10: {}
prompts@2.4.2:
@ -8917,6 +9001,8 @@ snapshots:
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@radix-ui/primitive': 1.1.3
@ -9058,6 +9144,8 @@ snapshots:
react@19.2.4: {}
real-require@0.2.0: {}
recast@0.23.11:
dependencies:
ast-types: 0.16.1
@ -9156,7 +9244,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@ -9195,6 +9283,8 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
scheduler@0.27.0: {}
@ -9205,7 +9295,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@10.2.2)
debug: 4.4.3(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@ -9273,7 +9363,7 @@ snapshots:
fast-glob: 3.3.3
fs-extra: 11.3.4
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
https-proxy-agent: 7.0.6
kleur: 4.1.5
msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3)
node-fetch: 3.3.2
@ -9381,6 +9471,10 @@ snapshots:
astral-regex: 2.0.0
is-fullwidth-code-point: 3.0.0
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@ -9390,6 +9484,8 @@ snapshots:
source-map@0.6.1: {}
split2@4.2.0: {}
sshpk@1.18.0:
dependencies:
asn1: 0.2.6
@ -9530,6 +9626,10 @@ snapshots:
tapable@2.3.0: {}
thread-stream@4.0.0:
dependencies:
real-require: 0.2.0
throttleit@1.0.1: {}
through@2.3.8: {}