Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2075,7 +2075,16 @@ export const en = {
whoHeader: "Who",
actionHeader: "Action",
entityHeader: "Entity",
reasonHeader: "Reason",
// #745 — the column shows the audit payload as a readable summary where one
// is available, and falls back to the row's reason. Renamed from
// reasonHeader; AuditPage.test.tsx's header-marker table names the new key.
detailsHeader: "Details",
// Sales-line summaries. The product name and unit come from the payload
// itself (#747), never a lookup, so a renamed product cannot rewrite history.
detailsAtList: "at list {{amount}}",
detailsAtPrice: "at {{amount}}",
detailsListParen: "(list {{amount}})",
detailsNoListPrice: "(no list price)",
// #666 — the date window. Inclusive calendar days over the UTC timestamp,
// matching this screen's own "When (UTC)" column.
fromLabel: "From",
Expand Down
6 changes: 5 additions & 1 deletion web/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1574,7 +1574,11 @@ export const es = {
whoHeader: "Quién",
actionHeader: "Acción",
entityHeader: "Entidad",
reasonHeader: "Motivo",
detailsHeader: "Detalles",
detailsAtList: "a precio de lista {{amount}}",
detailsAtPrice: "a {{amount}}",
detailsListParen: "(lista {{amount}})",
detailsNoListPrice: "(sin precio de lista)",
fromLabel: "Desde",
toLabel: "Hasta",
emptyMessage: "Aún no hay eventos de auditoría.",
Expand Down
6 changes: 5 additions & 1 deletion web/src/i18n/tl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1632,7 +1632,11 @@ export const tl = {
whoHeader: "Sino",
actionHeader: "Aksyon",
entityHeader: "Entidad",
reasonHeader: "Dahilan",
detailsHeader: "Detalye",
detailsAtList: "sa listahang presyo na {{amount}}",
detailsAtPrice: "sa {{amount}}",
detailsListParen: "(listahan {{amount}})",
detailsNoListPrice: "(walang listahang presyo)",
fromLabel: "Mula",
toLabel: "Hanggang",
emptyMessage: "Wala pang audit event.",
Expand Down
92 changes: 91 additions & 1 deletion web/src/routes/AuditPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,96 @@ describe("AuditPage load + render", () => {
// controls — and 'load more' only appears when a full page came back.
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("renders an AddItem payload as the artboard's summary, from the payload alone", async () => {
mockListAuditEvents.mockResolvedValue([{
id: "d1",
occurredAtUtc: "2026-09-08T14:16:00Z",
actorEmail: "admin@farm.test",
action: "SalesOrder.AddItem",
entityType: "SalesOrder",
entityId: "10d2c04d-0000",
reason: null,
detailsJson: JSON.stringify({
salesOrderItemId: "11111111-0000", productId: "22222222-0000",
productName: "Large Eggs", unit: "Egg", quantity: 240,
unitPriceMinorUnits: 40, listUnitPriceMinorUnits: 45,
listPriceBasis: "Recorded", currencyCode: "USD", currencyMinorUnit: 2,
}),
}]);
renderAudit();

const row = await screen.findByRole("row", { name: /admin@farm\.test/ });
expect(within(row).getByText(/Large Eggs ×240/)).toBeInTheDocument();
expect(within(row).getByText(/\$0\.45/)).toBeInTheDocument();
});

it("renders an UpdateItem payload with the old price struck through and the new one bold", async () => {
mockListAuditEvents.mockResolvedValue([{
id: "d2",
occurredAtUtc: "2026-09-08T14:19:00Z",
actorEmail: "admin@farm.test",
action: "SalesOrder.UpdateItem",
entityType: "SalesOrder",
entityId: "10d2c04d-0000",
reason: null,
detailsJson: JSON.stringify({
salesOrderItemId: "11111111-0000", productId: "22222222-0000",
productName: "Medium Eggs", unit: "Dozen",
before: { quantity: 20, unitPriceMinorUnits: 540 },
after: { quantity: 20, unitPriceMinorUnits: 432 },
listUnitPriceMinorUnits: 540, listPriceBasis: "Recorded",
currencyCode: "USD", currencyMinorUnit: 2,
}),
}]);
renderAudit();

const row = await screen.findByRole("row", { name: /admin@farm\.test/ });
expect(within(row).getByText("Medium Eggs", { exact: false })).toBeInTheDocument();
// The artboard strikes the OLD price and bolds the NEW one. Asserting the
// element, not just the text, is what makes that a real check.
expect(within(row).getByText("$5.40", { selector: "s" })).toBeInTheDocument();
expect(within(row).getByText("$4.32", { selector: "strong" })).toBeInTheDocument();
});

it("shows no-list-price wording rather than a zero price when the list price is null", async () => {
mockListAuditEvents.mockResolvedValue([{
id: "d3",
occurredAtUtc: "2026-09-08T14:20:00Z",
actorEmail: "admin@farm.test",
action: "SalesOrder.UpdateItem",
entityType: "SalesOrder",
entityId: "10d2c04d-0000",
reason: null,
detailsJson: JSON.stringify({
salesOrderItemId: "11111111-0000", productId: "22222222-0000",
productName: "Cracked Eggs", unit: "Egg",
before: { quantity: 150, unitPriceMinorUnits: 20 },
after: { quantity: 150, unitPriceMinorUnits: 18 },
listUnitPriceMinorUnits: null, listPriceBasis: "ProductUnpriced",
currencyCode: "USD", currencyMinorUnit: 2,
}),
}]);
renderAudit();

const row = await screen.findByRole("row", { name: /admin@farm\.test/ });
// #720: a null list price is "no list price", never a zero discount.
expect(within(row).getByText(/no list price/i)).toBeInTheDocument();
expect(within(row).queryByText("$0.00")).not.toBeInTheDocument();
});

it("falls back to the reason, then an em dash, and never throws on a malformed payload", async () => {
mockListAuditEvents.mockResolvedValue([
{ ...EVENT_A, detailsJson: "{not json" },
EVENT_B,
]);
renderAudit();

const rowA = await screen.findByRole("row", { name: /admin@farm\.test/ });
expect(within(rowA).getByText("culled sick birds")).toBeInTheDocument();
const rowB = screen.getByRole("row", { name: /manager@farm\.test/ });
expect(within(rowB).getByText("—")).toBeInTheDocument();
});
});

describe("AuditPage filter", () => {
Expand Down Expand Up @@ -646,7 +736,7 @@ describe("AuditPage i18n wiring (#182, Task 29)", () => {
["whoHeader", "WHO-MARKER", "Who"],
["actionHeader", "ACTION-HEADER-MARKER", "Action"],
["entityHeader", "ENTITY-MARKER", "Entity"],
["reasonHeader", "REASON-MARKER", "Reason"],
["detailsHeader", "DETAILS-MARKER", "Details"],
] as const) {
await withOverride("audit", key, marker, async () => {
// Unmounted at the end of this iteration (afterEach's cleanup() only
Expand Down
74 changes: 71 additions & 3 deletions web/src/routes/AuditPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useFormat } from "../farm/useFormat";
import { useSearchParams } from "react-router";
import { listAuditEvents } from "../api/cluckwork";
import { listAuditEvents, type AuditEvent } from "../api/cluckwork";
import { usePagedList } from "../components/usePagedList";
import { isIsoCalendarDate } from "../lib/dates";
import {
Expand Down Expand Up @@ -60,6 +61,73 @@ export function isFetchStale(committedFetchPage: unknown, currentFetchPage: unkn
// `updateActionFilter`, `updateEntityTypeFilter` and `updateDateFilter` —
// builds a full copy from the CURRENT params rather than a partial object
// (INV-2).
// #745 — the Details cell. Renders the sales-line audit payloads as the summary
// the #722 artboard draws, and falls back to the row's reason, then an em dash.
//
// Everything it renders comes from the row's OWN payload. It never resolves
// productId against the catalogue: that would print a renamed product's CURRENT
// name on a historical row, which is exactly what #747's productName snapshot
// exists to prevent. A fetch in here is a defect, not an optimisation.
//
// Money goes through useFormat().money, which is farm-locale bound (§4.5/#650).
// formatMoney keyed off i18n.language is what formattingIndependence.test.ts
// exists to catch.
function AuditDetails({ event }: { event: AuditEvent }) {
const { t } = useTranslation("audit");
const fmt = useFormat();

const summary = (() => {
if (!event.detailsJson) return null;
let d: Record<string, unknown>;
try {
d = JSON.parse(event.detailsJson) as Record<string, unknown>;
} catch {
// A payload we cannot parse is not an error worth showing a user: fall
// through to the reason, exactly as a row with no payload does.
return null;
}

const name = typeof d.productName === "string" ? d.productName : null;
const code = typeof d.currencyCode === "string" ? d.currencyCode : null;
const minor = typeof d.currencyMinorUnit === "number" ? d.currencyMinorUnit : null;
if (name === null || code === null || minor === null) return null;
const money = (v: number) => fmt.money(v, code, minor);

// listUnitPriceMinorUnits is nullable and listPriceBasis says why (#720).
// A null must never render as a zero price.
const list = typeof d.listUnitPriceMinorUnits === "number" ? d.listUnitPriceMinorUnits : null;

if (event.action === "SalesOrder.AddItem") {
const qty = typeof d.quantity === "number" ? d.quantity : null;
const unit = typeof d.unitPriceMinorUnits === "number" ? d.unitPriceMinorUnits : null;
if (qty === null || unit === null) return null;
return (
<>
{name} ×{qty}{" "}
{list === null ? t("detailsAtPrice", { amount: money(unit) })
: t("detailsAtList", { amount: money(list) })}
</>
);
}

if (event.action === "SalesOrder.UpdateItem") {
const before = (d.before as { unitPriceMinorUnits?: unknown } | undefined)?.unitPriceMinorUnits;
const after = (d.after as { unitPriceMinorUnits?: unknown } | undefined)?.unitPriceMinorUnits;
if (typeof before !== "number" || typeof after !== "number") return null;
return (
<>
{name} <s>{money(before)}</s> → <strong>{money(after)}</strong>{" "}
{list === null ? t("detailsNoListPrice") : t("detailsListParen", { amount: money(list) })}
</>
);
}

return null;
})();

return <>{summary ?? event.reason ?? "—"}</>;
}

export function AuditPage() {
const { t } = useTranslation("audit");
const { t: tc } = useTranslation("common");
Expand Down Expand Up @@ -401,7 +469,7 @@ export function AuditPage() {
entity; repeating it up to 100 times is noise, not a
neutral no-op, so it's hidden rather than left in. */}
{!entityId && <th>{t("entityHeader")}</th>}
<th>{t("reasonHeader")}</th>
<th>{t("detailsHeader")}</th>
</tr>
</thead>
<tbody>
Expand All @@ -411,7 +479,7 @@ export function AuditPage() {
<td>{e.actorEmail}</td>
<td>{auditActionLabel(e.action)}</td>
{!entityId && <td>{entityTypeLabel(e.entityType)} {e.entityId.slice(0, 8)}</td>}
<td>{e.reason ?? "—"}</td>
<td><AuditDetails event={e} /></td>
</tr>
))}
</tbody>
Expand Down
Loading