diff --git a/src/dashboard/column-catalog.test.ts b/src/dashboard/column-catalog.test.ts new file mode 100644 index 0000000..19c69df --- /dev/null +++ b/src/dashboard/column-catalog.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_VISIBLE_COLUMN_KEYS, + INVOICE_ACTION_OPTIONS, + INVOICE_COLUMN_CATALOG, + LOCKED_COLUMN_KEYS, +} from "@/dashboard/column-catalog"; + +describe("INVOICE_COLUMN_CATALOG", () => { + it("locks Stampli Visible defaults and locked columns", () => { + expect(DEFAULT_VISIBLE_COLUMN_KEYS).toEqual([ + "bolt", + "bell", + "invoiceId", + "userStatus", + "vendor", + "lastUpdate", + "invoiceDate", + "dueDate", + "amount", + "tagging", + "fs", + "eye", + ]); + expect(LOCKED_COLUMN_KEYS).toEqual(["bolt", "bell", "invoiceId", "vendor"]); + }); + + it("includes Hidden catalog entries", () => { + const hidden = INVOICE_COLUMN_CATALOG.filter((entry) => !entry.defaultVisible).map( + (entry) => entry.key, + ); + expect(hidden).toEqual([ + "description", + "type", + "location", + "paymentAccount", + "paymentDate", + "processingBegan", + "relatedInvoice", + "rowCount", + ]); + }); + + it("locks the ten Action filter options", () => { + expect(INVOICE_ACTION_OPTIONS).toHaveLength(10); + expect(INVOICE_ACTION_OPTIONS).toContain("Approve invoice"); + }); +}); diff --git a/src/dashboard/column-catalog.ts b/src/dashboard/column-catalog.ts new file mode 100644 index 0000000..99b37c5 --- /dev/null +++ b/src/dashboard/column-catalog.ts @@ -0,0 +1,77 @@ +/** Locked Stampli HTML column catalog (AP-41 / AP-47). */ + +export type InvoiceColumnKey = + | "bolt" + | "bell" + | "invoiceId" + | "userStatus" + | "vendor" + | "lastUpdate" + | "invoiceDate" + | "dueDate" + | "amount" + | "tagging" + | "fs" + | "eye" + | "description" + | "type" + | "location" + | "paymentAccount" + | "paymentDate" + | "processingBegan" + | "relatedInvoice" + | "rowCount"; + +export type InvoiceColumnCatalogEntry = { + key: InvoiceColumnKey; + label: string; + locked: boolean; + defaultVisible: boolean; +}; + +export const INVOICE_COLUMN_CATALOG: InvoiceColumnCatalogEntry[] = [ + { key: "bolt", label: "Bolt", locked: true, defaultVisible: true }, + { key: "bell", label: "Bell", locked: true, defaultVisible: true }, + { key: "invoiceId", label: "Invoice", locked: true, defaultVisible: true }, + { key: "userStatus", label: "Action", locked: false, defaultVisible: true }, + { key: "vendor", label: "Vendor", locked: true, defaultVisible: true }, + { 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: "tagging", label: "Tagging", locked: false, defaultVisible: true }, + { key: "fs", label: "F.S.", locked: false, defaultVisible: true }, + { key: "eye", label: "Eye", locked: false, defaultVisible: true }, + { key: "description", label: "Description", locked: false, defaultVisible: false }, + { key: "type", label: "Invoice Type", locked: false, defaultVisible: false }, + { key: "location", label: "Location", locked: false, defaultVisible: false }, + { key: "paymentAccount", label: "Payment Account", locked: false, defaultVisible: false }, + { key: "paymentDate", label: "Payment Date", locked: false, defaultVisible: false }, + { key: "processingBegan", label: "Processing Began", locked: false, defaultVisible: false }, + { key: "relatedInvoice", label: "Related Invoice", locked: false, defaultVisible: false }, + { key: "rowCount", label: "Row count", locked: false, defaultVisible: false }, +]; + +export const DEFAULT_VISIBLE_COLUMN_KEYS: InvoiceColumnKey[] = INVOICE_COLUMN_CATALOG.filter( + (entry) => entry.defaultVisible, +).map((entry) => entry.key); + +export const LOCKED_COLUMN_KEYS: InvoiceColumnKey[] = INVOICE_COLUMN_CATALOG.filter( + (entry) => entry.locked, +).map((entry) => entry.key); + +/** Locked Action filter options from Stampli HTML (AP-41 / AP-43). */ +export const INVOICE_ACTION_OPTIONS = [ + "A field update request has been submitted", + "A question has been submitted", + "Approve invoice", + "Attach receipt", + "Filed for my attention", + "Inquiry for you", + "Recalled invoice", + "Received response", + "View Comment", + "Waiting for response", +] as const; + +export type InvoiceActionOption = (typeof INVOICE_ACTION_OPTIONS)[number]; diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index 686468e..ed4f772 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -1,5 +1,9 @@ +import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined"; +import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Link as MuiLink } from "@mui/material"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; import type { MockInvoice } from "@/mocks"; import { Link } from "@/router"; @@ -95,17 +99,79 @@ export const invoiceAttentionColumns: GridColDef[] = [ renderCell: invoiceNumberLink, }, { - field: "status", + field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140, - valueFormatter: (value: string) => (value === "pending_approval" ? "Approve invoice" : value), }, { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 }, dateColumn("dueDate", "Due Date"), moneyColumn("amountDue", "Σ Sum"), ]; +const iconColumn = ( + field: "hasBolt" | "hasBell" | "hasEye", + headerName: string, + Icon: typeof BoltOutlinedIcon, +): GridColDef => ({ + field, + headerName, + width: 56, + sortable: false, + filterable: false, + renderCell: (params) => (params.value ? : null), +}); + +/** 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), + invoiceId: { + field: "invoiceNumbers", + headerName: "Invoice", + flex: 1, + minWidth: 120, + 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"), + 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), + 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 }, + paymentAccount: { + field: "paymentAccount", + headerName: "Payment Account", + flex: 1.2, + minWidth: 140, + }, + paymentDate: dateColumn("payDate", "Payment Date"), + processingBegan: dateColumn("processingBegan", "Processing Began"), + relatedInvoice: { + field: "relatedInvoice", + headerName: "Related Invoice", + flex: 1, + minWidth: 120, + valueFormatter: (value: string | null) => value ?? "—", + }, + rowCount: { + field: "rowCount", + headerName: "Row count", + width: 100, + type: "number", + }, +}; + +export function columnsFromCatalogKeys(keys: InvoiceColumnKey[]): GridColDef[] { + return keys.map((key) => catalogColumnDefs[key]); +} + export const readyToPayColumns: GridColDef[] = [ { field: "invoiceNumber", diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index f3e8692..7888a6f 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -54,6 +54,32 @@ describe("mocks module", () => { expect(getMockInvoices().length).toBeGreaterThanOrEqual(6); }); + it("seeds Stampli catalog fields on every invoice (AP-47)", () => { + const invoices = getMockInvoices(); + expect(invoices.length).toBeGreaterThan(0); + for (const invoice of invoices) { + expect(typeof invoice.hasBolt).toBe("boolean"); + expect(typeof invoice.hasBell).toBe("boolean"); + expect(typeof invoice.hasEye).toBe("boolean"); + expect(invoice.actionLabel.length).toBeGreaterThan(0); + expect(invoice.lastUpdate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + 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"); + expect(typeof invoice.fs).toBe("string"); + expect(typeof invoice.description).toBe("string"); + expect(typeof invoice.invoiceType).toBe("string"); + expect(typeof invoice.location).toBe("string"); + expect(typeof invoice.paymentAccount).toBe("string"); + expect(invoice.relatedInvoice === null || typeof invoice.relatedInvoice === "string").toBe( + true, + ); + expect(invoice.rowCount).toBe(invoice.lines.length); + } + const pending = invoices.filter((invoice) => invoice.status === "pending_approval"); + expect(pending.every((invoice) => invoice.actionLabel === "Approve invoice")).toBe(true); + }); + it("seeds at least two invoices per dashboard tab", () => { const invoices = getMockInvoices(); const pending = invoices.filter((invoice) => invoice.status === "pending_approval"); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index c324ad7..86a5167 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -1,3 +1,4 @@ +import { INVOICE_ACTION_OPTIONS } from "@/dashboard/column-catalog"; import { syncMockApprovalsForInvoice } from "@/mocks/approvals"; import { areMocksEnabled } from "@/mocks/env"; @@ -45,6 +46,21 @@ export type MockInvoice = { lines: MockInvoiceLine[]; comments: MockInvoiceComment[]; activity: MockInvoiceActivity[]; + hasBolt: boolean; + hasBell: boolean; + hasEye: boolean; + actionLabel: string; + lastUpdate: string; + invoiceDate: string; + processingBegan: string; + tagging: string; + fs: string; + description: string; + invoiceType: string; + location: string; + paymentAccount: string; + relatedInvoice: string | null; + rowCount: number; }; export type MockInvoiceHeaderPatch = Partial< @@ -63,6 +79,50 @@ export type MockInvoiceHeaderPatch = Partial< const SAMPLE_DOCUMENT_URL = "/fixtures/sample-invoice.pdf"; +type MockInvoiceCore = Omit< + MockInvoice, + | "hasBolt" + | "hasBell" + | "hasEye" + | "actionLabel" + | "lastUpdate" + | "invoiceDate" + | "processingBegan" + | "tagging" + | "fs" + | "description" + | "invoiceType" + | "location" + | "paymentAccount" + | "relatedInvoice" + | "rowCount" +>; + +function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { + const actionLabel = + core.status === "pending_approval" + ? "Approve invoice" + : INVOICE_ACTION_OPTIONS[index % INVOICE_ACTION_OPTIONS.length]; + 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, + invoiceDate: core.dueDate, + processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, + tagging: index % 2 === 0 ? "Ops" : "", + fs: index % 2 === 0 ? "FS-1" : "", + description: core.memo, + invoiceType: "Standard", + location: "Ronkonkoma", + paymentAccount: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check", + relatedInvoice: null, + rowCount: core.lines.length, + }; +} + function cloneInvoice(invoice: MockInvoice): MockInvoice { return { ...invoice, @@ -72,7 +132,7 @@ function cloneInvoice(invoice: MockInvoice): MockInvoice { }; } -const seedInvoices: MockInvoice[] = [ +const seedInvoiceCores: MockInvoiceCore[] = [ { id: "inv-1", invoiceNumber: "INV-1001", @@ -414,6 +474,10 @@ const seedInvoices: MockInvoice[] = [ }, ]; +const seedInvoices: MockInvoice[] = seedInvoiceCores.map((core, index) => + enrichInvoice(core, index), +); + /** Mutable in-memory invoice store for mock POC interactions. */ let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => cloneInvoice(invoice));