feat: enhance invoice and job card forms with new fields, validation, and print functionality

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-09 02:51:19 +03:00
co-authored by Copilot
parent 25c3125894
commit 349a458c3c
13 changed files with 319 additions and 77 deletions
@@ -16,7 +16,6 @@ export default async function InvoiceDetailLayout(props: { params: Promise<{ id:
return (
<InvoiceProvider invoice={data}>
<DashboardDetailsPage
className='p-0 lg:p-0'
title={title}
description={data?.invoice_number ? `Invoice #: ${data.invoice_number}` : undefined}
icon={<ReceiptIcon className="size-5" />}
@@ -5,18 +5,74 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { InvoiceForm } from "@/modules/invoices/invoice-form"
import { Badge } from "@/shared/components/ui/badge"
import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters"
import { INVOICE_ROUTES } from "@garage/api"
import type { InvoicesClient } from "@garage/api"
type InvoiceItem = {
id: number
subject?: string
invoice_title?: string
invoice_number?: string
customer_name?: string
status?: string
invoice_date?: string
due_date?: string
created_at?: string
total?: number | string
balance_due?: number | string
customer?: {
first_name?: string
last_name?: string
company_name?: string
phone?: string
} | null
vehicle?: {
make?: string
model?: string
sub_model?: string
year?: number | string
license_plate?: string
} | null
}
function getCustomerLabel(item: InvoiceItem): string {
const firstName = item.customer?.first_name?.trim()
const lastName = item.customer?.last_name?.trim()
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim()
return fullName || item.customer_name || item.customer?.company_name || "—"
}
function getVehicleLabel(item: InvoiceItem): string {
const vehicle = item.vehicle
if (!vehicle) return "—"
const core = [vehicle.make, vehicle.model, vehicle.sub_model].filter(Boolean).join(" ")
const suffix = [vehicle.year, vehicle.license_plate].filter(Boolean).join(" • ")
if (core && suffix) return `${core} (${suffix})`
return core || suffix || "—"
}
function getDueMeta(dueDate?: string) {
if (!dueDate) return { text: "No due date", tone: "muted" as const }
const due = new Date(dueDate)
if (isNaN(due.getTime())) return { text: "Invalid due date", tone: "muted" as const }
const today = new Date()
today.setHours(0, 0, 0, 0)
due.setHours(0, 0, 0, 0)
const days = Math.ceil((due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (days < 0) return { text: `${Math.abs(days)} day(s) overdue`, tone: "danger" as const }
if (days === 0) return { text: "Due today", tone: "warning" as const }
if (days <= 3) return { text: `Due in ${days} day(s)`, tone: "warning" as const }
return { text: `Due in ${days} day(s)`, tone: "ok" as const }
}
export default function InvoicesPage() {
@@ -44,15 +100,66 @@ export default function InvoicesPage() {
columns={({ actionsColumn }) => [
{
accessorKey: "subject",
header: ({ column }) => <ColumnHeader column={column} title="Subject" />,
header: ({ column }) => <ColumnHeader column={column} title="Invoice" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const title = item.subject || item.invoice_title || "Untitled invoice"
return (
<div className="min-w-[220px]">
<p className="font-medium leading-none">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.invoice_number || "No invoice number"}
</p>
</div>
)
},
},
{
accessorKey: "invoice_number",
header: ({ column }) => <ColumnHeader column={column} title="Invoice #" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
return (
<span className="font-mono text-xs text-muted-foreground">
{item.invoice_number || "—"}
</span>
)
},
},
{
accessorKey: "customer_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const customerLabel = getCustomerLabel(item)
return (
<div className="min-w-[190px]">
<p className="font-medium leading-none">{customerLabel}</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.customer?.phone || item.customer?.company_name || "—"}
</p>
</div>
)
},
},
{
id: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
return (
<div className="min-w-[220px]">
<p className="font-medium leading-none">{getVehicleLabel(item)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.vehicle?.license_plate || "—"}
</p>
</div>
)
},
},
{
accessorKey: "status",
@@ -60,27 +167,71 @@ export default function InvoicesPage() {
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const status = item.status
const colorMap: Record<string, string> = {
draft: "text-muted-foreground",
open: "text-blue-600",
paid: "text-green-600",
overdue: "text-red-600",
void: "text-gray-400",
const classMap: Record<string, string> = {
draft: "bg-slate-100 text-slate-700",
open: "bg-blue-100 text-blue-700",
paid: "bg-emerald-100 text-emerald-700",
over_due: "bg-red-100 text-red-700",
un_paid: "bg-orange-100 text-orange-700",
partially_paid: "bg-amber-100 text-amber-700",
void: "bg-zinc-100 text-zinc-700",
}
return (
<span className={colorMap[status ?? ""] ?? ""}>
{status ? status.charAt(0).toUpperCase() + status.slice(1) : "—"}
</span>
<Badge variant="outline" className={classMap[status ?? ""] ?? ""}>
{formatEnum(status) || "—"}
</Badge>
)
},
},
{
accessorKey: "invoice_date",
header: ({ column }) => <ColumnHeader column={column} title="Invoice Date" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
return formatDate(item.invoice_date)
},
},
{
accessorKey: "due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
const dueMeta = getDueMeta(item.due_date)
const toneClassMap = {
danger: "text-red-600",
warning: "text-amber-600",
ok: "text-emerald-600",
muted: "text-muted-foreground",
}
return (
<div>
<p className="font-medium leading-none">{formatDate(item.due_date)}</p>
<p className={`mt-1 text-xs ${toneClassMap[dueMeta.tone]}`}>
{dueMeta.text}
</p>
</div>
)
},
},
{
id: "amounts",
header: ({ column }) => <ColumnHeader column={column} title="Amounts" />,
cell: ({ row }) => {
const item = row.original as unknown as InvoiceItem
return (
<div className="min-w-[150px]">
<p className="font-medium leading-none">
Total: {formatCurrency(item.total ?? 0)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Due: {formatCurrency(item.balance_due ?? 0)}
</p>
</div>
)
},
},
actionsColumn(),
]}
@@ -67,7 +67,7 @@ export default function JobCardsPage() {
actions: (
<div className="flex items-center gap-2">
<FilterTrigger onClick={filter.open} activeFilterCount={filter.activeFilterCount} />
<FormDialog classNames={{ dialogContent: 'min-w-6xl' }} title="Job Card" >
<FormDialog classNames={{ dialogContent: 'xl:min-w-6xl' }} title="Job Card" >
{(resourceId, {close}) => (
<JobCardForm
resourceId={resourceId}