mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-01 23:13:21 +00:00
Merge remote-tracking branch 'origin/feature/invoice-filter-toolbar' into feature/columns-slideover
This commit is contained in:
commit
ee425dfb45
6 changed files with 147 additions and 71 deletions
29
.github/workflows/policy.yaml
vendored
29
.github/workflows/policy.yaml
vendored
|
|
@ -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 }}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue