mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-06 21:41:56 +00:00
feat(web): add multi-line Stampli invoice cell renderers
Refs: AP-47
This commit is contained in:
parent
2f55c36409
commit
bf555a346e
6 changed files with 180 additions and 31 deletions
|
|
@ -38,7 +38,7 @@ export const INVOICE_COLUMN_CATALOG: InvoiceColumnCatalogEntry[] = [
|
||||||
{ key: "lastUpdate", label: "Last Update", locked: false, defaultVisible: true },
|
{ key: "lastUpdate", label: "Last Update", locked: false, defaultVisible: true },
|
||||||
{ key: "invoiceDate", label: "Invoice Date", locked: false, defaultVisible: true },
|
{ key: "invoiceDate", label: "Invoice Date", locked: false, defaultVisible: true },
|
||||||
{ key: "dueDate", label: "Due 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: "tagging", label: "Tagging", locked: false, defaultVisible: true },
|
||||||
{ key: "fs", label: "F.S.", locked: false, defaultVisible: true },
|
{ key: "fs", label: "F.S.", locked: false, defaultVisible: true },
|
||||||
{ key: "eye", label: "Eye", locked: false, defaultVisible: true },
|
{ key: "eye", label: "Eye", locked: false, defaultVisible: true },
|
||||||
|
|
|
||||||
15
src/dashboard/columns.test.ts
Normal file
15
src/dashboard/columns.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,39 +1,42 @@
|
||||||
import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
|
import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
|
||||||
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
|
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
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 { GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import type { InvoiceColumnKey } from "@/dashboard/column-catalog";
|
import type { InvoiceColumnKey } from "@/dashboard/column-catalog";
|
||||||
|
import { formatRelativeFromIsoDate, formatSlashDate } from "@/dashboard/invoice-date-format";
|
||||||
import type { MockInvoice } from "@/mocks";
|
import type { MockInvoice } from "@/mocks";
|
||||||
import { Link } from "@/router";
|
import { Link } from "@/router";
|
||||||
|
|
||||||
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
const dateTimeFormatter = new Intl.DateTimeFormat("en-US", {
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
});
|
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "2-digit",
|
||||||
day: "numeric",
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatIsoDate(value: string | null | undefined): string {
|
function formatAmountUsd(value: number | null | undefined): string {
|
||||||
if (!value) {
|
if (value == null) {
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
const parsed = new Date(`${value}T00:00:00`);
|
return `${value.toLocaleString("en-US", {
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
minimumFractionDigits: 2,
|
||||||
return value;
|
maximumFractionDigits: 2,
|
||||||
}
|
})} USD`;
|
||||||
return dateFormatter.format(parsed);
|
}
|
||||||
|
|
||||||
|
function formatIsoDate(value: string | null | undefined): string {
|
||||||
|
return formatSlashDate(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value: number | null | undefined): string {
|
function formatCurrency(value: number | null | undefined): string {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return "—";
|
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<MockInvoice> => ({
|
const moneyColumn = (field: keyof MockInvoice, headerName: string): GridColDef<MockInvoice> => ({
|
||||||
|
|
@ -58,6 +61,22 @@ const dateColumn = (
|
||||||
valueFormatter: (value: string | null | undefined) => formatIsoDate(value),
|
valueFormatter: (value: string | null | undefined) => formatIsoDate(value),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function twoLineCell(primary: string, secondary: string, primaryBold = false) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={0.25} sx={{ py: 0.25, lineHeight: 1.3 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: primaryBold ? 700 : 400, color: "text.primary" }}
|
||||||
|
>
|
||||||
|
{primary}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{secondary}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
|
function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
|
||||||
return (
|
return (
|
||||||
<MuiLink
|
<MuiLink
|
||||||
|
|
@ -67,6 +86,7 @@ function invoiceNumberLink(params: GridRenderCellParams<MockInvoice, string>) {
|
||||||
underline="hover"
|
underline="hover"
|
||||||
data-testid={`invoice-link-${params.row.id}`}
|
data-testid={`invoice-link-${params.row.id}`}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||||
>
|
>
|
||||||
{params.value}
|
{params.value}
|
||||||
</MuiLink>
|
</MuiLink>
|
||||||
|
|
@ -111,21 +131,38 @@ export const invoiceAttentionColumns: GridColDef<MockInvoice>[] = [
|
||||||
|
|
||||||
const iconColumn = (
|
const iconColumn = (
|
||||||
field: "hasBolt" | "hasBell" | "hasEye",
|
field: "hasBolt" | "hasBell" | "hasEye",
|
||||||
headerName: string,
|
|
||||||
Icon: typeof BoltOutlinedIcon,
|
Icon: typeof BoltOutlinedIcon,
|
||||||
): GridColDef<MockInvoice> => ({
|
): GridColDef<MockInvoice> => ({
|
||||||
field,
|
field,
|
||||||
headerName,
|
headerName: "",
|
||||||
width: 56,
|
width: 56,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
renderHeader: () => <Icon fontSize="small" color="action" />,
|
||||||
renderCell: (params) => (params.value ? <Icon fontSize="small" color="action" /> : null),
|
renderCell: (params) => (params.value ? <Icon fontSize="small" color="action" /> : null),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function dualDateColumn(
|
||||||
|
field: "invoiceDate" | "dueDate",
|
||||||
|
headerName: string,
|
||||||
|
): GridColDef<MockInvoice> {
|
||||||
|
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). */
|
/** Full catalog column defs keyed by AP-41 HTML column keys (AP-47). */
|
||||||
export const catalogColumnDefs: Record<InvoiceColumnKey, GridColDef<MockInvoice>> = {
|
export const catalogColumnDefs: Record<InvoiceColumnKey, GridColDef<MockInvoice>> = {
|
||||||
bolt: iconColumn("hasBolt", "", BoltOutlinedIcon),
|
bolt: iconColumn("hasBolt", BoltOutlinedIcon),
|
||||||
bell: iconColumn("hasBell", "", NotificationsNoneOutlinedIcon),
|
bell: iconColumn("hasBell", NotificationsNoneOutlinedIcon),
|
||||||
invoiceId: {
|
invoiceId: {
|
||||||
field: "invoiceNumbers",
|
field: "invoiceNumbers",
|
||||||
headerName: "Invoice",
|
headerName: "Invoice",
|
||||||
|
|
@ -134,14 +171,55 @@ export const catalogColumnDefs: Record<InvoiceColumnKey, GridColDef<MockInvoice>
|
||||||
renderCell: invoiceNumberLink,
|
renderCell: invoiceNumberLink,
|
||||||
},
|
},
|
||||||
userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 },
|
userStatus: { field: "actionLabel", headerName: "Action", flex: 1.2, minWidth: 140 },
|
||||||
vendor: { field: "vendorName", headerName: "Vendor", flex: 1.4, minWidth: 160 },
|
vendor: {
|
||||||
lastUpdate: dateColumn("lastUpdate", "Last Update"),
|
field: "vendorName",
|
||||||
invoiceDate: dateColumn("invoiceDate", "Invoice Date"),
|
headerName: "Vendor",
|
||||||
dueDate: dateColumn("dueDate", "Due Date"),
|
flex: 1.4,
|
||||||
amount: moneyColumn("amountDue", "Sum"),
|
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: () => (
|
||||||
|
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||||
|
Σ Sum
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{formatAmountUsd(params.row.amountDue)}
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
},
|
||||||
tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 },
|
tagging: { field: "tagging", headerName: "Tagging", flex: 0.8, minWidth: 100 },
|
||||||
fs: { field: "fs", headerName: "F.S.", flex: 0.6, minWidth: 80 },
|
fs: {
|
||||||
eye: iconColumn("hasEye", "", VisibilityOutlinedIcon),
|
field: "fs",
|
||||||
|
headerName: "F.S.",
|
||||||
|
flex: 0.6,
|
||||||
|
minWidth: 80,
|
||||||
|
renderCell: (params) =>
|
||||||
|
params.row.fs ? <CheckCircleIcon fontSize="small" color="success" /> : null,
|
||||||
|
},
|
||||||
|
eye: iconColumn("hasEye", VisibilityOutlinedIcon),
|
||||||
description: { field: "description", headerName: "Description", flex: 1.4, minWidth: 160 },
|
description: { field: "description", headerName: "Description", flex: 1.4, minWidth: 160 },
|
||||||
type: { field: "invoiceType", headerName: "Invoice Type", flex: 1, minWidth: 120 },
|
type: { field: "invoiceType", headerName: "Invoice Type", flex: 1, minWidth: 120 },
|
||||||
location: { field: "location", headerName: "Location", flex: 1, minWidth: 120 },
|
location: { field: "location", headerName: "Location", flex: 1, minWidth: 120 },
|
||||||
|
|
|
||||||
42
src/dashboard/invoice-date-format.ts
Normal file
42
src/dashboard/invoice-date-format.ts
Normal file
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,9 @@ describe("mocks module", () => {
|
||||||
expect(typeof invoice.hasEye).toBe("boolean");
|
expect(typeof invoice.hasEye).toBe("boolean");
|
||||||
expect(invoice.actionLabel.length).toBeGreaterThan(0);
|
expect(invoice.actionLabel.length).toBeGreaterThan(0);
|
||||||
expect(invoice.lastUpdate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
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.invoiceDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
expect(invoice.processingBegan).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.tagging).toBe("string");
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ export type MockInvoice = {
|
||||||
hasEye: boolean;
|
hasEye: boolean;
|
||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
lastUpdate: string;
|
lastUpdate: string;
|
||||||
|
lastUpdateMessage: string;
|
||||||
|
lastUpdateActorName: string;
|
||||||
|
lastUpdateAt: string;
|
||||||
invoiceDate: string;
|
invoiceDate: string;
|
||||||
processingBegan: string;
|
processingBegan: string;
|
||||||
tagging: string;
|
tagging: string;
|
||||||
|
|
@ -86,6 +89,9 @@ type MockInvoiceCore = Omit<
|
||||||
| "hasEye"
|
| "hasEye"
|
||||||
| "actionLabel"
|
| "actionLabel"
|
||||||
| "lastUpdate"
|
| "lastUpdate"
|
||||||
|
| "lastUpdateMessage"
|
||||||
|
| "lastUpdateActorName"
|
||||||
|
| "lastUpdateAt"
|
||||||
| "invoiceDate"
|
| "invoiceDate"
|
||||||
| "processingBegan"
|
| "processingBegan"
|
||||||
| "tagging"
|
| "tagging"
|
||||||
|
|
@ -103,17 +109,22 @@ function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice {
|
||||||
core.status === "pending_approval"
|
core.status === "pending_approval"
|
||||||
? "Approve invoice"
|
? "Approve invoice"
|
||||||
: INVOICE_ACTION_OPTIONS[index % INVOICE_ACTION_OPTIONS.length];
|
: 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 {
|
return {
|
||||||
...core,
|
...core,
|
||||||
hasBolt: index % 3 === 0,
|
hasBolt: index % 3 === 0,
|
||||||
hasBell: index % 2 === 0,
|
hasBell: index % 2 === 0,
|
||||||
hasEye: true,
|
hasEye: true,
|
||||||
actionLabel,
|
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,
|
invoiceDate: core.dueDate,
|
||||||
processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate,
|
processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate,
|
||||||
tagging: index % 2 === 0 ? "Ops" : "",
|
tagging: index % 2 === 0 ? "Ops" : "",
|
||||||
fs: index % 2 === 0 ? "FS-1" : "",
|
fs: index % 2 === 0 ? "synced" : "",
|
||||||
description: core.memo,
|
description: core.memo,
|
||||||
invoiceType: "Standard",
|
invoiceType: "Standard",
|
||||||
location: "Ronkonkoma",
|
location: "Ronkonkoma",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue