Merge remote-tracking branch 'origin/feature/invoice-filter-toolbar' into feature/columns-slideover

This commit is contained in:
Adam Moussa 2026-09-25 16:50:37 -04:00
commit ee425dfb45
No known key found for this signature in database
6 changed files with 147 additions and 71 deletions

View file

@ -1,29 +0,0 @@
name: PR Policy
on:
pull_request:
types:
- opened
- reopened
- synchronize
- edited
- labeled
- unlabeled
- ready_for_review
concurrency:
group: "policy-${{ github.event.pull_request.number }}"
cancel-in-progress: true
permissions:
contents: read
issues: read
pull-requests: read
jobs:
policy:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7
secrets:
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}

View file

@ -61,12 +61,12 @@ test.describe("dashboard grids", () => {
await editor.fill("2026-09-01");
await editor.press("Enter");
await expect(payDateCell).toContainText("Sep 1, 2026");
await expect(payDateCell).toContainText("09/01/2026");
await page.getByTestId("tab-pending").click();
await page.getByTestId("tab-ready").click();
await expect(
page.getByTestId("grid-ready").locator('[data-field="payDate"]').nth(1),
).toContainText("Sep 1, 2026");
).toContainText("09/01/2026");
});
});

View file

@ -12,4 +12,17 @@ describe("invoice column date helpers (AP-47)", () => {
expect(formatRelativeFromIsoDate("2026-08-10", now)).toBe("1 day ago");
expect(formatRelativeFromIsoDate("2026-02-11", now)).toBe("6 months ago");
});
it("uses calendar days so early hours still treat yesterday as 1 day ago", () => {
const earlyMorning = new Date("2026-08-11T01:15:00");
expect(formatRelativeFromIsoDate("2026-08-10", earlyMorning)).toBe("1 day ago");
expect(formatRelativeFromIsoDate("2026-08-11", earlyMorning)).toBe("today");
});
it("formats future invoice and due dates instead of clamping to today", () => {
const now = new Date("2026-08-11T12:00:00");
expect(formatRelativeFromIsoDate("2026-08-12", now)).toBe("in 1 day");
expect(formatRelativeFromIsoDate("2026-08-16", now)).toBe("in 5 days");
expect(formatRelativeFromIsoDate("2026-10-11", now)).toBe("in 2 months");
});
});

View file

@ -1,3 +1,12 @@
function startOfLocalDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function formatUnit(count: number, unit: "day" | "month" | "year", future: boolean): string {
const noun = count === 1 ? unit : `${unit}s`;
return future ? `in ${count} ${noun}` : `${count} ${noun} ago`;
}
export function formatSlashDate(value: string | null | undefined): string {
if (!value) {
return "—";
@ -24,19 +33,24 @@ export function formatRelativeFromIsoDate(
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) {
const days = Math.round(
(startOfLocalDay(now).getTime() - startOfLocalDay(parsed).getTime()) / dayMs,
);
if (days === 0) {
return "today";
}
if (days < 30) {
return days === 1 ? "1 day ago" : `${days} days ago`;
const future = days < 0;
const absDays = Math.abs(days);
if (absDays < 30) {
return formatUnit(absDays, "day", future);
}
const months = Math.floor(days / 30);
const months = Math.floor(absDays / 30);
if (months < 12) {
return months === 1 ? "1 month ago" : `${months} months ago`;
return formatUnit(months, "month", future);
}
const years = Math.floor(months / 12);
return years === 1 ? "1 year ago" : `${years} years ago`;
return formatUnit(years, "year", future);
}

View file

@ -81,6 +81,8 @@ describe("mocks module", () => {
}
const pending = invoices.filter((invoice) => invoice.status === "pending_approval");
expect(pending.every((invoice) => invoice.actionLabel === "Approve invoice")).toBe(true);
const nonPending = invoices.filter((invoice) => invoice.status !== "pending_approval");
expect(nonPending.every((invoice) => invoice.actionLabel !== "Approve invoice")).toBe(true);
});
it("seeds at least two invoices per dashboard tab", () => {
@ -123,6 +125,7 @@ describe("mocks module", () => {
const updated = updateMockInvoiceHeader("inv-2", { memo: "Updated memo", amountDue: 4700 });
expect(updated.memo).toBe("Updated memo");
expect(updated.amountDue).toBe(4700);
expect(updated.description).toBe("Updated memo");
const withLines = updateMockInvoiceLines("inv-2", [
{
@ -135,7 +138,9 @@ describe("mocks module", () => {
]);
expect(withLines.lines).toHaveLength(1);
expect(withLines.lines[0].description).toBe("Updated line");
expect(withLines.rowCount).toBe(1);
expect(getMockInvoice("inv-2")?.lines[0].description).toBe("Updated line");
expect(getMockInvoice("inv-2")?.rowCount).toBe(1);
});
it("adds comments and transitions status with activity", () => {
@ -150,13 +155,40 @@ describe("mocks module", () => {
actorUserId: "user-2",
actorName: "Jordan Lee",
message: "Approved",
createdAt: "2026-08-12T09:00:00.000Z",
});
expect(approved.status).toBe("approved");
expect(approved.activity.some((entry) => entry.message === "Approved")).toBe(true);
expect(approved.lastUpdateMessage).toBe("Approved");
expect(approved.lastUpdateActorName).toBe("Jordan Lee");
expect(approved.lastUpdateAt).toBe("2026-08-12T09:00:00.000Z");
expect(approved.lastUpdate).toBe("2026-08-12");
expect(approved.actionLabel).not.toBe("Approve invoice");
expect(getMockPendingApprovals().some((item) => item.invoiceId === "inv-2")).toBe(false);
expect(getMockApprovals().find((item) => item.invoiceId === "inv-2")?.status).toBe("approved");
});
it("refreshes description and rowCount when header or lines change", () => {
const before = getMockInvoice("inv-1");
expect(before?.rowCount).toBe(2);
expect(before?.description).toBe("Office supplies restock");
const header = updateMockInvoiceHeader("inv-1", { memo: "Revised supplies memo" });
expect(header.description).toBe("Revised supplies memo");
const lines = updateMockInvoiceLines("inv-1", [
{
id: "inv-1-line-1",
description: "Only line",
amount: 100,
glAccountId: "gl-1",
departmentId: "dept-4",
},
]);
expect(lines.rowCount).toBe(1);
expect(getMockInvoice("inv-1")?.rowCount).toBe(1);
});
it("returns undefined for unknown invoice ids", () => {
expect(getMockInvoice("missing-invoice")).toBeUndefined();
});

View file

@ -104,36 +104,67 @@ type MockInvoiceCore = Omit<
| "rowCount"
>;
function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice {
const actionLabel =
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`;
/** Non-pending rows never show the pending-only "Approve invoice" action. */
const NON_PENDING_ACTION_OPTIONS = INVOICE_ACTION_OPTIONS.filter(
(option) => option !== "Approve invoice",
);
function actionLabelForStatus(status: MockInvoiceStatus, index: number): string {
if (status === "pending_approval") {
return "Approve invoice";
}
return NON_PENDING_ACTION_OPTIONS[index % NON_PENDING_ACTION_OPTIONS.length];
}
function derivedActivityFields(invoice: Pick<MockInvoice, "activity" | "dueDate">) {
const latestActivity = invoice.activity[invoice.activity.length - 1];
const lastUpdateAt = latestActivity?.createdAt ?? `${invoice.dueDate}T12:00:00.000Z`;
return {
...core,
hasBolt: index % 3 === 0,
hasBell: index % 2 === 0,
hasEye: true,
actionLabel,
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 ? "synced" : "",
description: core.memo,
invoiceType: "Standard",
location: "Ronkonkoma",
paymentAccount: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check",
relatedInvoice: null,
rowCount: core.lines.length,
};
}
/** Keep catalog-derived fields in sync after mock mutations. */
function withRefreshedCatalogFields(invoice: MockInvoice, index: number): MockInvoice {
return {
...invoice,
actionLabel: actionLabelForStatus(invoice.status, index),
...derivedActivityFields(invoice),
description: invoice.memo,
rowCount: invoice.lines.length,
};
}
function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice {
return withRefreshedCatalogFields(
{
...core,
hasBolt: index % 3 === 0,
hasBell: index % 2 === 0,
hasEye: true,
actionLabel: actionLabelForStatus(core.status, index),
lastUpdate: "",
lastUpdateMessage: "",
lastUpdateActorName: "",
lastUpdateAt: "",
invoiceDate: core.dueDate,
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",
relatedInvoice: null,
rowCount: core.lines.length,
},
index,
);
}
function cloneInvoice(invoice: MockInvoice): MockInvoice {
return {
...invoice,
@ -527,7 +558,10 @@ export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatc
throw new Error(`Mock invoice not found: ${id}`);
}
return replaceInvoice(index, { ...invoiceStore[index], ...patch });
return replaceInvoice(
index,
withRefreshedCatalogFields({ ...invoiceStore[index], ...patch }, index),
);
}
export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice {
@ -536,10 +570,16 @@ export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): Mo
throw new Error(`Mock invoice not found: ${id}`);
}
return replaceInvoice(index, {
...invoiceStore[index],
lines: lines.map((line) => ({ ...line })),
});
return replaceInvoice(
index,
withRefreshedCatalogFields(
{
...invoiceStore[index],
lines: lines.map((line) => ({ ...line })),
},
index,
),
);
}
export function addMockInvoiceComment(
@ -591,11 +631,17 @@ export function transitionMockInvoiceStatus(
createdAt: activity.createdAt ?? new Date().toISOString(),
};
const updated = replaceInvoice(index, {
...current,
status,
activity: [...current.activity, nextActivity],
});
const updated = replaceInvoice(
index,
withRefreshedCatalogFields(
{
...current,
status,
activity: [...current.activity, nextActivity],
},
index,
),
);
if (status === "approved" || status === "rejected") {
syncMockApprovalsForInvoice(id, status);