diff --git a/src/dashboard/DashboardGrids.tsx b/src/dashboard/DashboardGrids.tsx index d52555f..cbff4d9 100644 --- a/src/dashboard/DashboardGrids.tsx +++ b/src/dashboard/DashboardGrids.tsx @@ -6,7 +6,7 @@ import { FilterDrawer } from "@/dashboard/FilterDrawer"; import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar"; import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton"; import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid"; -import { columnsFromCatalogKeys } from "@/dashboard/columns"; +import { columnsForApprovedGrid, columnsFromCatalogKeys } from "@/dashboard/columns"; import { loadColumnPrefs, resetColumnPrefs, @@ -86,6 +86,10 @@ export function DashboardGrids() { () => columnsFromCatalogKeys(columnPrefs.visibleOrder), [columnPrefs.visibleOrder], ); + const approvedColumns = useMemo( + () => columnsForApprovedGrid(columnPrefs.visibleOrder), + [columnPrefs.visibleOrder], + ); const openInvoice = (params: GridRowParams) => { navigate("/invoices/:id", { params: { id: params.row.id } }); @@ -235,18 +239,12 @@ export function DashboardGrids() { {filteredAttention.length > 0 && tab === "attention" ? ( @@ -288,7 +286,7 @@ export function DashboardGrids() { setSelection(model)} diff --git a/src/dashboard/approved-columns.ts b/src/dashboard/approved-columns.ts new file mode 100644 index 0000000..176dde7 --- /dev/null +++ b/src/dashboard/approved-columns.ts @@ -0,0 +1,16 @@ +import type { GridColDef } from "@mui/x-data-grid"; +import type { MockInvoice } from "@/mocks"; + +/** Keep one editable pay-date column on the approved grid. */ +export function withEditablePayDate( + columns: GridColDef[], + payDateColumn: GridColDef, +): GridColDef[] { + const next = columns.map((column) => + column.field === "payDate" ? { ...column, editable: true } : column, + ); + if (next.some((column) => column.field === "payDate")) { + return next; + } + return [...next, { ...payDateColumn, editable: true }]; +} diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts index a0cdeb1..4d2cc28 100644 --- a/src/dashboard/columns.test.ts +++ b/src/dashboard/columns.test.ts @@ -1,4 +1,7 @@ +import type { GridColDef } from "@mui/x-data-grid"; import { describe, expect, it } from "vitest"; +import { withEditablePayDate } from "@/dashboard/approved-columns"; +import type { MockInvoice } from "@/mocks"; import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; describe("invoice column date helpers (AP-47)", () => { @@ -26,3 +29,22 @@ describe("invoice column date helpers (AP-47)", () => { expect(formatRelativeFromIsoDate("2026-10-11", now)).toBe("in 2 months"); }); }); + +describe("withEditablePayDate", () => { + const hidden: GridColDef = { field: "vendorName", headerName: "Vendor" }; + const payDate: GridColDef = { field: "payDate", headerName: "Payment Date" }; + + it("appends an editable pay date when the catalog hides Payment Date", () => { + const columns = withEditablePayDate([hidden], payDate); + expect(columns.filter((column) => column.field === "payDate")).toEqual([ + { field: "payDate", headerName: "Payment Date", editable: true }, + ]); + }); + + it("keeps a single editable pay date when Payment Date is already visible", () => { + const columns = withEditablePayDate([hidden, { ...payDate, editable: false }], payDate); + const matches = columns.filter((column) => column.field === "payDate"); + expect(matches).toHaveLength(1); + expect(matches[0]?.editable).toBe(true); + }); +}); diff --git a/src/dashboard/columns.tsx b/src/dashboard/columns.tsx index 3b1ded2..c03a486 100644 --- a/src/dashboard/columns.tsx +++ b/src/dashboard/columns.tsx @@ -4,6 +4,7 @@ import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNone import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { Box, Link as MuiLink, Stack, Typography } from "@mui/material"; import type { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { withEditablePayDate } from "@/dashboard/approved-columns"; import type { InvoiceColumnKey } from "@/dashboard/column-catalog"; import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format"; import type { MockInvoice } from "@/mocks"; @@ -250,6 +251,11 @@ export function columnsFromCatalogKeys(keys: InvoiceColumnKey[]): GridColDef catalogColumnDefs[key]); } +/** Approved tab keeps an editable pay date even when the catalog hides Payment Date. */ +export function columnsForApprovedGrid(keys: InvoiceColumnKey[]): GridColDef[] { + return withEditablePayDate(columnsFromCatalogKeys(keys), catalogColumnDefs.paymentDate); +} + export const readyToPayColumns: GridColDef[] = [ { field: "invoiceNumber", diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 774dcd9..506a8f0 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -67,6 +67,7 @@ describe("mocks module", () => { expect(invoice.lastUpdateActorName.length).toBeGreaterThan(0); expect(invoice.lastUpdateAt.length).toBeGreaterThan(0); expect(invoice.invoiceDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(invoice.invoiceDate).not.toBe(invoice.dueDate); expect(invoice.processingBegan).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(typeof invoice.tagging).toBe("string"); expect(typeof invoice.fs).toBe("string"); @@ -101,6 +102,8 @@ describe("mocks module", () => { const updated = updateMockInvoicePayDate("inv-1", "2026-08-18"); expect(updated.payDate).toBe("2026-08-18"); + expect(updated.lastUpdateMessage).toBe("Pay date updated"); + expect(updated.lastUpdateAt).not.toBe(before?.lastUpdateAt); const after = getMockInvoices().find((invoice) => invoice.id === "inv-1"); expect(after?.payDate).toBe("2026-08-18"); @@ -148,6 +151,7 @@ describe("mocks module", () => { authorUserId: "user-1", authorName: "Alex Rivera", body: "Need another look", + createdAt: "2026-08-11T09:00:00.000Z", }); expect(withComment.comments.some((comment) => comment.body === "Need another look")).toBe(true); @@ -168,6 +172,23 @@ describe("mocks module", () => { expect(getMockApprovals().find((item) => item.invoiceId === "inv-2")?.status).toBe("approved"); }); + it("refreshes last update from a comment and payment account from the method", () => { + const commented = addMockInvoiceComment("inv-1", { + authorUserId: "user-1", + authorName: "Alex Rivera", + body: "Checked the total", + createdAt: "2026-09-01T15:00:00.000Z", + }); + expect(commented.lastUpdateAt).toBe("2026-09-01T15:00:00.000Z"); + expect(commented.lastUpdate).toBe("2026-09-01"); + expect(commented.lastUpdateMessage).toBe("Checked the total"); + expect(commented.lastUpdateActorName).toBe("Alex Rivera"); + + const method = updateMockInvoiceHeader("inv-1", { paymentMethod: "ach" }); + expect(method.paymentAccount).toBe("Operating ACH"); + expect(getMockInvoice("inv-1")?.paymentAccount).toBe("Operating ACH"); + }); + it("refreshes description and rowCount when header or lines change", () => { const before = getMockInvoice("inv-1"); expect(before?.rowCount).toBe(2); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index 81cc2b5..a565190 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -116,17 +116,46 @@ function actionLabelForStatus(status: MockInvoiceStatus, index: number): string return NON_PENDING_ACTION_OPTIONS[index % NON_PENDING_ACTION_OPTIONS.length]; } -function derivedActivityFields(invoice: Pick) { - const latestActivity = invoice.activity[invoice.activity.length - 1]; - const lastUpdateAt = latestActivity?.createdAt ?? `${invoice.dueDate}T12:00:00.000Z`; +function derivedActivityFields(invoice: Pick) { + const events = [ + ...invoice.activity.map((entry) => ({ + at: entry.createdAt, + message: entry.message, + actor: entry.actorName, + })), + ...invoice.comments.map((comment) => ({ + at: comment.createdAt, + message: comment.body, + actor: comment.authorName, + })), + ]; + const latest = events.reduce<(typeof events)[number] | undefined>((current, event) => { + if (!current || event.at > current.at) { + return event; + } + return current; + }, undefined); + const lastUpdateAt = latest?.at ?? `${invoice.dueDate}T12:00:00.000Z`; return { lastUpdate: lastUpdateAt.slice(0, 10), - lastUpdateMessage: latestActivity?.message ?? "Invoice updated", - lastUpdateActorName: latestActivity?.actorName ?? "System", + lastUpdateMessage: latest?.message ?? "Invoice updated", + lastUpdateActorName: latest?.actor ?? "System", lastUpdateAt, }; } +function paymentAccountFor(paymentMethod: MockInvoice["paymentMethod"]): string { + return paymentMethod === "ach" ? "Operating ACH" : "Operating Check"; +} + +/** Invoice date is independent of the due date. Offset varies per seed. */ +function invoiceDateBeforeDue(dueDate: string, index: number): string { + const [year, month, day] = dueDate.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() - (14 + index)); + return date.toISOString().slice(0, 10); +} + /** Keep catalog-derived fields in sync after mock mutations. */ function withRefreshedCatalogFields(invoice: MockInvoice, index: number): MockInvoice { return { @@ -134,6 +163,7 @@ function withRefreshedCatalogFields(invoice: MockInvoice, index: number): MockIn actionLabel: actionLabelForStatus(invoice.status, index), ...derivedActivityFields(invoice), description: invoice.memo, + paymentAccount: paymentAccountFor(invoice.paymentMethod), rowCount: invoice.lines.length, }; } @@ -150,14 +180,14 @@ function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { lastUpdateMessage: "", lastUpdateActorName: "", lastUpdateAt: "", - invoiceDate: core.dueDate, + invoiceDate: invoiceDateBeforeDue(core.dueDate, index), processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, tagging: index % 2 === 0 ? "Ops" : "", fs: index % 2 === 0 ? "synced" : "", description: core.memo, invoiceType: "Standard", location: "Ronkonkoma", - paymentAccount: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check", + paymentAccount: paymentAccountFor(core.paymentMethod), relatedInvoice: null, rowCount: core.lines.length, }, @@ -549,7 +579,27 @@ export function updateMockInvoicePayDate(id: string, payDate: string | null): Mo throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { ...invoiceStore[index], payDate }); + const current = invoiceStore[index]; + return replaceInvoice( + index, + withRefreshedCatalogFields( + { + ...current, + payDate, + activity: [ + ...current.activity, + { + id: `activity-pay-date-${Date.now()}`, + actorUserId: "user-1", + actorName: "System", + message: "Pay date updated", + createdAt: new Date().toISOString(), + }, + ], + }, + index, + ), + ); } export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatch): MockInvoice { @@ -603,10 +653,16 @@ export function addMockInvoiceComment( createdAt: comment.createdAt ?? new Date().toISOString(), }; - return replaceInvoice(index, { - ...current, - comments: [...current.comments, nextComment], - }); + return replaceInvoice( + index, + withRefreshedCatalogFields( + { + ...current, + comments: [...current.comments, nextComment], + }, + index, + ), + ); } export function transitionMockInvoiceStatus(