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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user