diff --git a/specs/product/GLOSSARY.md b/specs/product/GLOSSARY.md index 7d6712bb..cf7b04b1 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -499,6 +499,17 @@ this link "Audit history" like the other five would promise a full lifecycle the data can't deliver: a normal, never-adjusted lot shows nothing at all under that scope. "Adjustment history" says exactly what it shows. +**Record-type filter (#520)** — a second dropdown on the /audit viewer, +ahead of the existing action filter. Picking a record type (Flock, Sales +order, User, …) does not query anything itself — it narrows the action +dropdown's option list to only the actions actually recorded against that +type, so an admin who knows *what kind of record* they're after isn't +scanning one flat list of every action the farm can log to find it. The +mapping from action to type is read off the server's own audit-write call +sites, not the action code's own "Entity.Verb" prefix — the four +`Account.Set/RemoveLogo/Banner` actions are prefixed `Account` but recorded +against `FarmLogo`, a row shared by both the logo and the banner. + **Hen-day % (#91)** — eggs collected ÷ hen-days × 100 (spec §19.3). A hen-day is one bird alive for one day; the day's bird count comes from the bird ledger (placements + movements). The production report shows it per diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index af5f905d..b974c90c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1846,6 +1846,8 @@ export const en = { // loaded, or if the entity has zero audit events — deliberately not // distinguishing those two cases (Gate 3). scopedHeadingFallback: "Record history", + entityTypeFilterLabel: "Record type", + allEntityTypesOption: "All types", actionFilterLabel: "Action", allActionsOption: "All actions", whenHeader: "When (UTC)", @@ -2572,6 +2574,11 @@ export const en = { + "it, when (UTC), what it touched, and the reason where one was given. Written together with the " + "change itself — a failed action leaves no trace, a successful one always does — and never editable, " + "by anyone.", + auditRecordTypeFilter: + "The Audit log's \"Record type\" dropdown does not filter the rows itself — it narrows the " + + "\"Action\" dropdown next to it down to only the actions that happen on the type you pick " + + "(Flock, Sales order, and so on), so you are not scanning one long list of every action the " + + "farm can log. Pick the action from that narrowed list to actually filter the rows.", auditRecordHistory: "Flocks, Egg grades, Daily entry history, Sales and Expenses each carry a History column showing who " + "created the record and when, plus who last changed it if anyone has. It is read from the same audit " diff --git a/web/src/i18n/enums.ts b/web/src/i18n/enums.ts index 8f87e179..d040b22b 100644 --- a/web/src/i18n/enums.ts +++ b/web/src/i18n/enums.ts @@ -353,6 +353,66 @@ export function auditActionLabel(value: AuditActionValue | (string & {})): strin return key ? i18n.t(key) : String(value); } +// Which EntityType each action is actually recorded against server-side — +// read off every audit.WriteAsync(...) call site, not derived from the +// action's own "Entity.Verb" prefix, which is misleading for the four +// Account.Set/RemoveLogo/Banner actions (prefixed "Account", recorded +// against "FarmLogo" — a shared row FarmLogo.cs documents as covering both +// logo and banner). Powers AuditPage's entity-type dropdown, which narrows +// the action dropdown to only actions that can occur on the selected type. +export const AUDIT_ACTION_ENTITY_TYPE = { + "DailyEntry.Adjust": "DailyEntry", + "DailyEntry.Void": "DailyEntry", + "SalesOrder.Void": "SalesOrder", + "Payment.Void": "Payment", + "Expense.Adjust": "Expense", + "ExpenseCategory.Update": "ExpenseCategory", + "InventoryItem.Adjust": "InventoryItem", + "WaterUsage.Correct": "WaterUsage", + "Flock.BirdMovement": "Flock", + "Flock.Update": "Flock", + "Flock.Deplete": "Flock", + "Flock.Archive": "Flock", + "Flock.Reactivate": "Flock", + "EggGrade.Update": "EggGrade", + "EggGrade.Activate": "EggGrade", + "EggGrade.Deactivate": "EggGrade", + "User.Create": "User", + "User.Update": "User", + "User.PasswordSet": "User", + "User.PasswordChanged": "User", + "User.BreakGlassReset": "User", + "User.RoleChanged": "User", + "User.Disabled": "User", + "User.Enabled": "User", + "User.FlockAssign": "User", + "User.FlockUnassign": "User", + "Account.Export": "Account", + "Account.SetLogo": "FarmLogo", + "Account.RemoveLogo": "FarmLogo", + "Account.SetBanner": "FarmLogo", + "Account.RemoveBanner": "FarmLogo", + "Account.UpdateSettings": "Account", + "Product.Create": "Product", + "Product.Update": "Product", + "Product.Activate": "Product", + "Product.Deactivate": "Product", + "EggUnitConversion.Update": "EggUnitConversion", + "EggLot.Movement": "EggLot", + "Flock.Create": "Flock", + "DailyEntry.Create": "DailyEntry", + "DailyEntry.Update": "DailyEntry", + "DailyEntry.Submit": "DailyEntry", + "SalesOrder.Create": "SalesOrder", + "SalesOrder.Confirm": "SalesOrder", + "SalesOrder.Cancel": "SalesOrder", + "SalesOrder.AddItem": "SalesOrder", + "SalesOrder.UpdateItem": "SalesOrder", + "SalesOrder.RemoveItem": "SalesOrder", + "Expense.Create": "Expense", + "EggGrade.Create": "EggGrade", +} as const satisfies Record; + // --------------------------------------------------------------------------- // entity type (AuditPage table entity cell) — AuditEvent.entityType. // --------------------------------------------------------------------------- diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index d48abb39..ad5c5fc6 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -1442,6 +1442,8 @@ export const es = { + "mismo y nunca se editan.", scopedHeading: "Historial de {{entityType}}", scopedHeadingFallback: "Historial del registro", + entityTypeFilterLabel: "Tipo de registro", + allEntityTypesOption: "Todos los tipos", actionFilterLabel: "Acción", allActionsOption: "Todas las acciones", whenHeader: "Cuándo (UTC)", @@ -2141,6 +2143,11 @@ export const es = { + "automáticamente: quién lo hizo, cuándo (UTC), qué afectó, y el motivo cuando se dio uno. Se " + "escribe junto con el cambio mismo — una acción fallida no deja rastro, una exitosa siempre lo hace " + "— y nunca es editable, por nadie.", + auditRecordTypeFilter: + "El menú \"Tipo de registro\" del registro de auditoría no filtra las filas por sí solo — reduce el " + + "menú \"Acción\" de al lado a solo las acciones que ocurren en el tipo elegido (Lote, Pedido de " + + "venta, etc.), para que no tengas que revisar una lista larga con cada acción que la granja puede " + + "registrar. Elige la acción de esa lista reducida para filtrar las filas realmente.", auditRecordHistory: "Lotes, Grados de huevo, Historial de registro diario, Ventas y Gastos tienen una columna Historial " + "que muestra quién creó el registro y cuándo, más quién lo modificó por última vez si alguien lo " diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 1f2cb63a..53567834 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -1495,6 +1495,8 @@ export const tl = { + "pagbabago mismo at hindi na ito ine-edit pa.", scopedHeading: "Kasaysayan ng {{entityType}}", scopedHeadingFallback: "Kasaysayan ng record", + entityTypeFilterLabel: "Uri ng record", + allEntityTypesOption: "Lahat ng uri", actionFilterLabel: "Aksyon", allActionsOption: "Lahat ng aksyon", whenHeader: "Kailan (UTC)", @@ -2223,6 +2225,12 @@ export const tl = { + "awtomatiko: sino ang gumawa nito, kailan (UTC), ano ang naapektuhan, at ang dahilan kung mayroon. " + "Isinusulat ito kasabay ng pagbabago mismo — walang naiiwang bakas ang isang nabigong aksyon, " + "palaging may naiiwan ang isang matagumpay — at hindi kailanman ine-edit, ng kahit sino.", + auditRecordTypeFilter: + "Ang dropdown na \"Uri ng record\" sa audit log ay hindi ito ang nagsa-salà sa mga row — pinapaliit " + + "lamang nito ang dropdown na \"Aksyon\" sa tabi nito para lang sa mga aksyong nangyayari sa uring " + + "pinili mo (Kawan, Order ng benta, atbp.), para hindi mo na kailangang mag-scan sa isang mahabang " + + "listahan ng bawat aksyong maaaring itala ng bukid. Piliin ang aksyon sa napaliit na listahang iyon " + + "para talagang masalà ang mga row.", auditRecordHistory: "Ang Mga kawan, Grado ng itlog, Kasaysayan ng pang-araw-araw na entry, Benta at Gastos ay may " + "kolum na Kasaysayan na nagpapakita kung sino ang gumawa ng record at kailan, at kung sino ang " diff --git a/web/src/routes/AuditPage.test.tsx b/web/src/routes/AuditPage.test.tsx index 9fec25f8..fd56c0aa 100644 --- a/web/src/routes/AuditPage.test.tsx +++ b/web/src/routes/AuditPage.test.tsx @@ -193,7 +193,7 @@ describe("AuditPage filter", () => { await screen.findByText("No audit events yet."); // let the mount load settle first await act(async () => { - fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } }); }); // The ARGUMENT is the behavior: the chosen action must reach the seam. @@ -201,6 +201,110 @@ describe("AuditPage filter", () => { expect.objectContaining({ action: "Flock.Deplete", offset: 0 }), ); }); + + it("narrows the action dropdown to only actions recorded against the chosen record type", async () => { + renderAudit(); + await screen.findByText("No audit events yet."); + + const actionSelect = screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement; + const optionValues = () => Array.from(actionSelect.options).map((o) => o.value); + expect(optionValues()).toContain("User.Create"); // unfiltered: every action listed + expect(optionValues()).toContain("Flock.Deplete"); + + fireEvent.change(screen.getByRole("combobox", { name: "Record type" }), { + target: { value: "Flock" }, + }); + + // Only Flock-recorded actions remain, plus the "All actions" blank option. + expect(optionValues()).toEqual( + expect.arrayContaining(["", "Flock.BirdMovement", "Flock.Update", "Flock.Deplete", "Flock.Archive", "Flock.Reactivate", "Flock.Create"]), + ); + expect(optionValues()).not.toContain("User.Create"); + expect(optionValues()).not.toContain("SalesOrder.Void"); + }); + + it("resets a selected action that no longer matches once the record type changes", async () => { + mockListAuditEvents.mockResolvedValue([]); + renderAudit(); + await screen.findByText("No audit events yet."); + + await act(async () => { + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { + target: { value: "User.Create" }, + }); + }); + expect(mockListAuditEvents).toHaveBeenLastCalledWith( + expect.objectContaining({ action: "User.Create" }), + ); + + // "User.Create" isn't a Flock action — picking Flock as the record type + // must not leave a hidden, mismatched action filter still in effect. + await act(async () => { + fireEvent.change(screen.getByRole("combobox", { name: "Record type" }), { + target: { value: "Flock" }, + }); + }); + + expect(mockListAuditEvents).toHaveBeenLastCalledWith( + expect.objectContaining({ action: undefined }), + ); + expect((screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).value).toBe(""); + }); + + it("carries the record-type filter in the URL, independent of entityId scoping", async () => { + renderAudit("/audit?entityType=Flock"); + await screen.findByText("No audit events yet."); + + expect((screen.getByRole("combobox", { name: "Record type" }) as HTMLSelectElement).value).toBe( + "Flock", + ); + }); + + it("falls back to the unfiltered action list for a garbage entityType query value", async () => { + renderAudit("/audit?entityType=NotARealType"); + await screen.findByText("No audit events yet."); + + expect((screen.getByRole("combobox", { name: "Record type" }) as HTMLSelectElement).value).toBe( + "", + ); + expect( + Array.from( + (screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).options, + ).map((o) => o.value), + ).toContain("User.Create"); // unfiltered — the bogus value is ignored, not applied + }); + + it("ignores an action from the URL that is incompatible with the entityType also in the URL (codex review of #521)", async () => { + mockListAuditEvents.mockResolvedValue([]); + // Flock + User.Create is a real but incompatible pair — User.Create is + // never in Flock's action list, so a hand-edited or shared URL carrying + // both must not silently keep querying the hidden action. + renderAudit("/audit?entityType=Flock&action=User.Create"); + await screen.findByText("No audit events yet."); + + expect(mockListAuditEvents).toHaveBeenLastCalledWith( + expect.objectContaining({ action: undefined }), + ); + expect((screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).value).toBe(""); + }); + + it("clears the entityType query param when the record type is reset to 'All types'", async () => { + renderAudit("/audit?entityType=Flock"); + await screen.findByText("No audit events yet."); + + fireEvent.change(screen.getByRole("combobox", { name: "Record type" }), { + target: { value: "" }, + }); + + expect((screen.getByRole("combobox", { name: "Record type" }) as HTMLSelectElement).value).toBe( + "", + ); + expect( + Array.from( + (screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).options, + ).map((o) => o.value), + ).toContain("SalesOrder.Void"); // back to the full, unfiltered list + }); }); describe("AuditPage paging", () => { @@ -486,7 +590,7 @@ describe("AuditPage entity-scoped mode (#493)", () => { await screen.findByText("No audit events for this record yet."); await act(async () => { - fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } }); }); expect(mockListAuditEvents).toHaveBeenLastCalledWith( @@ -500,7 +604,7 @@ describe("AuditPage entity-scoped mode (#493)", () => { await screen.findByText("No audit events yet."); await act(async () => { - fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } }); }); expect(mockListAuditEvents).toHaveBeenLastCalledWith( @@ -524,7 +628,7 @@ describe("AuditPage entity-scoped mode (#493)", () => { mockListAuditEvents.mockReturnValueOnce(new Promise((r) => (resolveReload = r))); await act(async () => { - fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } }); }); // The reload is in flight: the heading must NOT still say "Flock diff --git a/web/src/routes/AuditPage.tsx b/web/src/routes/AuditPage.tsx index 476f9fdd..2ab333e0 100644 --- a/web/src/routes/AuditPage.tsx +++ b/web/src/routes/AuditPage.tsx @@ -3,7 +3,14 @@ import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router"; import { listAuditEvents } from "../api/cluckwork"; import { usePagedList } from "../components/usePagedList"; -import { AUDIT_ACTION_VALUES, auditActionLabel, entityTypeLabel } from "../i18n/enums"; +import { + AUDIT_ACTION_ENTITY_TYPE, + AUDIT_ACTION_VALUES, + auditActionLabel, + ENTITY_TYPE_VALUES, + entityTypeLabel, + type EntityTypeValue, +} from "../i18n/enums"; const PAGE = 100; @@ -56,7 +63,7 @@ export function AuditPage() { const { t: tc } = useTranslation("common"); const [searchParams, setSearchParams] = useSearchParams(); - const actionFilter = searchParams.get("action") ?? ""; + const rawActionFilter = searchParams.get("action") ?? ""; const rawEntityId = searchParams.get("entityId"); // Lowercased (codex review of #516): the API returns EntityId as a .NET // Guid, which System.Text.Json serializes lowercase regardless of the @@ -75,6 +82,43 @@ export function AuditPage() { setSearchParams(next); }, [searchParams, setSearchParams]); + // Entity-type filter narrows the action dropdown's OPTION LIST only — it is + // never sent to the server (the /api/v1/audit query still filters on + // `action` alone, matching what the backend supports). Changing it drops + // any selected `action`: AUDIT_ACTION_ENTITY_TYPE is a many-to-one map, so + // a previously chosen action can fall outside the new type's option list, + // and leaving it selected-but-hidden would silently keep querying against + // a type the visible dropdown no longer shows. + const rawEntityTypeFilter = searchParams.get("entityType"); + const entityTypeFilter: EntityTypeValue | "" = + rawEntityTypeFilter && (ENTITY_TYPE_VALUES as readonly string[]).includes(rawEntityTypeFilter) + ? (rawEntityTypeFilter as EntityTypeValue) + : ""; + + const updateEntityTypeFilter = useCallback((type: string) => { + const next = new URLSearchParams(searchParams); + if (type) next.set("entityType", type); + else next.delete("entityType"); + next.delete("action"); + setSearchParams(next); + }, [searchParams, setSearchParams]); + + const availableActions = entityTypeFilter + ? AUDIT_ACTION_VALUES.filter((a) => AUDIT_ACTION_ENTITY_TYPE[a] === entityTypeFilter) + : AUDIT_ACTION_VALUES; + + // codex review of #521 — a hand-edited or shared URL can carry a valid but + // INCOMPATIBLE pair, e.g. ?entityType=Flock&action=User.Create: the raw + // action isn't in `availableActions` for that type, so the updateEntityTypeFilter(e.target.value)}> + + {ENTITY_TYPE_VALUES.map((et) => ( + + ))} + +