241 lines
9.9 KiB
TypeScript
241 lines
9.9 KiB
TypeScript
"use client"
|
|
|
|
import { useRouter } from "next/navigation"
|
|
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() {
|
|
const router = useRouter()
|
|
|
|
return (
|
|
<ResourcePage<InvoicesClient>
|
|
pageTitle="Invoices"
|
|
routeKey={INVOICE_ROUTES.INDEX}
|
|
getClient={(api) => api.invoices}
|
|
onRowClick={(row) => router.push(`/sales/invoice/${(row as any).id}`)}
|
|
headerProps={({ selectedItem, invalidateQuery }) => ({
|
|
actions: (
|
|
<FormDialog classNames={{dialogContent:'lg:min-w-6xl'}} title="Invoice">
|
|
{(resourceId) => (
|
|
<InvoiceForm
|
|
resourceId={resourceId}
|
|
initialData={selectedItem}
|
|
onSuccess={invalidateQuery}
|
|
/>
|
|
)}
|
|
</FormDialog>
|
|
),
|
|
})}
|
|
columns={({ actionsColumn }) => [
|
|
{
|
|
accessorKey: "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",
|
|
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
|
cell: ({ row }) => {
|
|
const item = row.original as unknown as InvoiceItem
|
|
const status = item.status
|
|
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 (
|
|
<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(),
|
|
]}
|
|
/>
|
|
)
|
|
}
|