From 47fce5a63495dede9c6458ed3ee35f8ba3b88e19 Mon Sep 17 00:00:00 2001 From: mforce Date: Thu, 17 Sep 2026 23:17:48 +0000 Subject: [PATCH 01/14] feat(web): add the shared FilterBar component (#831) Paper(variant="outlined") + Stack(direction="row", flexWrap, useFlexGap) holding a caller-supplied filter row, plus a FilterDateField helper that carries #653's 12rem bounded date width from md up and widens to one control per line below it. Ledger screens adopt it starting with the next commit; Audit (#833) can pick it up from this branch or from main once this lands. --- web/src/components/FilterBar.test.tsx | 42 ++++++++++++++++++++++ web/src/components/FilterBar.tsx | 51 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 web/src/components/FilterBar.test.tsx create mode 100644 web/src/components/FilterBar.tsx diff --git a/web/src/components/FilterBar.test.tsx b/web/src/components/FilterBar.test.tsx new file mode 100644 index 00000000..974c3783 --- /dev/null +++ b/web/src/components/FilterBar.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FilterBar, FilterDateField } from "./FilterBar"; + +describe("FilterBar", () => { + it("renders every child control, each reachable by its own label", () => { + render( + + {}} /> + {}} /> + , + ); + expect(screen.getByLabelText("From", { exact: true })).toHaveValue("2026-01-01"); + expect(screen.getByLabelText("To", { exact: true })).toHaveValue("2026-01-31"); + }); + + it("lays the row out as a wrapping flex row, never a column, at rest", () => { + render( + + {}} /> + , + ); + // The Stack is the immediate child of the outlined Paper. + const paper = screen.getByLabelText("From").closest(".MuiPaper-root"); + expect(paper).not.toBeNull(); + expect(paper).toHaveClass("MuiPaper-outlined"); + const stack = paper!.querySelector(":scope > .MuiStack-root"); + expect(stack).not.toBeNull(); + expect(stack).toHaveStyle({ flexWrap: "wrap" }); + }); + + it("a date field reports the value change the caller's onChange receives", () => { + const onChange = vi.fn(); + render( + + + , + ); + fireEvent.change(screen.getByLabelText("From", { exact: true }), { target: { value: "2026-02-01" } }); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx new file mode 100644 index 00000000..93a0faa2 --- /dev/null +++ b/web/src/components/FilterBar.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import { Paper, Stack, TextField } from "@mui/material"; +import type { TextFieldProps } from "@mui/material"; + +/** + * #831/#653 — the shared filter row every ledger screen (Sales, Stock, + * Inventory, History, Expenses, Feed, Water, Reports) and Audit (#833) mount + * above their table. `variant="outlined"` is load-bearing: #651 D1 measured + * `--surface-2` against `--canvas` at 1.05:1-1.21:1 in every palette and + * mode, too close to read as an edge without the hairline border. + */ +export function FilterBar({ children }: { children: ReactNode }) { + return ( + + *": { flex: { xs: "1 1 100%", md: "0 0 auto" } }, + }} + > + {children} + + + ); +} + +const DATE_FIELD_MAX_WIDTH = "12rem"; + +/** + * A date control sized for a FilterBar. Bounded at #653's 12rem from `md` up + * (two ten-character dates do not need the row's full width); the FilterBar + * itself widens it back to one control per line below that, per D3.3. + */ +export function FilterDateField({ sx, slotProps, ...props }: TextFieldProps) { + return ( + + ); +} From 847c21b8a3db13efc941d1722f10b21e1c46f0eb Mon Sep 17 00:00:00 2001 From: mforce Date: Thu, 17 Sep 2026 23:28:04 +0000 Subject: [PATCH 02/14] feat(web): convert Reports and Feed to MUI (#831) Pair 7 (FilterBar), pair 9 (Table/TableContainer), pair 10 (title row) and pair 11 (Feed's inline capture form). Feed's flock pickers keep the retired `.form-grid .named-picker` sizing (15rem/8rem/100%) as an inline sx constant so the closed/open states stop shifting siblings. --- web/src/routes/FeedPage.tsx | 201 ++++++++++++++---------- web/src/routes/ReportsPage.test.tsx | 12 +- web/src/routes/ReportsPage.tsx | 234 +++++++++++++++------------- 3 files changed, 248 insertions(+), 199 deletions(-) diff --git a/web/src/routes/FeedPage.tsx b/web/src/routes/FeedPage.tsx index 11f33aae..9dc73a3d 100644 --- a/web/src/routes/FeedPage.tsx +++ b/web/src/routes/FeedPage.tsx @@ -3,6 +3,9 @@ import type { FormEvent } from "react"; import { useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import { FilterX, Inbox } from "lucide-react"; +import { + Box, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, +} from "@mui/material"; import { listFeedUsage, listFlocks, listInventoryItems, recordFeedUsage, } from "../api/cluckwork"; @@ -12,6 +15,7 @@ import { useFormat } from "../farm/useFormat"; import { FarmDate } from "../components/FarmDate"; import { BusyButton } from "../components/BusyButton"; import { EmptyState } from "../components/EmptyState"; +import { FilterBar, FilterDateField } from "../components/FilterBar"; import { FlockPicker } from "../components/FlockPicker"; import type { PickerSnapshot } from "../components/NamedEntityPicker"; import { usePagedList } from "../components/usePagedList"; @@ -21,6 +25,11 @@ import { newId } from "../lib/ids"; import i18n from "../i18n"; const PAGE = 50; +const NOWRAP = { whiteSpace: "nowrap" as const }; +// #831 — replicates the retired `.form-grid .named-picker` rule: without a +// fixed flex-basis the picker's closed (button) and open (input) states have +// different intrinsic widths, which used to shift every sibling field. +const PICKER_SX = { flex: "0 1 15rem", width: "15rem", minWidth: "8rem", maxWidth: "100%" }; // Client-side mirror of RecordFeedUsageHandler.FeedableCategories — one copy // for the SPA (InventoryPage imports it for its panel link). The server @@ -236,73 +245,92 @@ export function FeedPage() { }); } - if (error && usage.rows === null) return

{t("title")}

{error}

; - if (usage.rows === null) return

{t("title")}

{tc("loading")}

; + if (error && usage.rows === null) return
{t("title")}

{error}

; + if (usage.rows === null) return
{t("title")}

{tc("loading")}

; return (
-

{t("title")}

+ {t("title")}

{t("intro")}

-
- { - setCaptureFlock(f); - setCaptureFlockGen((g) => g + 1); - setCapturePickerOpen(false); - }} - onEscape={() => setCapturePickerOpen(false)} - onOutsideClick={() => setCapturePickerOpen(false)} - trigger={ - - } + + + { + setCaptureFlock(f); + setCaptureFlockGen((g) => g + 1); + setCapturePickerOpen(false); + }} + onEscape={() => setCapturePickerOpen(false)} + onOutsideClick={() => setCapturePickerOpen(false)} + trigger={ + + } + /> + + setItemId(e.target.value)} + > + {pickableItems.map((x) => ( + + ))} + + setDate(e.target.value)} /> - - - - + value={quantity} + size="small" + slotProps={{ htmlInput: { min: 0.001, step: 0.001, required: true } }} + onChange={(e) => setQuantity(e.target.value)} + /> + setNote(e.target.value)} + /> {t("recordFeedButton")} - + {/* Feed is create-only — the FIFO stock draw already happened, so a mis-entry is undone with a compensating lot adjustment, not an edit. */} @@ -315,8 +343,8 @@ export function FeedPage() { {/* List failures degrade the LIST only — the capture form must stay usable through a transient history read failure (review of #446). */} {usage.error &&

{usage.error}

} -
-
+ + } /> -
- {/* #653 — the date range gets its own bounded toolbar; the flock - picker above stays a plain form-grid field. */} -
- - -
-
+ + setFrom(e.target.value)} /> + setTo(e.target.value)} /> + {/* One window's rows must never sit under another window's controls, not even for the length of the request (#469). Only this region is @@ -379,23 +399,32 @@ export function FeedPage() { : ) : ( <> - - - - - - {usage.rows.map((r) => ( - - - - - - - - - ))} - -
{t("dateHeader")}{t("flockHeader")}{t("itemHeader")}{t("amountHeader")}{t("estimatedCostHeader")}{t("noteHeader")}
{r.flockName ?? t("rowFlockUnavailable")}{itemName(r.inventoryItemId)}{fmt.count(r.quantity)} {r.unit}{fmt.money(r.estimatedCostMinorUnits, r.currencyCode, r.currencyMinorUnit)}{r.note ?? ""}
+ + + + + {t("dateHeader")} + {t("flockHeader")} + {t("itemHeader")} + {t("amountHeader")} + {t("estimatedCostHeader")} + {t("noteHeader")} + + + + {usage.rows.map((r) => ( + + + {r.flockName ?? t("rowFlockUnavailable")} + {itemName(r.inventoryItemId)} + {fmt.count(r.quantity)} {r.unit} + {fmt.money(r.estimatedCostMinorUnits, r.currencyCode, r.currencyMinorUnit)} + {r.note ?? ""} + + ))} + +
+
{usage.canLoadMore && ( // Two rapid clicks cannot append the same page twice: the hook // no-ops a load-more while one is in flight, and canLoadMore diff --git a/web/src/routes/ReportsPage.test.tsx b/web/src/routes/ReportsPage.test.tsx index c58d0ea0..e1fcecbc 100644 --- a/web/src/routes/ReportsPage.test.tsx +++ b/web/src/routes/ReportsPage.test.tsx @@ -99,12 +99,12 @@ describe("ReportsPage production section (renders for every role)", () => { // missing, and eggs ÷ Recorded has to reproduce the percentage beside it. expect(within(row1).getAllByText("98")).toHaveLength(2); // henDays, recordedHenDays within(row1).getByText("91.8"); // henDayPct - // #650 — figures are numeric cells: right-aligned tabular nowrap (styles.num.test.ts - // pins what the class does; this pins that the screen puts it on the figure and - // its header, and keeps it off the date). - for (const cell of within(row1).getAllByText("100")) expect(cell).toHaveClass("num"); - expect(within(row1).getByText("07/19/2026")).not.toHaveClass("num"); - expect(screen.getByRole("columnheader", { name: "Eggs" })).toHaveClass("num"); + // #650 — figures are numeric cells: right-aligned, tabular numerals (the + // theme's MuiTableCell rule pins tabular-nums globally; this pins that the + // screen right-aligns the figure and its header, and keeps the date left). + for (const cell of within(row1).getAllByText("100")) expect(cell).toHaveStyle({ textAlign: "right" }); + expect(within(row1).getByText("07/19/2026")).not.toHaveStyle({ textAlign: "right" }); + expect(screen.getByRole("columnheader", { name: "Eggs" })).toHaveStyle({ textAlign: "right" }); const row2 = screen.getByRole("row", { name: /07\/18\/2026/ }); within(row2).getByText("—"); // null henDayPct falls back to the em dash diff --git a/web/src/routes/ReportsPage.tsx b/web/src/routes/ReportsPage.tsx index 9267c689..4d7c2202 100644 --- a/web/src/routes/ReportsPage.tsx +++ b/web/src/routes/ReportsPage.tsx @@ -1,5 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { + Table, TableBody, TableCell, TableContainer, TableFooter, TableHead, TableRow, Typography, +} from "@mui/material"; import { getExpenseSummary, getProductionReport, getProfitReport, getSalesSummary, } from "../api/cluckwork"; @@ -9,10 +12,15 @@ import type { import { ApiError } from "../api/client"; import { useFormat } from "../farm/useFormat"; import { FarmDate } from "../components/FarmDate"; +import { FilterBar, FilterDateField } from "../components/FilterBar"; import { daysBefore } from "../lib/dates"; import { useFarmToday } from "../farm/useFarm"; import { useAuth } from "../auth/useAuth"; +// MUI's auto table layout shrinks any wrappable cell below its content width, +// so a short value (a date) is pinned; free text wraps (#897 convention). +const NOWRAP = { whiteSpace: "nowrap" as const }; + function errText(err: unknown): string { if (err instanceof ApiError) return err.message; return err instanceof Error ? err.message : String(err); @@ -77,21 +85,25 @@ export function ReportsPage() { return (
-

{t("title")}

+ {t("title")} {/* #653 — the only controls on this screen are the date range, so the - whole bar is the toolbar (Reports has no other filter to keep + whole bar is the filter bar (Reports has no other filter to keep separate, unlike History/Feed/Water below). */} -
- - -
+ + setFrom(e.target.value)} + /> + setTo(e.target.value)} + /> + {error && (

@@ -112,58 +124,64 @@ export function ReportsPage() { {production && ( <>

{t("productionHeading")}

- - - - - {/* #396 — beside Sellable, not folded into it: Sellable is the - hand-graded remainder, Condition is what the cracked/dirty - counters contributed as stock. */} - - - {/* #780 — the percentage's own numerator and denominator. Eggs - ÷ Hen-days stopped reproducing Hen-day %: Hen-days is every - bird alive, the rate divides by the flocks that recorded, - and its numerator excludes any flock whose birds it excludes. - Showing only one half left the row inviting a division that - gives the wrong answer. The gap between Hen-days and Recorded - is what the period is missing. */} - - - - - - - {production.days.map((d) => ( - - - - - - - - - - - - - ))} - - - - - - - - - - - - - - - -
{t("dateHeader")}{t("eggsHeader")}{t("lossesHeader")}{t("sellableHeader")}{t("conditionHeader")}{t("deathsHeader")}{t("henDaysHeader")}{t("recordedHenDaysHeader")}{t("ratedEggsHeader")}{t("henDayPctHeader")}
{fmt.count(d.totalEggs)}{fmt.count(d.cracked)}/{fmt.count(d.dirty)}/{fmt.count(d.discarded)}{fmt.count(d.sellable)}{fmt.count(d.fromCounts)}{fmt.count(d.deaths)}{fmt.count(d.henDays)}{fmt.count(d.recordedHenDays)}{fmt.count(d.ratedEggs)}{d.henDayPct === null ? "—" : fmt.count(d.henDayPct, 1)}
{t("periodRowLabel")}{fmt.count(production.totalEggs)}{fmt.count(production.totalSellable)}{fmt.count(production.totalFromCounts)}{fmt.count(production.totalDeaths)}{fmt.count(production.totalHenDays)}{fmt.count(production.totalRecordedHenDays)}{fmt.count(production.totalRatedEggs)}{production.periodHenDayPct === null ? "—" : fmt.count(production.periodHenDayPct, 1)}
+ + + + + {t("dateHeader")} + {t("eggsHeader")} + {t("lossesHeader")} + {t("sellableHeader")} + {/* #396 — beside Sellable, not folded into it: Sellable is the + hand-graded remainder, Condition is what the cracked/dirty + counters contributed as stock. */} + {t("conditionHeader")} + {t("deathsHeader")} + {t("henDaysHeader")} + {/* #780 — the percentage's own numerator and denominator. Eggs + ÷ Hen-days stopped reproducing Hen-day %: Hen-days is every + bird alive, the rate divides by the flocks that recorded, + and its numerator excludes any flock whose birds it excludes. + Showing only one half left the row inviting a division that + gives the wrong answer. The gap between Hen-days and Recorded + is what the period is missing. */} + {t("recordedHenDaysHeader")} + {t("ratedEggsHeader")} + {t("henDayPctHeader")} + + + + {production.days.map((d) => ( + + + {fmt.count(d.totalEggs)} + {fmt.count(d.cracked)}/{fmt.count(d.dirty)}/{fmt.count(d.discarded)} + {fmt.count(d.sellable)} + {fmt.count(d.fromCounts)} + {fmt.count(d.deaths)} + {fmt.count(d.henDays)} + {fmt.count(d.recordedHenDays)} + {fmt.count(d.ratedEggs)} + {d.henDayPct === null ? "—" : fmt.count(d.henDayPct, 1)} + + ))} + + + + {t("periodRowLabel")} + {fmt.count(production.totalEggs)} + + {fmt.count(production.totalSellable)} + {fmt.count(production.totalFromCounts)} + {fmt.count(production.totalDeaths)} + {fmt.count(production.totalHenDays)} + {fmt.count(production.totalRecordedHenDays)} + {fmt.count(production.totalRatedEggs)} + {production.periodHenDayPct === null ? "—" : fmt.count(production.periodHenDayPct, 1)} + + +
+
{production.gradeTotals.length > 0 && (

{t("gradeTotalsLabel")}{" "} @@ -176,49 +194,51 @@ export function ReportsPage() { {isAdmin && sales && expenses && profit && ( <>

{t("moneyHeading")}

- - - - - - - - - - - - - - - -
{t("salesRowLabel")} - {t("salesSummary", { - count: sales.confirmedCount, - confirmed: fmt.count(sales.confirmedCount), - revenue: fmt.money(sales.revenueMinorUnits, sales.currencyCode, sales.currencyMinorUnit), - paid: fmt.money(sales.paidMinorUnits, sales.currencyCode, sales.currencyMinorUnit), - outstanding: fmt.money(sales.outstandingMinorUnits, sales.currencyCode, sales.currencyMinorUnit), - })} - {sales.voidedCount > 0 ? t("salesVoidedSuffix", { count: sales.voidedCount, voided: fmt.count(sales.voidedCount) }) : ""} -
{t("expensesRowLabel")} - {expenses.categories.length === 0 - ? t("expensesNone") - : expenses.categories - .map((c) => `${c.name} ${fmt.money(c.totalMinorUnits, expenses.currencyCode, expenses.currencyMinorUnit)}`) - .join(", ")} - {t("expensesTotalSuffix", { - total: fmt.money(expenses.grandTotalMinorUnits, expenses.currencyCode, expenses.currencyMinorUnit), - })} -
{t("profitRowLabel")} - }} - /> -
+ + + + + {t("salesRowLabel")} + + {t("salesSummary", { + count: sales.confirmedCount, + confirmed: fmt.count(sales.confirmedCount), + revenue: fmt.money(sales.revenueMinorUnits, sales.currencyCode, sales.currencyMinorUnit), + paid: fmt.money(sales.paidMinorUnits, sales.currencyCode, sales.currencyMinorUnit), + outstanding: fmt.money(sales.outstandingMinorUnits, sales.currencyCode, sales.currencyMinorUnit), + })} + {sales.voidedCount > 0 ? t("salesVoidedSuffix", { count: sales.voidedCount, voided: fmt.count(sales.voidedCount) }) : ""} + + + + {t("expensesRowLabel")} + + {expenses.categories.length === 0 + ? t("expensesNone") + : expenses.categories + .map((c) => `${c.name} ${fmt.money(c.totalMinorUnits, expenses.currencyCode, expenses.currencyMinorUnit)}`) + .join(", ")} + {t("expensesTotalSuffix", { + total: fmt.money(expenses.grandTotalMinorUnits, expenses.currencyCode, expenses.currencyMinorUnit), + })} + + + + {t("profitRowLabel")} + + }} + /> + + + +
+

{t("profitFootnote")}

From 2e3613ff2c60df618c212fae42a4a739d5ebf280 Mon Sep 17 00:00:00 2001 From: mforce Date: Thu, 17 Sep 2026 23:29:40 +0000 Subject: [PATCH 03/14] feat(web): convert Water to MUI (#831) Same shape as Feed: pair 7 (FilterBar), pair 9 (Table), pair 10 (title row), pair 11 (capture form, including the meter-readings checkbox to FormControlLabel+Checkbox). All 30 existing tests pass unchanged. --- web/src/routes/WaterPage.tsx | 298 ++++++++++++++++++++--------------- 1 file changed, 173 insertions(+), 125 deletions(-) diff --git a/web/src/routes/WaterPage.tsx b/web/src/routes/WaterPage.tsx index b0e8d00d..628c826b 100644 --- a/web/src/routes/WaterPage.tsx +++ b/web/src/routes/WaterPage.tsx @@ -2,6 +2,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import { useTranslation } from "react-i18next"; import { FilterX, Inbox } from "lucide-react"; +import { + Box, Checkbox, FormControlLabel, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, +} from "@mui/material"; import { listFlocks, listWaterUsage, recordWaterUsage, updateWaterUsage } from "../api/cluckwork"; import type { Flock, WaterUsage } from "../api/cluckwork"; import { ApiError } from "../api/client"; @@ -10,6 +13,7 @@ import { FarmDate } from "../components/FarmDate"; import { useAuth } from "../auth/useAuth"; import { BusyButton } from "../components/BusyButton"; import { EmptyState } from "../components/EmptyState"; +import { FilterBar, FilterDateField } from "../components/FilterBar"; import { readLastFlockId, rememberFlockId, resolveDefaultFlock } from "../lib/flockDefault"; import { FlockPicker } from "../components/FlockPicker"; import type { PickerSnapshot } from "../components/NamedEntityPicker"; @@ -23,6 +27,11 @@ import { waterSourceLabel, waterUnitLabel } from "../i18n/enums"; const PAGE = 50; const SOURCES = ["Well", "Municipal", "Tank", "Other"]; const UNITS = ["L", "gal"]; +const NOWRAP = { whiteSpace: "nowrap" as const }; +// #831 — replicates the retired `.form-grid .named-picker` rule: without a +// fixed flex-basis the picker's closed (button) and open (input) states have +// different intrinsic widths, which used to shift every sibling field. +const PICKER_SX = { flex: "0 1 15rem", width: "15rem", minWidth: "8rem", maxWidth: "100%" }; function errText(err: unknown): string { // Concurrent-edit conflicts get a human message instead of raw problem text. @@ -349,110 +358,147 @@ export function WaterPage() { }); } - if (error && usage.rows === null) return

{t("title")}

{error}

; - if (usage.rows === null) return

{t("title")}

{tc("loading")}

; + if (error && usage.rows === null) return
{t("title")}

{error}

; + if (usage.rows === null) return
{t("title")}

{tc("loading")}

; return (
-

{t("title")}

+ {t("title")}

{t("intro")}

-
- { - setCaptureFlockSnapshot(snap); - // #512 (P2) — only adopt the engine's committed entity when it - // resolves the page's own requestedId exact GET (the row-owned - // id the loaded list never carried). Every other snapshot — - // including the engine's internal re-emission after a controlled - // sync — carries the engine's PREVIOUS committed entity, which - // can be STALE relative to a concurrent page-side commit - // (startEdit / resetForm). Blindly adopting it overwrites the - // page's fresh row-owned entity with the old default. - if ( - snap.committed && - captureFlockRequestId && - snap.committed.id === captureFlockRequestId - ) { - setCaptureFlock(snap.committed); + + + { + setCaptureFlockSnapshot(snap); + // #512 (P2) — only adopt the engine's committed entity when it + // resolves the page's own requestedId exact GET (the row-owned + // id the loaded list never carried). Every other snapshot — + // including the engine's internal re-emission after a controlled + // sync — carries the engine's PREVIOUS committed entity, which + // can be STALE relative to a concurrent page-side commit + // (startEdit / resetForm). Blindly adopting it overwrites the + // page's fresh row-owned entity with the old default. + if ( + snap.committed && + captureFlockRequestId && + snap.committed.id === captureFlockRequestId + ) { + setCaptureFlock(snap.committed); + setCaptureFlockRequestId(null); + } + }} + onCommit={(f) => { + setCaptureFlock(f); setCaptureFlockRequestId(null); + setCaptureFlockGen((g) => g + 1); + setCapturePickerOpen(false); + }} + onEscape={() => setCapturePickerOpen(false)} + onOutsideClick={() => setCapturePickerOpen(false)} + trigger={ + } - }} - onCommit={(f) => { - setCaptureFlock(f); - setCaptureFlockRequestId(null); - setCaptureFlockGen((g) => g + 1); - setCapturePickerOpen(false); - }} - onEscape={() => setCapturePickerOpen(false)} - onOutsideClick={() => setCapturePickerOpen(false)} - trigger={ - + /> + + setDate(e.target.value)} + /> + setSource(e.target.value)} + > + {SOURCES.map((s) => )} + + setUnit(e.target.value)} + > + {UNITS.map((u) => )} + + setUseMeters(e.target.checked)} /> } /> - - - - {useMeters ? ( <> - - + setMeterStart(e.target.value)} + /> + setMeterEnd(e.target.value)} + /> ) : ( - + setQuantity(e.target.value)} + /> )} - + setNote(e.target.value)} + /> {editingId ? t("saveCorrectionButton") : t("recordWaterButton")} @@ -460,14 +506,14 @@ export function WaterPage() { {editingId && ( )} - + {error &&

{error}

} {message &&

{message}

}

{t("recordsHeading")}

-
-
+ + } /> -
- {/* #653 — the date range gets its own bounded toolbar; the flock - picker above stays a plain form-grid field. */} -
- - -
-
+ + setFrom(e.target.value)} /> + setTo(e.target.value)} /> + {usage.error &&

{usage.error}

} @@ -531,28 +569,38 @@ export function WaterPage() { : ) : ( <> - - - - - - {usage.rows.map((r) => ( - - - - - - - - - - ))} - -
{t("dateHeader")}{t("flockHeader")}{t("amountHeader")}{t("sourceHeader")}{t("metersHeader")}{t("noteHeader")}
{r.flockName ?? t("rowFlockUnavailable")}{fmt.count(r.quantity)} {waterUnitLabel(r.unit)}{waterSourceLabel(r.source)}{r.meterStart !== null ? `${fmt.count(r.meterStart)} → ${r.meterEnd === null ? "" : fmt.count(r.meterEnd)}` : "—"}{r.note ?? ""} - {isAdmin && ( - - )} -
+ + + + + {t("dateHeader")} + {t("flockHeader")} + {t("amountHeader")} + {t("sourceHeader")} + {t("metersHeader")} + {t("noteHeader")} + + + + + {usage.rows.map((r) => ( + + + {r.flockName ?? t("rowFlockUnavailable")} + {fmt.count(r.quantity)} {waterUnitLabel(r.unit)} + {waterSourceLabel(r.source)} + {r.meterStart !== null ? `${fmt.count(r.meterStart)} → ${r.meterEnd === null ? "" : fmt.count(r.meterEnd)}` : "—"} + {r.note ?? ""} + + {isAdmin && ( + + )} + + + ))} + +
+
{usage.canLoadMore && ( {t("writeOffSubmitButton")} - - + + )}
diff --git a/web/src/theme/FarmThemeProvider.tsx b/web/src/theme/FarmThemeProvider.tsx index d0ea4452..abf83427 100644 --- a/web/src/theme/FarmThemeProvider.tsx +++ b/web/src/theme/FarmThemeProvider.tsx @@ -400,7 +400,21 @@ export function createFarmTheme(tokens: TokenValues, mode: ThemeMode): Theme { // forward. MuiTableContainer: { styleOverrides: { - root: { [phone]: { contain: "layout" } }, + root: { + [phone]: { + contain: "layout", + // #150/#831 — the same scroll-shadow affordance `table.data` + // carries for every unconverted ledger (styles.css §2.2, + // verbatim): a wide MUI table on phone gets the "more to + // scroll" edge cue instead of silently clipping its last + // column with no sign anything is cut off. + background: + "linear-gradient(to right, var(--surface) 40%, transparent) 0 0 / 2.25rem 100% no-repeat local," + + "linear-gradient(to left, var(--surface) 40%, transparent) 100% 0 / 2.25rem 100% no-repeat local," + + "linear-gradient(to right, var(--scroll-cue), transparent) 0 0 / 0.85rem 100% no-repeat scroll," + + "linear-gradient(to left, var(--scroll-cue), transparent) 100% 0 / 0.85rem 100% no-repeat scroll", + }, + }, }, }, // #832 — closes the gap the comment above used to carry: this is the From fda1aebbd7b36c1e806c6c5626e2e2716e1d8896 Mon Sep 17 00:00:00 2001 From: mforce Date: Thu, 17 Sep 2026 23:39:37 +0000 Subject: [PATCH 05/14] refactor(web): extract EntryRow/STEPPER_SX out of DailyEntryPage (#831) History's adjust dialog mirrors Daily entry's two-step grading layout exactly (its own comments say so) and needs the identical MUI grid to convert away from .entry-form/.entry-rows without drifting from it. Pure extraction, no behavior change; DailyEntryPage's 82 tests stay green. --- web/src/components/EntryRow.tsx | 108 ++++++++++++++++++++++++++++++ web/src/routes/DailyEntryPage.tsx | 104 +--------------------------- 2 files changed, 110 insertions(+), 102 deletions(-) create mode 100644 web/src/components/EntryRow.tsx diff --git a/web/src/components/EntryRow.tsx b/web/src/components/EntryRow.tsx new file mode 100644 index 00000000..40f20a81 --- /dev/null +++ b/web/src/components/EntryRow.tsx @@ -0,0 +1,108 @@ +import { Box, Typography } from "@mui/material"; +import type { ReactNode } from "react"; +import { remainderDropProps } from "./GradingChip"; + +// #830/#831 — shared between Daily entry's capture form and History's adjust +// dialog: both render the identical two-step egg-counts/grading layout (the +// dialog IS that form, per History's own copy), so the alignment fix below +// has to live in one place or drift the moment either screen's rows change. +// +// #830 (owner's screenshot review of #888) — the stepper row's 48px squares +// (mockup: docs/designs/864-visual-language/daily-entry.html) are an sx +// override on NumberField's OWN classes (`.numfield-step`), never an edit to +// NumberField.tsx or its base CSS block (styles.css, #828's): those stay +// exactly as #828 left them, and this override reaches only rows rendered +// through EntryRow. Every part NumberField renders is a FIXED size at a given +// breakpoint — the two step buttons, and the input's own ch-width — so +// `.numfield`'s overall footprint is constant across every row; that +// constancy is what the grid below leans on to line the minus/plus buttons +// up without touching NumberField itself. +export const STEPPER_SX = { + "& .numfield": { width: "100%", justifyContent: "space-between" }, + "& .numfield-step": { + width: { xs: 48, md: 36 }, height: { xs: 48, md: 36 }, + borderRadius: "var(--r-input)", + }, + "& .numfield input": { + // The row numeral size (FarmThemeProvider's `h2`/title scale, DIRECTION.md + // — 24/28 desktop, 28/32 phone), not a bespoke size: the readout is the + // biggest thing in the row and reads as one more title-weight figure + // beside the others this screen shows (the sellable value, the grading + // count), right-aligned and tabular so a column of them lines up by digit. + fontSize: { xs: "1.75rem", md: "1.5rem" }, + lineHeight: { xs: "2rem", md: "1.75rem" }, + fontWeight: 500, textAlign: "right", + // Wide enough for a 4-digit count (a flock's daily total can run into the + // low thousands) with room to spare — measured against "430" clipping to + // "43" at a tighter "4ch" on desktop (Playwright capture, #830). + width: { xs: "5.5ch", md: "6ch" }, + }, +} as const; + +// #830 (owner's screenshot review of #888) — one ruled GRID row: label (+ +// optional caption, e.g. "deactivated") in a flexible truncating column, +// stepper in a fixed-content column, per the mockup's `.row`. The row used to +// be a flex `justify-content: space-between` pair, which reads as aligned +// only until a label overflows: a flex item shrinks by default, so "Total +// eggs" wrapping onto two lines squeezed the stepper beside it by a different +// amount on every row — the owner's screenshot review of #888 caught this as +// each row's minus button sitting at a different x. A grid's second column +// sizes to its own max-content and does NOT shrink to make room for an +// overflowing sibling; pairing that with `minmax(0, 1fr)` + an ellipsis on +// the label (never wrap) is what makes the fix structural rather than a +// pinned width. `groupLabel` names a grade row as an `aria-label`ed group +// (mirrors Dashboard's TodayRow `role="group"` pattern) — the drop target the +// test suite locates by name instead of a class, and `armed` draws the F134 +// "taking" outline the same rows carried before, now an inline sx state +// instead of a shared `.taking` class. +export function EntryRow({ + htmlFor, label, caption, groupLabel, armed = false, dropProps, children, +}: { + htmlFor: string; + label: string; + caption?: string; + groupLabel?: string; + armed?: boolean; + dropProps?: ReturnType; + children: ReactNode; +}) { + return ( + + + {label} + {caption && ( + + {caption} + + )} + + + {children} + + + ); +} diff --git a/web/src/routes/DailyEntryPage.tsx b/web/src/routes/DailyEntryPage.tsx index b5b60e66..27394d73 100644 --- a/web/src/routes/DailyEntryPage.tsx +++ b/web/src/routes/DailyEntryPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; -import type { FormEvent, ReactNode } from "react"; +import type { FormEvent } from "react"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import { Box, Button, Paper, TextField, Typography, useMediaQuery } from "@mui/material"; @@ -12,6 +12,7 @@ import { ApiError } from "../api/client"; import { useFormat } from "../farm/useFormat"; import { rememberFlockId, resolveDefaultFlock } from "../lib/flockDefault"; import { BusyButton } from "../components/BusyButton"; +import { EntryRow } from "../components/EntryRow"; import { FlockPicker } from "../components/FlockPicker"; import type { PickerSnapshot } from "../components/NamedEntityPicker"; import { Dialog } from "../components/Dialog"; @@ -31,107 +32,6 @@ import { useMe } from "../session/SessionContext"; import i18n from "../i18n"; import { statusLabel } from "../i18n/enums"; -// #830 (owner's screenshot review of #888) — the stepper row's 48px squares -// (mockup: docs/designs/864-visual-language/daily-entry.html) are an sx -// override on NumberField's OWN classes (`.numfield-step`), never an edit to -// NumberField.tsx or its base CSS block (styles.css L1030-1093, #828's): -// those stay exactly as #828 will find them, and this override reaches only -// rows rendered by THIS page. Every part NumberField renders is a FIXED size -// at a given breakpoint — the two step buttons, and the input's own ch-width -// — so `.numfield`'s overall footprint is constant across every row; that -// constancy is what EntryRow's grid below leans on to line the minus/plus -// buttons up without touching NumberField itself. -const STEPPER_SX = { - "& .numfield": { width: "100%", justifyContent: "space-between" }, - "& .numfield-step": { - width: { xs: 48, md: 36 }, height: { xs: 48, md: 36 }, - borderRadius: "var(--r-input)", - }, - "& .numfield input": { - // The row numeral size (FarmThemeProvider's `h2`/title scale, DIRECTION.md - // — 24/28 desktop, 28/32 phone), not a bespoke size: the readout is the - // biggest thing in the row and reads as one more title-weight figure - // beside the others this screen shows (the sellable value, the grading - // count), right-aligned and tabular so a column of them lines up by digit. - fontSize: { xs: "1.75rem", md: "1.5rem" }, - lineHeight: { xs: "2rem", md: "1.75rem" }, - fontWeight: 500, textAlign: "right", - // Wide enough for a 4-digit count (a flock's daily total can run into the - // low thousands) with room to spare — measured against "430" clipping to - // "43" at a tighter "4ch" on desktop (Playwright capture, #830). - width: { xs: "5.5ch", md: "6ch" }, - }, -} as const; - -// #830 (owner's screenshot review of #888) — one ruled GRID row: label (+ -// optional caption, e.g. "deactivated") in a flexible truncating column, -// stepper in a fixed-content column, per the mockup's `.row`. The row used to -// be a flex `justify-content: space-between` pair, which reads as aligned -// only until a label overflows: a flex item shrinks by default, so "Total -// eggs" wrapping onto two lines squeezed the stepper beside it by a different -// amount on every row — the owner's screenshot review of #888 caught this as -// each row's minus button sitting at a different x. A grid's second column -// sizes to its own max-content and does NOT shrink to make room for an -// overflowing sibling; pairing that with `minmax(0, 1fr)` + an ellipsis on -// the label (never wrap) is what makes the fix structural rather than a -// pinned width. `groupLabel` names a grade row as an `aria-label`ed group -// (mirrors Dashboard's TodayRow `role="group"` pattern) — the drop target the -// test suite locates by name instead of a class, and `armed` draws the F134 -// "taking" outline the same rows carried before, now an inline sx state -// instead of a shared `.taking` class. -function EntryRow({ - htmlFor, label, caption, groupLabel, armed = false, dropProps, children, -}: { - htmlFor: string; - label: string; - caption?: string; - groupLabel?: string; - armed?: boolean; - dropProps?: ReturnType; - children: ReactNode; -}) { - return ( - - - {label} - {caption && ( - - {caption} - - )} - - - {children} - - - ); -} - - // Capture targets active flocks plus depleted ones — a depleted flock still // accepts backfilled entries up to its depletion date (the API gates exact // dates), matching the Flocks screen's promise and the feed-usage picker. From 7a05e9be01a063a46f564679dcfba97e7e438a23 Mon Sep 17 00:00:00 2001 From: mforce Date: Thu, 17 Sep 2026 23:44:34 +0000 Subject: [PATCH 06/14] feat(web): convert History to MUI (#831) Pairs 7/9/10/11 across the filter row, the entries table (Voided rows keep their muted tone via sx inheritance) and the adjust dialog, which now renders through the shared EntryRow grid DailyEntryPage's capture form already uses (keeping History's own visible .step-n pill, per #830's comment). 2 of 75 tests selected by retired .entry-readout/.entry-row classes rewritten to the same role-scoped pattern DailyEntryPage.test.tsx already established. --- web/src/routes/HistoryPage.test.tsx | 24 +- web/src/routes/HistoryPage.tsx | 353 ++++++++++++++-------------- 2 files changed, 197 insertions(+), 180 deletions(-) diff --git a/web/src/routes/HistoryPage.test.tsx b/web/src/routes/HistoryPage.test.tsx index 73e7a88a..20350315 100644 --- a/web/src/routes/HistoryPage.test.tsx +++ b/web/src/routes/HistoryPage.test.tsx @@ -332,12 +332,22 @@ describe("HistoryPage adjust — reconciliation guard", () => { // shows and what it allows would fail here. describe("HistoryPage adjust — mirrored daily-entry layout", () => { const dialog = () => screen.getByRole("dialog"); - // Class-selected, exactly as DailyEntryPage.test.tsx selects the same two - // readouts: neither has an unambiguous role here either — every BusyButton - // renders its own sr-only role="status" for the "Working…" announcement, so - // the chip's live region is one of several. + // `.entry-chip` is GradingChip's own class (component untouched by #831, + // shared with DailyEntryPage) — still class-selected for the same reason + // DailyEntryPage.test.tsx gives: every BusyButton renders its own sr-only + // role="status" for the "Working…" announcement, so the chip's live region + // is one of several and a role alone would not disambiguate it. const chip = () => dialog().querySelector(".entry-chip") as HTMLElement; - const sellableReadout = () => dialog().querySelector(".entry-readout") as HTMLElement; + // #831 dropped `.entry-readout` in favor of the same role-scoped lookup + // DailyEntryPage.test.tsx uses for its converted counterpart: `role="alert"` + // once losses exceed the total, `role="status"` in the normal case, both + // scoped to the Egg counts section so they cannot match the chip's status. + const countsSection = () => + within(dialog()).getByRole("heading", { name: /Egg counts/ }).closest("section") as HTMLElement; + const sellableReadout = () => { + const section = countsSection(); + return within(section).queryByRole("alert") ?? within(section).getByRole("status"); + }; it("shows both steps and the sellable figure the grading pane has to hit", async () => { mockListDailyEntries.mockResolvedValue([SUBMITTED]); @@ -431,7 +441,9 @@ describe("HistoryPage adjust — mirrored daily-entry layout", () => { await openAdjustPanel(); fireEvent.click(within(dialog()).getByRole("button", { name: /remaining 30/ })); - const gradeBRow = screen.getByRole("spinbutton", { name: "Grade B" }).closest(".entry-row")!; + // #831: the row is now a named `role="group"` (the F134 drop target), + // exactly as DailyEntryPage.test.tsx selects its own converted rows. + const gradeBRow = within(dialog()).getByRole("group", { name: "Grade B row" }); // A foreign drag (plain text — what dropping a link or a selection looks // like) must leave the line untouched. diff --git a/web/src/routes/HistoryPage.tsx b/web/src/routes/HistoryPage.tsx index b43e03b8..2f966501 100644 --- a/web/src/routes/HistoryPage.tsx +++ b/web/src/routes/HistoryPage.tsx @@ -3,6 +3,9 @@ import type { FormEvent } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { FilterX, Inbox } from "lucide-react"; +import { + Box, DialogActions, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, +} from "@mui/material"; import { adjustDailyEntry, getDailyEntry, listDailyEntries, listEggGrades, listEggUnitConversions, listFlocks, voidDailyEntry, @@ -15,6 +18,8 @@ import { useAuth } from "../auth/useAuth"; import { BusyButton } from "../components/BusyButton"; import { Dialog } from "../components/Dialog"; import { EmptyState } from "../components/EmptyState"; +import { EntryRow } from "../components/EntryRow"; +import { FilterBar, FilterDateField } from "../components/FilterBar"; import { FlockPicker } from "../components/FlockPicker"; import { DialogError } from "../components/DialogError"; import { GradingChip, TakeRemainderButton, remainderDropProps } from "../components/GradingChip"; @@ -33,6 +38,11 @@ import { useMe } from "../session/SessionContext"; import i18n from "../i18n"; const PAGE = 50; +const NOWRAP = { whiteSpace: "nowrap" as const }; +// #831 — replicates the retired `.form-grid .named-picker` rule: without a +// fixed flex-basis the picker's closed (button) and open (input) states have +// different intrinsic widths, which used to shift every sibling field. +const PICKER_SX = { flex: "0 1 15rem", width: "15rem", minWidth: "8rem", maxWidth: "100%" }; // The scope that owns a dialog (#703). `run` routes a failure by this and gates // a success by it; `void:` from the row button reports to the page and is @@ -526,19 +536,19 @@ export function HistoryPage() { // fatal case: without those, every row renders unresolvable ids. `entries` // is the hook's handle, so the emptiness test is on its rows. if (errors.page && entries.rows === null) - return

{t("loadingTitle")}

{errors.page}

; + return
{t("loadingTitle")}

{errors.page}

; return (
-

{t("title")}

+ {t("title")} {isAdmin && (

{t("intro")}

)} -
-
+ + {/* #512 (T038) — the read-only filter became an optional eligibility=all FlockPicker: the filter keeps its exact id ownership (the list fetches by `flockFilter`), and a row-owned @@ -571,18 +581,10 @@ export function HistoryPage() { } /> -
- {/* #653 — the date range gets its own bounded toolbar; the flock - picker above stays a plain form-grid field. */} -
- - -
-
+ + setFrom(e.target.value)} /> + setTo(e.target.value)} /> + + from recording them. #250's steppers throughout; EntryRow is + the same grid DailyEntryPage's own capture form renders + through (#831 extracted it precisely so the two never drift). */} + {/* #444 — same caption as the capture screen; the dialog IS that form, so the taps count the same way and say so the same way. */} {stepSize > 1 && ( @@ -624,107 +628,102 @@ export function HistoryPage() { {te("stepperUnitCaption", { unit: stepperUnit.unitCode, count: stepSize })}

)} -
-
+ + {/* The word boundaries live in the h3's own text nodes, not at the edges of the sr-only span: accessible-name computation - trims each nested element's contribution. */} -

{te("stepLabel", { n: 1 })} {te("stepOfTotal")} {te("eggCountsHeading")}

-
-
- {/* Sibling label, not wrapping — a
- - {lossesExceedTotal ? ( -

- {te("countsExceedTotalMessage", { losses: grading.losses, total })} -

- ) : ( - /* Shown as a value, not buried in a sentence — it is the - target the grading pane has to hit. */ -

- {te("sellableLabel")}
{te("sellableFormula", { total, cracked, dirty, discarded })}
- {sellable} -

- )} -
-
- -
-

{te("stepLabel", { n: 2 })} {te("stepOfTotal")} {te("gradingHeading")}

-
-
- {panelGrades(adjusting).map((g) => ( -
assignRest(g.id))}> - - {/* #443 — no max=: same as the capture screen, the old - ceiling refused to let a grade run ahead of the - total. setLine raises the total to fit instead. */} - - {armed && ( - assignRest(g.id)} /> - )} -
- ))} -
- - {/* The same chip the capture screen uses — here it is also - exactly what the Save button is gated on (#394). */} - -
-
-
- - + trims each nested element's contribution. This dialog keeps + the visible `.step-n` pill DailyEntryPage's own heading made + sr-only (#830) — its comment names this screen as the + reason that CSS rule stays declared. */} + + {te("stepLabel", { n: 1 })} {te("stepOfTotal")} {te("eggCountsHeading")} + + + + + + + + + + + + + + {/* NO step — deaths are birds, not eggs; see the capture + screen's identical comment (codex P1 review of #451). */} + + + + + {lossesExceedTotal ? ( + + {te("countsExceedTotalMessage", { losses: grading.losses, total })} + + ) : ( + /* Shown as a value, not buried in a sentence — it is the + target the grading pane has to hit. */ + + {te("sellableLabel")}
{te("sellableFormula", { total, cracked, dirty, discarded })}
+ {sellable} +
+ )} + + + + + {te("stepLabel", { n: 2 })} {te("stepOfTotal")} {te("gradingHeading")} + + {panelGrades(adjusting).map((g) => ( + assignRest(g.id))} + > + {/* #443 — no max=: same as the capture screen, the old + ceiling refused to let a grade run ahead of the + total. setLine raises the total to fit instead. */} + + {armed && ( + assignRest(g.id)} /> + )} + + ))} + + {/* The same chip the capture screen uses — here it is also + exactly what the Save button is gated on (#394). */} + + + + + setReason(e.target.value)} + /> {/* The 409 rebind reports here, beside the form it asks you to re-apply. */} -
+ {/* #394: an adjustment has no draft state — Save stays disabled until grading reconciles exactly, the same rule Daily Entry's submit uses. */} {t("saveAdjustmentButton")} -
- + +
)}
@@ -758,71 +757,77 @@ export function HistoryPage() { : ) : ( <> - - - - - - {/* #396 — Losses shows the cracked/dirty/discarded COUNTS - whatever became of them; this shows how many of those - actually became stock, per the entry's own snapshot. */} - - - - - - - - {entries.rows.map((e) => ( - - - - - - - - - - - - - ))} - -
{t("dateHeader")}{t("flockHeader")}{t("statusHeader")}{t("totalHeader")}{t("lossesHeader")}{t("conditionHeader")}{t("mortalityHeader")}{t("gradedHeader")}{tc("recordHistoryHeader")}
{rowFlockName(e)}{statusCell(e)}{fmt.count(e.totalEggs)}{fmt.count(e.crackedEggs)}/{fmt.count(e.dirtyEggs)}/{fmt.count(e.discardedEggs)}{conditionStock(e)}{fmt.count(e.mortalityCount)} - {e.grades.length === 0 - ? "—" - : e.grades.map((g) => `${gradeName(g.eggGradeId)} ${fmt.count(g.quantity)}`).join(", ")} - - {/* #493 — full audit trail for this record, distinct from - the created/last-changed summary in ProvenanceCell. - Admin-gated: /api/v1/audit is AdminOnly, and this - screen is open to workers too (codex review of - #516). */} - {isAdmin && ( - - {tc("recordHistory.viewHistoryLink")} - - )} - {/* Drafts are edited on the Daily entry screen (#85) — - open to workers too; adjust/void stay admin-only. */} - {e.status === "Draft" && flockEditable(e) && ( - - {t("editButton")} - - )} - {isAdmin && correctable(e) && ( - <> - {/* Opens the dialog — the mutation's own trigger (and - its spinner) is the dialog's Save adjustment. */} - - void onVoid(e)}>{t("voidButton")} - - )} -
+ + + + + {t("dateHeader")} + {t("flockHeader")} + {t("statusHeader")} + {t("totalHeader")} + {t("lossesHeader")} + {/* #396 — Losses shows the cracked/dirty/discarded COUNTS + whatever became of them; this shows how many of those + actually became stock, per the entry's own snapshot. */} + {t("conditionHeader")} + {t("mortalityHeader")} + {t("gradedHeader")} + {tc("recordHistoryHeader")} + + + + + {entries.rows.map((e) => ( + + + {rowFlockName(e)} + {statusCell(e)} + {fmt.count(e.totalEggs)} + {fmt.count(e.crackedEggs)}/{fmt.count(e.dirtyEggs)}/{fmt.count(e.discardedEggs)} + {conditionStock(e)} + {fmt.count(e.mortalityCount)} + + {e.grades.length === 0 + ? "—" + : e.grades.map((g) => `${gradeName(g.eggGradeId)} ${fmt.count(g.quantity)}`).join(", ")} + + + + {/* #493 — full audit trail for this record, distinct from + the created/last-changed summary in ProvenanceCell. + Admin-gated: /api/v1/audit is AdminOnly, and this + screen is open to workers too (codex review of + #516). */} + {isAdmin && ( + + {tc("recordHistory.viewHistoryLink")} + + )} + {/* Drafts are edited on the Daily entry screen (#85) — + open to workers too; adjust/void stay admin-only. */} + {e.status === "Draft" && flockEditable(e) && ( + + {t("editButton")} + + )} + {isAdmin && correctable(e) && ( + <> + {/* Opens the dialog — the mutation's own trigger (and + its spinner) is the dialog's Save adjustment. */} + + void onVoid(e)}>{t("voidButton")} + + )} + + + ))} + +
+
{entries.canLoadMore && ( @@ -587,7 +593,7 @@ export function ExpensesPage() { {tc("clearFiltersButton")} )} - + {/* The total belongs to the rows below it: it lands and clears with them, so it can never describe a period they do not (#469). It is @@ -607,107 +613,139 @@ export function ExpensesPage() { )} {showCategories && ( -
-

{t("categoriesHeading")}

-
- -
- - -
- {/* Disabled during any flight — kept as shipped (#242 review); - since #703 the spinner reads the fixed "add-category" scope, - so the original re-pointing hazard is gone, and the field - stays inert during a flight like every other trigger here. */} - - -
- - {t("addCategoryButton")} -
- -
- -
    - {categories.map((c) => ( -
  • - {c.name}{c.active ? "" : t("deactivatedSuffix")}{" "} - onToggleCategory(c)}> - {c.active ? t("deactivateButton") : t("reactivateButton")} - -
  • - ))} - {categories.length === 0 &&
  • {t("noCategoriesMessage")}
  • } -
-
+ // Pair 15 (#822 D2): the drill-down is a ruled region, not a card — + // a Box between two Dividers, an h3, no fill, no radius. Same shape + // #897 gave Flocks' ledger panel; Expenses was the last remaining + // `.order-panel` consumer besides Sales/Inventory (#831 converts all + // three in this slice). + + + + {t("categoriesHeading")} + + + + + + + {/* Disabled during any flight — kept as shipped (#242 review); + since #703 the spinner reads the fixed "add-category" scope, + so the original re-pointing hazard is gone, and the field + stays inert during a flight like every other trigger here. */} + setNewCategoryName(e.target.value)} + /> + + + + {t("addCategoryButton")} + + + + +
    + {categories.map((c) => ( +
  • + {c.name}{c.active ? "" : t("deactivatedSuffix")}{" "} + onToggleCategory(c)}> + {c.active ? t("deactivateButton") : t("reactivateButton")} + +
  • + ))} + {categories.length === 0 &&
  • {t("noCategoriesMessage")}
  • } +
+
+ +
)}

{t("recordExpenseHeading")}

-
- - - - - { - setAddFlock(f); - setAddFlockPickerOpen(false); - }} - onClear={() => setAddFlock(null)} - onEscape={() => setAddFlockPickerOpen(false)} - onOutsideClick={() => setAddFlockPickerOpen(false)} - trigger={ - - } + + setDate(e.target.value)} + /> + setCategoryId(e.target.value)} + > + + {activeCategories.map((c) => )} + + setDescription(e.target.value)} + /> + setAmount(e.target.value)} + /> + + { + setAddFlock(f); + setAddFlockPickerOpen(false); + }} + onClear={() => setAddFlock(null)} + onEscape={() => setAddFlockPickerOpen(false)} + onOutsideClick={() => setAddFlockPickerOpen(false)} + trigger={ + + } + /> + + setNote(e.target.value)} /> - -
- {/* No known denomination means no recording: converting the typed - amount would have to guess the scale (#469 codex review). - #512 (T028): the picker's canSubmit gates the write too — an - exploring/uninitialized picker must not submit a stale flock. */} - - {t("recordExpenseButton")} - -
- + {/* No known denomination means no recording: converting the typed + amount would have to guess the scale (#469 codex review). + #512 (T028): the picker's canSubmit gates the write too — an + exploring/uninitialized picker must not submit a stale flock. */} + + {t("recordExpenseButton")} + +
{activeCategories.length === 0 && (

{t("addCategoryFirstMessage")}

)} @@ -729,82 +767,100 @@ export function ExpensesPage() { focusKey={editing} > {editing && ( -
- - - - + + setEditDate(e.target.value)} + /> + setEditCategory(e.target.value)} + > + {editCategories.map((c) => ( + + ))} + + setEditDescription(e.target.value)} + /> + setEditAmount(e.target.value)} + /> {/* #512 (T038) — the correction's flock is a ROW-OWNED identity: requestedId resolves it exactly (archived / outside the discovery window included), a failed exact read enters the explicit unavailable state with a Retry, and the picker's clear restores the account-wide (blank) choice. */} - { - setEditFlockSnapshot(snap); - if (snap.committed) { - setEditFlockEntity(snap.committed); + + { + setEditFlockSnapshot(snap); + if (snap.committed) { + setEditFlockEntity(snap.committed); + setEditFlockId(null); + } + }} + onCommit={(f) => { + setEditFlockEntity(f); + setEditFlockId(null); + setEditFlockGen((g) => g + 1); + }} + onClear={() => { + setEditFlockEntity(null); setEditFlockId(null); + setEditFlockGen((g) => g + 1); + }} + onEscape={() => {}} + onOutsideClick={() => {}} + trigger={ + {editFlockEntity + ? editFlockEntity.name + : editFlockId !== null && editFlockSnapshot.selectionPhase === "unavailable" + ? t("flockUnavailable") + : t("noneOption")} } - }} - onCommit={(f) => { - setEditFlockEntity(f); - setEditFlockId(null); - setEditFlockGen((g) => g + 1); - }} - onClear={() => { - setEditFlockEntity(null); - setEditFlockId(null); - setEditFlockGen((g) => g + 1); - }} - onEscape={() => {}} - onOutsideClick={() => {}} - trigger={ - {editFlockEntity - ? editFlockEntity.name - : editFlockId !== null && editFlockSnapshot.selectionPhase === "unavailable" - ? t("flockUnavailable") - : t("noneOption")} - } + /> + + setEditNote(e.target.value)} /> - {/* The 409 rebind reports through here, so the conflict banner stays next to the form it is telling you to re-apply. */} -
+ {/* #512 (T028): canSubmit also gates the visible control; the @@ -813,8 +869,8 @@ export function ExpensesPage() { disabled={busy || !editFlockSnapshot.canSubmit}> {t("saveCorrectionButton")} -
- + +
)} @@ -840,41 +896,48 @@ export function ExpensesPage() { : { label: t("showAllTimeButton"), onClick: showAllTime }} /> : ) : ( - - - - - - - - - - {expenses.rows.map((x) => ( - - - - - - - - - - - ))} - -
{t("dateHeader")}{t("categoryHeader")}{t("descriptionHeader")}{t("amountHeader")}{t("flockHeader")}{t("noteHeader")}{tc("recordHistoryHeader")}
{categoryName(x.expenseCategoryId)}{x.description}{fmt.money(x.amountMinorUnits, x.currencyCode, x.currencyMinorUnit)}{rowFlockName(x)}{x.note ?? "—"} - {/* #493 — full audit trail for this record, distinct from - the created/last-changed summary in ProvenanceCell. */} - - {tc("recordHistory.viewHistoryLink")} - - {/* Opens the correction dialog — non-mutating, so the - spinner belongs to the dialog's Save, not here (#242). */} - -
+ + + + + {t("dateHeader")} + {t("categoryHeader")} + {t("descriptionHeader")} + {t("amountHeader")} + {t("flockHeader")} + {t("noteHeader")} + {tc("recordHistoryHeader")} + + + + + {expenses.rows.map((x) => ( + + + {categoryName(x.expenseCategoryId)} + {x.description} + {fmt.money(x.amountMinorUnits, x.currencyCode, x.currencyMinorUnit)} + {rowFlockName(x)} + {x.note ?? "—"} + + + {/* #493 — full audit trail for this record, distinct from + the created/last-changed summary in ProvenanceCell. */} + + {tc("recordHistory.viewHistoryLink")} + + {/* Opens the correction dialog — non-mutating, so the + spinner belongs to the dialog's Save, not here (#242). */} + + + + ))} + +
+
)} {expenses.canLoadMore && ( )} - +

{t("intro")}

{/* Gated like the inline form was: a role change mid-edit closes it. */} -
- - - - + + setName(e.target.value)} + /> + setCategory(e.target.value)} + > + {CATEGORIES.map((c) => )} + + setUnit(e.target.value)} + /> + setDefaultCost(e.target.value)} + /> -
+ {t("addItemButton")} -
- + +
{/* noValidate: the row's save used to be a plain button, so the browser never enforced min/step — toMinorUnits' own message did. */} -
- - - + + setEditName(e.target.value)} + /> + setEditUnit(e.target.value)} + /> + setEditCost(e.target.value)} + /> -
+ {tc("save")} -
- + +
{/* Unconditional since #479 — a dialog's failure lives in its own slot @@ -548,202 +571,257 @@ export function InventoryPage() { {message &&

{message}

} {active && ( -
-

{t("itemPanelHeading", { name: active.name, quantity: active.quantityOnHand, unit: active.unit })}

- - {/* One row of actions; each opens its own dialog so the ledger below - stays put instead of being pushed down by three stacked forms. */} -
- - {canFeed && ( - // #446 — feed usage lives on its own page now; the deep link - // keeps the one thing the old dialog had over it: the item you - // are looking at arrives preselected. - - {t("recordUsageLink")} - - )} - {isAdmin && lots.length > 0 && ( - + {canFeed && ( + // #446 — feed usage lives on its own page now; the deep link + // keeps the one thing the old dialog had over it: the item you + // are looking at arrives preselected. + + {t("recordUsageLink")} + + )} + {isAdmin && lots.length > 0 && ( + + )} + + + {/* Why an action is unavailable, in the place the button would be. */} + {!canFeed && ( +

+ {t("notFeedableMessage", { category: inventoryCategoryLabel(active.category) })} +

)} -
- - {/* Why an action is unavailable, in the place the button would be. */} - {!canFeed && ( -

- {t("notFeedableMessage", { category: inventoryCategoryLabel(active.category) })} -

- )} - {!isAdmin ? ( -

{t("correctionsNeedAdminMessage")}

- ) : lots.length === 0 ? ( -

{t("noLotsMessage")}

- ) : null} - - -
- - - - - - - -
- - - {t("recordPurchaseSubmitButton")} - -
- -
- - -
- {/* Disabled during any flight — kept as shipped (#242); since - #703 the spinner reads the fixed "adjust" scope, so the - original re-pointing hazard is gone, and the field stays - inert during a flight like every other trigger here. */} - - - - - -
- - {/* The pending scope is the dialog's; the composite key scope is - the idempotency key's alone since #703. */} - - {t("recordCorrectionButton")} - -
- -
- - {/* #511 round 5 — the error renders BESIDE the rows, never instead of - them. usePagedList keeps `rows` and `hasMore` when an EXTENSION - fails (only a failed REPLACEMENT empties them), so a branch that - swapped the table for the message threw away everything the user - had paged to over one transient load-more failure. That is AC3: - a failed extension keeps already-loaded rows and permits retry. - CustomersPage had this right from the start — it is the shape - copied here. A failed REPLACEMENT still shows the message alone, - because the hook has emptied `rows` by then and the empty branch - below does not fire on `error`. */} - {ledger.error &&

{ledger.error}

} - {ledger.rows === null || ledger.reloading ? ( -

{tc("loading")}

- ) : ledger.rows.length === 0 && !ledger.error ? ( -

{t("noMovementsMessage")}

- ) : ( - - - - - - {ledger.rows.map((m) => ( - - - - - - - ))} - -
{t("ledgerDateHeader")}{t("ledgerTypeHeader")}{t("ledgerQuantityHeader")}{t("ledgerNoteHeader")}
{inventoryMovementLabel(m.type)}{m.quantityDelta > 0 ? `+${fmt.count(m.quantityDelta)}` : fmt.count(m.quantityDelta)} {m.unit}{m.note ?? ""}
- )} - {ledger.canLoadMore && ( - - )} -
- -
-
+ slotProps={{ htmlInput: { step: 0.001, required: true } }} + onChange={(e) => setAdjustQty(e.target.value)} + /> + setAdjustReason(e.target.value)} + /> + + + + {/* The pending scope is the dialog's; the composite key scope is + the idempotency key's alone since #703. */} + + {t("recordCorrectionButton")} + + + + + + {/* #511 round 5 — the error renders BESIDE the rows, never instead of + them. usePagedList keeps `rows` and `hasMore` when an EXTENSION + fails (only a failed REPLACEMENT empties them), so a branch that + swapped the table for the message threw away everything the user + had paged to over one transient load-more failure. That is AC3: + a failed extension keeps already-loaded rows and permits retry. + CustomersPage had this right from the start — it is the shape + copied here. A failed REPLACEMENT still shows the message alone, + because the hook has emptied `rows` by then and the empty branch + below does not fire on `error`. */} + {ledger.error &&

{ledger.error}

} + {ledger.rows === null || ledger.reloading ? ( +

{tc("loading")}

+ ) : ledger.rows.length === 0 && !ledger.error ? ( +

{t("noMovementsMessage")}

+ ) : ( + + + + + {t("ledgerDateHeader")} + {t("ledgerTypeHeader")} + {t("ledgerQuantityHeader")} + {t("ledgerNoteHeader")} + + + + {ledger.rows.map((m) => ( + + + {inventoryMovementLabel(m.type)} + {m.quantityDelta > 0 ? `+${fmt.count(m.quantityDelta)}` : fmt.count(m.quantityDelta)} {m.unit} + {m.note ?? ""} + + ))} + +
+
+ )} + {ledger.canLoadMore && ( + + )} + + + + + + )} - - - - - - {items.map((i) => ( - - - - - - - - - ))} - -
{t("nameHeader")}{t("categoryHeader")}{t("onHandHeader")}{t("defaultCostHeader")}{t("statusHeader")}
{i.name}{inventoryCategoryLabel(i.category)}{fmt.count(i.quantityOnHand)} {i.unit}{costText(i)} - - {isAdmin && ( - <> - {/* Opens the edit dialog — non-mutating, so the spinner - belongs to the dialog's Save, not here (#242). */} - - {i.active ? ( - void run(`deactivate:${i.id}`, () => commit(`deactivate:${i.id}`, (key) => deactivateInventoryItem(i.id, key)))}> - {t("deactivateButton")} - - ) : ( - void run(`activate:${i.id}`, () => commit(`activate:${i.id}`, (key) => activateInventoryItem(i.id, key)))}> - {t("activateButton")} - - )} - - )} -
+ + + + + {t("nameHeader")} + {t("categoryHeader")} + {t("onHandHeader")} + {t("defaultCostHeader")} + {t("statusHeader")} + + + + + {items.map((i) => ( + + {i.name} + {inventoryCategoryLabel(i.category)} + {fmt.count(i.quantityOnHand)} {i.unit} + {costText(i)} + + + + {isAdmin && ( + <> + {/* Opens the edit dialog — non-mutating, so the spinner + belongs to the dialog's Save, not here (#242). */} + + {i.active ? ( + void run(`deactivate:${i.id}`, () => commit(`deactivate:${i.id}`, (key) => deactivateInventoryItem(i.id, key)))}> + {t("deactivateButton")} + + ) : ( + void run(`activate:${i.id}`, () => commit(`activate:${i.id}`, (key) => activateInventoryItem(i.id, key)))}> + {t("activateButton")} + + )} + + )} + + + ))} + +
+
); } From ad053de69f947c95c8864dadf28ad661eafdc192 Mon Sep 17 00:00:00 2001 From: mforce Date: Thu, 17 Sep 2026 23:54:17 +0000 Subject: [PATCH 09/14] fix(web): drop the 15rem width cap from Expenses' dialog FlockPicker (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PICKER_SX (flex 0 1 15rem) belongs on a picker inside a horizontal filter/capture row, replicating the retired .form-grid .named-picker rule. Inside a vertical dialog Stack every child already stretches full width by default, which is what the retired .dialog .form-grid .named-picker override gave it — the edit dialog's FlockPicker had picked up the row cap by copy-paste and would have rendered too narrow. No behavior asserted by a test (jsdom computes no layout); caught by re-reading the CSS this markup replaces. --- web/src/routes/ExpensesPage.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/src/routes/ExpensesPage.tsx b/web/src/routes/ExpensesPage.tsx index dca5b39a..579fa61f 100644 --- a/web/src/routes/ExpensesPage.tsx +++ b/web/src/routes/ExpensesPage.tsx @@ -806,8 +806,13 @@ export function ExpensesPage() { requestedId resolves it exactly (archived / outside the discovery window included), a failed exact read enters the explicit unavailable state with a Retry, and the picker's - clear restores the account-wide (blank) choice. */} - + clear restores the account-wide (blank) choice. + No PICKER_SX here: inside a vertical dialog Stack every child + stretches full width by default (flex align-items: stretch), + which is the same full-width behaviour the retired + `.dialog .form-grid .named-picker` override gave it — the + 15rem row cap is for a horizontal filter/capture row only. */} + Date: Fri, 18 Sep 2026 00:12:26 +0000 Subject: [PATCH 10/14] fix(web): keep .actions on Inventory's item-panel close button (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .actions carries a real phone-stacking rule (styles.css, D3.4's default), not a bare layout hook — dropping it for a plain Box in the prior commit was an unnecessary deviation with no CSS deletion behind it. Reverts to the retained class. --- web/src/routes/InventoryPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/routes/InventoryPage.tsx b/web/src/routes/InventoryPage.tsx index 575df2d8..5d03f959 100644 --- a/web/src/routes/InventoryPage.tsx +++ b/web/src/routes/InventoryPage.tsx @@ -767,9 +767,11 @@ export function InventoryPage() { {t("loadMoreButton")} )} - + {/* `.actions` stays: it carries a real phone-stacking rule + (styles.css, D3.4's default), not a bare layout hook. */} +
- +
From 83eb5c4a82d1cdf65a7b94816ccd73b40ebffd84 Mon Sep 17 00:00:00 2001 From: mforce Date: Fri, 18 Sep 2026 00:31:35 +0000 Subject: [PATCH 11/14] fix(e2e): fix phone.spec.ts's stale table.data locators for Stock/History (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full quick Playwright suite caught a real regression: "no walked screen overflows the viewport horizontally" located /stock and /history by `content: "table.data"`, and both now render MUI's with no `data` class, same shape #832 already hit for /customers and /flocks (whose fix — switch to `role=table` — this mirrors). Also updates mutation-check.sh's EXPECT_MSG_FOR and mutants.ts's phone-table-overflow-unclipped mutant, which asserted /history still overflows under that mutant's table.data-scoped CSS override — it no longer does, narrowing the mutant to /sales alone (the follow-up PR converts Sales too and needs to retire or retarget this mutant per #824's rule). --- tools/simulation/ui/mutation-check.sh | 34 ++++++++++++------------- tools/simulation/ui/specs/phone.spec.ts | 6 +++-- tools/simulation/ui/src/mutants.ts | 28 ++++++++++++-------- 3 files changed, 38 insertions(+), 30 deletions(-) diff --git a/tools/simulation/ui/mutation-check.sh b/tools/simulation/ui/mutation-check.sh index 200bac3d..9ea0a8c8 100755 --- a/tools/simulation/ui/mutation-check.sh +++ b/tools/simulation/ui/mutation-check.sh @@ -261,21 +261,22 @@ declare -A FALSE_KILLS=( # does — this mutant still dies inside sign-in, still proves nothing about the # nav gate, and is still counted as a false kill rather than as coverage. # -# The three phone entries were observed the same way. Two of them carry a custom -# message; `phone-table-overflow-unclipped` declares TWO lines, one per route it -# still breaks, because the softness of that walk is itself the claim — a hard -# assertion would stop at /sales and report half the damage, so requiring both -# is what keeps `expect.soft` there honest. /daily-entry and /stock are -# deliberately absent: neither renders a wide data table, and both stayed at -# exactly 390 under the mutant. /customers and /flocks were also on this list -# until #832: the mutant's CSS targets `table.data` specifically, and #832 -# moved both routes onto MUI's `TableContainer`, which the mutant's rule does -# not reach — observed directly (`CLUCKWORK_E2E_MUTANT=phone-table-overflow-unclipped` -# against a #832 build): only /sales and /history still overflow. This is a -# real narrowing of what the mutant proves, not a typo; if a later slice moves -# /sales or /history onto MUI too, this mutant stops proving anything at all -# and needs a new CSS target (MUI's `TableContainer`, not `table.data`) or -# retirement, matching #824's "retire only with a named successor" rule. +# The three phone entries were observed the same way. One of them carries a +# custom message; `phone-table-overflow-unclipped` declares ONE line now +# (narrowed from two in #832 to one in #831 — see below), because the +# softness of that walk is itself the claim — a hard assertion would stop at +# the first offender and report only part of the damage, so requiring the +# line that remains is what keeps `expect.soft` there honest. /daily-entry +# and /stock are deliberately absent: neither renders a wide data table (Stock +# moved to MUI's `Table` in #831, same shape as Customers/Flocks below), and +# both stayed at exactly 390 under the mutant. /customers and /flocks were +# also on this list until #832, and /history until #831: the mutant's CSS +# targets `table.data` specifically, and each of those slices moved its route +# onto MUI's `TableContainer`, which the mutant's rule does not reach. This is +# a real narrowing of what the mutant proves, not a typo — only /sales still +# overflows under it now, and #831's own follow-up (Sales) needs a new CSS +# target (MUI's `TableContainer`, not `table.data`) or retirement, matching +# #824's "retire only with a named successor" rule, once it lands too. # # The two phone action mutants split the walk's rule between them, and each # declares only what it can actually redden. #823 stacks every action row below @@ -332,8 +333,7 @@ taller than it is wide, so its pill clamps into an ellipse" [phone-entry-foot-stacked]="in the daily-entry save bar spans" [phone-dialog-footer-stacked]="dialog footer's row is not laid out as a row (computed flex-direction: column) dialog footer's buttons share no common vertical band" - [phone-table-overflow-unclipped]="/sales scrolls sideways at phone width -/history scrolls sideways at phone width" + [phone-table-overflow-unclipped]="/sales scrolls sideways at phone width" ) MUTANTS=("$@") diff --git a/tools/simulation/ui/specs/phone.spec.ts b/tools/simulation/ui/specs/phone.spec.ts index 9f018b51..5a329540 100644 --- a/tools/simulation/ui/specs/phone.spec.ts +++ b/tools/simulation/ui/specs/phone.spec.ts @@ -557,8 +557,10 @@ test.describe("Phone shell", { tag: "@phone" }, () => { // either way, `table.data` or MUI's. { path: "/customers", content: "role=table", what: "the customer book" }, { path: "/flocks", content: "role=table", what: "the flock table" }, - { path: "/stock", content: "table.data", what: "the stock table" }, - { path: "/history", content: "table.data", what: "the entry history table" }, + // #831 — Stock and History moved their table onto MUI's `Table` too, + // same reasoning as Customers/Flocks above. + { path: "/stock", content: "role=table", what: "the stock table" }, + { path: "/history", content: "role=table", what: "the entry history table" }, ]; for (const { path: route, content, what } of ROUTES) { diff --git a/tools/simulation/ui/src/mutants.ts b/tools/simulation/ui/src/mutants.ts index 3adb775f..9d0fd47f 100644 --- a/tools/simulation/ui/src/mutants.ts +++ b/tools/simulation/ui/src/mutants.ts @@ -1121,19 +1121,25 @@ export const MUTANTS: Record = { + "so an unconverted screen's table lays its full content width out into the page instead of " + "scrolling within itself. #832 gave `Customers`/`Flocks` (and `Products`/`Grades`/`Users`) " + "the same containment through a different mechanism — a `MuiTableContainer` theme override, " - + "not this class — so this mutant's `table.data`-scoped rule no longer reaches them; see the " - + "note on EXPECT_MSG_FOR in mutation-check.sh.", + + "not this class — and #831 did the same for `Stock`/`History` — so this mutant's " + + "`table.data`-scoped rule no longer reaches any of them; see the note on EXPECT_MSG_FOR in " + + "mutation-check.sh.", caughtBy: "phone.spec.ts — no walked screen overflows the viewport horizontally", apply: (page) => - // Two of the six walked routes overflow under this now — /sales and - // /history — and four do not: /daily-entry and /stock render no wide - // data table, and /customers and /flocks moved off `table.data` in - // #832 (see `breaks` above). That per-route spread is why the spec's - // walk asserts PER ROUTE and asserts SOFTLY: a hard assertion stops at - // the first and reports half the damage. The exact widths are - // deliberately not recorded here; they drift with fixture content, and - // a stale copy of them in this file is a defect this file has already - // had once. + // Only ONE of the six walked routes overflows under this now — /sales + // — narrowed from two (/sales and /history) once #831 converted + // History. /daily-entry and /stock render no wide data table (Stock + // moved off `table.data` in #831 too); /customers, /flocks and + // /history moved onto MUI's `TableContainer` (#832, #831 — see + // `breaks` above), which this mutant's rule does not reach. That + // per-route spread is why the spec's walk asserts PER ROUTE and + // asserts SOFTLY: a hard assertion stops at the first and reports + // only part of the damage. The exact widths are deliberately not + // recorded here; they drift with fixture content, and a stale copy of + // them in this file is a defect this file has already had once. Once + // /sales converts too this mutant proves nothing at all and needs a + // new CSS target (MUI's `TableContainer`) or retirement (#824's + // "retire only with a named successor" rule). // // Desktop-green, stated honestly rather than claimed as containment: // the rule is inside `@media (max-width: 900px)`, so it cannot apply at From f2722e9a54f22676971c872cee9d473911c00988 Mon Sep 17 00:00:00 2001 From: mforce Date: Fri, 18 Sep 2026 00:35:11 +0000 Subject: [PATCH 12/14] fix(web): shrink Expenses' two Category selects' labels on placeholder (#831) TextField select whose value can be "" with a placeholder option needs slotProps.inputLabel.shrink=true or the label rests on top of the placeholder text (the owner caught this on #897's Grade select; #833 hit it again on Audit's filters). Expenses' category filter and its record-expense form both have the same shape. Adds the unit assertion #897 established (label carries MuiInputLabel-shrink) for both. --- web/src/routes/ExpensesPage.test.tsx | 13 +++++++++++++ web/src/routes/ExpensesPage.tsx | 9 +++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/web/src/routes/ExpensesPage.test.tsx b/web/src/routes/ExpensesPage.test.tsx index 3c49e828..74877123 100644 --- a/web/src/routes/ExpensesPage.test.tsx +++ b/web/src/routes/ExpensesPage.test.tsx @@ -424,6 +424,19 @@ describe("ExpensesPage category filter", () => { }), ); }); + + // Both the filter's and the record-expense form's Category select start at + // "" with a placeholder option, so without an explicit shrink the label + // sat on top of that text. jsdom cannot show the overlap; the shrink class + // is the DOM fact that stands in for it (same pattern as #897's Grade + // select and #833's Audit filters). + it("shrinks both Category selects' labels instead of sitting them on top of their placeholder text", async () => { + mockListExpenses.mockResolvedValue(emptyList("USD", 2)); + await renderReady("USD"); + const labels = screen.getAllByText("Category", { selector: "label" }); + expect(labels.length).toBeGreaterThan(0); + for (const label of labels) expect(label).toHaveClass("MuiInputLabel-shrink"); + }); }); describe("ExpensesPage pagination", () => { diff --git a/web/src/routes/ExpensesPage.tsx b/web/src/routes/ExpensesPage.tsx index 579fa61f..f1291ccf 100644 --- a/web/src/routes/ExpensesPage.tsx +++ b/web/src/routes/ExpensesPage.tsx @@ -574,7 +574,10 @@ export function ExpensesPage() { label={t("categoryLabel")} value={filterCategory} size="small" - slotProps={{ select: { native: true } }} + // The placeholder option shows text while `value` is "", so MUI + // would leave the label resting on top of it (#897/#833 caught + // this on Products' grade select and Audit's filters). + slotProps={{ select: { native: true }, inputLabel: { shrink: true } }} onChange={(e) => setFilterCategory(e.target.value)} > @@ -682,7 +685,9 @@ export function ExpensesPage() { label={t("categoryLabel")} value={categoryId} size="small" - slotProps={{ select: { native: true }, htmlInput: { required: true } }} + // The placeholder option shows text while `value` is "", so MUI + // would leave the label resting on top of it (#897/#833). + slotProps={{ select: { native: true }, htmlInput: { required: true }, inputLabel: { shrink: true } }} onChange={(e) => setCategoryId(e.target.value)} > From 2da532a2e80200b4f94d0b02cb32855b20b42d79 Mon Sep 17 00:00:00 2001 From: mforce Date: Fri, 18 Sep 2026 00:46:01 +0000 Subject: [PATCH 13/14] fix(web): merge FilterDateField's sx as an array, not a spread (#831) CodeRabbit on #901 (FilterBar cherry-picked into Audit): sx={{ ..., ...sx }} only spreads a plain object's own enumerable properties, so a caller passing a theme-callback function or an sx array had it silently dropped instead of merged. MUI accepts an sx array and applies each entry in order; using that form keeps the bounded-width default AND the caller's own sx, of any shape. Added a test that passes a function sx and asserts it applied (confirmed red against the prior spread-based merge, green after this fix). --- web/src/components/FilterBar.test.tsx | 21 +++++++++++++++++++++ web/src/components/FilterBar.tsx | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/web/src/components/FilterBar.test.tsx b/web/src/components/FilterBar.test.tsx index 974c3783..85c8fc63 100644 --- a/web/src/components/FilterBar.test.tsx +++ b/web/src/components/FilterBar.test.tsx @@ -39,4 +39,25 @@ describe("FilterBar", () => { fireEvent.change(screen.getByLabelText("From", { exact: true }), { target: { value: "2026-02-01" } }); expect(onChange).toHaveBeenCalledTimes(1); }); + + // CodeRabbit on #901 (FilterBar cherry-picked into Audit): `{ ...sx }` only + // spreads a plain object — a theme-callback `sx` function or an `sx` array + // has no own enumerable properties to spread, so either was silently + // dropped. `sx` accepts both shapes; a caller passing a function must still + // see it applied alongside the field's own bounded-width default. + it("still applies a caller's function-form sx alongside the bounded-width default", () => { + render( + + {}} + sx={() => ({ color: "rgb(1, 2, 3)" })} + /> + , + ); + const field = screen.getByLabelText("From", { exact: true }).closest(".MuiFormControl-root"); + expect(field).not.toBeNull(); + expect(field).toHaveStyle({ color: "rgb(1, 2, 3)" }); + }); }); diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx index 93a0faa2..7543f8dd 100644 --- a/web/src/components/FilterBar.tsx +++ b/web/src/components/FilterBar.tsx @@ -45,7 +45,12 @@ export function FilterDateField({ sx, slotProps, ...props }: TextFieldProps) { size="small" {...props} slotProps={{ ...slotProps, inputLabel: { shrink: true, ...slotProps?.inputLabel } }} - sx={{ maxWidth: { md: DATE_FIELD_MAX_WIDTH }, ...sx }} + // An array, not a spread: `sx` may be a callback (a theme function) or + // an array itself, and `{ ...sx }` on either silently drops it (spreads + // no own enumerable properties). MUI merges an sx array by applying + // each entry in order, so the caller's own sx — of any shape — still + // applies after the bounded-width default. + sx={[{ maxWidth: { md: DATE_FIELD_MAX_WIDTH } }, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]} /> ); } From fad48cea88ed5f9e974e47d64fc1b2276f57a71b Mon Sep 17 00:00:00 2001 From: mforce Date: Fri, 18 Sep 2026 01:02:45 +0000 Subject: [PATCH 14/14] fix(web): pin nowrap on Expenses' short-value cells, ruled categories list (#831) Coordinator review of the rendered frames: Category and Flock cells wrapped onto multiple lines at 1280 (the #897 regression this repo has hit before). Pins whiteSpace:nowrap on date/category/amount/flock/ record-history cells and their headers; description and note stay free text and keep wrapping. Also converts the categories-management panel from a bulleted
    to a ruled MUI List (dividers, no bullets), matching direction A's ruled-row language rather than a bulleted list inside a ruled region. --- web/src/routes/ExpensesPage.tsx | 49 +++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/web/src/routes/ExpensesPage.tsx b/web/src/routes/ExpensesPage.tsx index f1291ccf..0b1c5761 100644 --- a/web/src/routes/ExpensesPage.tsx +++ b/web/src/routes/ExpensesPage.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { FilterX, Receipt } from "lucide-react"; import { - Box, DialogActions, Divider, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, + Box, DialogActions, Divider, List, ListItem, ListItemText, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, } from "@mui/material"; import { adjustExpense, createExpense, createExpenseCategory, getExpense, @@ -652,18 +652,27 @@ export function ExpensesPage() { -
      - {categories.map((c) => ( -
    • - {c.name}{c.active ? "" : t("deactivatedSuffix")}{" "} - onToggleCategory(c)}> - {c.active ? t("deactivateButton") : t("reactivateButton")} - -
    • + {/* Direction A: ruled rows, not bullets — a small ruled list + mirrors the table shape every other list on this screen uses. */} + + {categories.map((c, i) => ( + onToggleCategory(c)}> + {c.active ? t("deactivateButton") : t("reactivateButton")} + + } + > + + ))} - {categories.length === 0 &&
    • {t("noCategoriesMessage")}
    • } -
    + {categories.length === 0 && ( + + + + )} + @@ -910,13 +919,13 @@ export function ExpensesPage() {
- {t("dateHeader")} - {t("categoryHeader")} + {t("dateHeader")} + {t("categoryHeader")} {t("descriptionHeader")} - {t("amountHeader")} - {t("flockHeader")} + {t("amountHeader")} + {t("flockHeader")} {t("noteHeader")} - {tc("recordHistoryHeader")} + {tc("recordHistoryHeader")} @@ -924,10 +933,10 @@ export function ExpensesPage() { {expenses.rows.map((x) => ( - {categoryName(x.expenseCategoryId)} + {categoryName(x.expenseCategoryId)} {x.description} - {fmt.money(x.amountMinorUnits, x.currencyCode, x.currencyMinorUnit)} - {rowFlockName(x)} + {fmt.money(x.amountMinorUnits, x.currencyCode, x.currencyMinorUnit)} + {rowFlockName(x)} {x.note ?? "—"}