From bf555a346ec6d59ea4f00011bc3c30d0f1765384 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 11 Aug 2026 15:36:25 -0400 Subject: [PATCH] feat(web): add multi-line Stampli invoice cell renderers Refs: AP-47 --- src/dashboard/column-catalog.ts | 2 +- src/dashboard/columns.test.ts | 15 +++ src/dashboard/columns.tsx | 134 +++++++++++++++++++++------ src/dashboard/invoice-date-format.ts | 42 +++++++++ src/mocks/index.test.ts | 3 + src/mocks/invoices.ts | 15 ++- 6 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 src/dashboard/columns.test.ts create mode 100644 src/dashboard/invoice-date-format.ts diff --git a/src/dashboard/column-catalog.ts b/src/dashboard/column-catalog.ts index 99b37c5..4ef3d06 100644 --- a/src/dashboard/column-catalog.ts +++ b/src/dashboard/column-catalog.ts @@ -38,7 +38,7 @@ export const INVOICE_COLUMN_CATALOG: InvoiceColumnCatalogEntry[] = [ { key: "lastUpdate", label: "Last Update", locked: false, defaultVisible: true }, { key: "invoiceDate", label: "Invoice Date", locked: false, defaultVisible: true }, { key: "dueDate", label: "Due Date", locked: false, defaultVisible: true }, - { key: "amount", label: "Sum", locked: false, defaultVisible: true }, + { key: "amount", label: "Σ Sum", locked: false, defaultVisible: true }, { key: "tagging", label: "Tagging", locked: false, defaultVisible: true }, { key: "fs", label: "F.S.", locked: false, defaultVisible: true }, { key: "eye", label: "Eye", locked: false, defaultVisible: true }, diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts new file mode 100644 index 0000000..3688c34 --- /dev/null +++ b/src/dashboard/columns.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; + +describe("invoice column date helpers (AP-47)", () => { + it("formats ISO dates as MM/DD/YYYY", () => { + expect(formatSlashDate("2026-04-10")).toBe("04/10/2026"); + }); + + it("formats relative ages from a fixed now", () => { + const now = new Date("2026-08-11T12:00:00"); + expect(formatRelativeFromIsoDate("2026-08-11", now)).toBe("today"); + expect(formatRelativeFromIsoDate("2026-08-10", now)).toBe("1 day ago"); + expect(formatRelativeFromIsoDate("2026-02-11", now)).toBe("6 months ago"); + }); +}); diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index ed4f772..3b1ded2 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -1,39 +1,42 @@ import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; -import { Link as MuiLink } from "@mui/material"; +import { Box, Link as MuiLink, Stack, Typography } from "@mui/material"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; +import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; import type { MockInvoice } from "@/mocks"; import { Link } from "@/router"; -const currencyFormatter = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}); - -const dateFormatter = new Intl.DateTimeFormat("en-US", { +const dateTimeFormatter = new Intl.DateTimeFormat("en-US", { year: "numeric", - month: "short", - day: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, }); -function formatIsoDate(value: string | null | undefined): string { - if (!value) { +function formatAmountUsd(value: number | null | undefined): string { + if (value == null) { return "—"; } - const parsed = new Date(`${value}T00:00:00`); - if (Number.isNaN(parsed.getTime())) { - return value; - } - return dateFormatter.format(parsed); + return `${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} USD`; +} + +function formatIsoDate(value: string | null | undefined): string { + return formatSlashDate(value); } function formatCurrency(value: number | null | undefined): string { if (value == null) { return "—"; } - return currencyFormatter.format(value); + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value); } const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef => ({ @@ -58,6 +61,22 @@ const dateColumn = ( valueFormatter: (value: string | null | undefined) => formatIsoDate(value), }); +function twoLineCell(primary: string, secondary: string, primaryBold = false) { + return ( + + + {primary} + + + {secondary} + + + ); +} + function invoiceNumberLink(params: GridRenderCellParams) { return ( ) { underline="hover" data-testid={`invoice-link-${params.row.id}`} onClick={(event) => event.stopPropagation()} + sx={{ fontWeight: 700, color: "text.primary" }} > {params.value} @@ -111,21 +131,38 @@ export const invoiceAttentionColumns: GridColDef[] = [ const iconColumn = ( field: "hasBolt" | "hasBell" | "hasEye", - headerName: string, Icon: typeof BoltOutlinedIcon, ): GridColDef => ({ field, - headerName, + headerName: "", width: 56, sortable: false, filterable: false, + renderHeader: () => , renderCell: (params) => (params.value ? : null), }); +function dualDateColumn( + field: "invoiceDate" | "dueDate", + headerName: string, +): GridColDef { + return { + field, + headerName, + flex: 1, + minWidth: 120, + renderCell: (params) => + twoLineCell( + formatSlashDate(params.value as string), + formatRelativeFromIsoDate(params.value as string), + ), + }; +} + /** Full catalog column defs keyed by AP-41 HTML column keys (AP-47). */ export const catalogColumnDefs: Record> = { - bolt: iconColumn("hasBolt", "", BoltOutlinedIcon), - bell: iconColumn("hasBell", "", NotificationsNoneOutlinedIcon), + bolt: iconColumn("hasBolt", BoltOutlinedIcon), + bell: iconColumn("hasBell", NotificationsNoneOutlinedIcon), invoiceId: { field: "invoiceNumbers", headerName: "Invoice", @@ -134,14 +171,55 @@ export const catalogColumnDefs: Record renderCell: invoiceNumberLink, }, userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 }, - vendor: { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, - lastUpdate: dateColumn("lastUpdate", "Last Update"), - invoiceDate: dateColumn("invoiceDate", "Invoice Date"), - dueDate: dateColumn("dueDate", "Due Date"), - amount: moneyColumn("amountDue", "Sum"), + vendor: { + field: "vendorName", + headerName: "Vendor", + flex: 1.4, + minWidth: 160, + renderCell: (params) => twoLineCell(params.row.vendorName, params.row.vendorId, true), + }, + lastUpdate: { + field: "lastUpdate", + headerName: "Last Update", + flex: 1.6, + minWidth: 180, + renderCell: (params) => { + const stamp = dateTimeFormatter.format(new Date(params.row.lastUpdateAt)); + return twoLineCell( + params.row.lastUpdateMessage, + `${stamp} ${params.row.lastUpdateActorName}`, + ); + }, + }, + invoiceDate: dualDateColumn("invoiceDate", "Invoice Date"), + dueDate: dualDateColumn("dueDate", "Due Date"), + amount: { + field: "amountDue", + headerName: "Σ Sum", + flex: 1, + minWidth: 120, + type: "number", + renderHeader: () => ( + + Σ Sum + + ), + renderCell: (params) => ( + + {formatAmountUsd(params.row.amountDue)} + + ), + }, tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 }, - fs: { field: "fs", headerName: "F.S.", flex: 0.6, minWidth: 80 }, - eye: iconColumn("hasEye", "", VisibilityOutlinedIcon), + fs: { + field: "fs", + headerName: "F.S.", + flex: 0.6, + minWidth: 80, + renderCell: (params) => + params.row.fs ? : null, + }, + eye: iconColumn("hasEye", VisibilityOutlinedIcon), description: { field: "description", headerName: "Description", flex: 1.4, minWidth: 160 }, type: { field: "invoiceType", headerName: "Invoice Type", flex: 1, minWidth: 120 }, location: { field: "location", headerName: "Location", flex: 1, minWidth: 120 }, diff --git a/src/dashboard/invoice-date-format.ts b/src/dashboard/invoice-date-format.ts new file mode 100644 index 0000000..a3ff5e3 --- /dev/null +++ b/src/dashboard/invoice-date-format.ts @@ -0,0 +1,42 @@ +export function formatSlashDate(value: string | null | undefined): string { + if (!value) { + return "—"; + } + const parsed = new Date(`${value.slice(0, 10)}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(parsed); +} + +export function formatRelativeFromIsoDate( + value: string | null | undefined, + now: Date = new Date(), +): string { + if (!value) { + return ""; + } + const parsed = new Date(`${value.slice(0, 10)}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + return ""; + } + const diffMs = now.getTime() - parsed.getTime(); + const dayMs = 24 * 60 * 60 * 1000; + const days = Math.max(0, Math.floor(diffMs / dayMs)); + if (days < 1) { + return "today"; + } + if (days < 30) { + return days === 1 ? "1 day ago" : `${days} days ago`; + } + const months = Math.floor(days / 30); + if (months < 12) { + return months === 1 ? "1 month ago" : `${months} months ago`; + } + const years = Math.floor(months / 12); + return years === 1 ? "1 year ago" : `${years} years ago`; +} diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 7888a6f..4edda0d 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -63,6 +63,9 @@ describe("mocks module", () => { expect(typeof invoice.hasEye).toBe("boolean"); expect(invoice.actionLabel.length).toBeGreaterThan(0); expect(invoice.lastUpdate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(invoice.lastUpdateMessage.length).toBeGreaterThan(0); + expect(invoice.lastUpdateActorName.length).toBeGreaterThan(0); + expect(invoice.lastUpdateAt.length).toBeGreaterThan(0); expect(invoice.invoiceDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(invoice.processingBegan).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(typeof invoice.tagging).toBe("string"); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index 86a5167..48eb505 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -51,6 +51,9 @@ export type MockInvoice = { hasEye: boolean; actionLabel: string; lastUpdate: string; + lastUpdateMessage: string; + lastUpdateActorName: string; + lastUpdateAt: string; invoiceDate: string; processingBegan: string; tagging: string; @@ -86,6 +89,9 @@ type MockInvoiceCore = Omit< | "hasEye" | "actionLabel" | "lastUpdate" + | "lastUpdateMessage" + | "lastUpdateActorName" + | "lastUpdateAt" | "invoiceDate" | "processingBegan" | "tagging" @@ -103,17 +109,22 @@ function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { core.status === "pending_approval" ? "Approve invoice" : INVOICE_ACTION_OPTIONS[index % INVOICE_ACTION_OPTIONS.length]; + const latestActivity = core.activity[core.activity.length - 1]; + const lastUpdateAt = latestActivity?.createdAt ?? `${core.dueDate}T12:00:00.000Z`; return { ...core, hasBolt: index % 3 === 0, hasBell: index % 2 === 0, hasEye: true, actionLabel, - lastUpdate: core.activity[core.activity.length - 1]?.createdAt?.slice(0, 10) ?? core.dueDate, + lastUpdate: lastUpdateAt.slice(0, 10), + lastUpdateMessage: latestActivity?.message ?? "Invoice updated", + lastUpdateActorName: latestActivity?.actorName ?? "System", + lastUpdateAt, invoiceDate: core.dueDate, processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, tagging: index % 2 === 0 ? "Ops" : "", - fs: index % 2 === 0 ? "FS-1" : "", + fs: index % 2 === 0 ? "synced" : "", description: core.memo, invoiceType: "Standard", location: "Ronkonkoma",