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 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/components/FilterBar.test.tsx b/web/src/components/FilterBar.test.tsx new file mode 100644 index 00000000..85c8fc63 --- /dev/null +++ b/web/src/components/FilterBar.test.tsx @@ -0,0 +1,63 @@ +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); + }); + + // 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 new file mode 100644 index 00000000..7543f8dd --- /dev/null +++ b/web/src/components/FilterBar.tsx @@ -0,0 +1,56 @@ +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 ( + + ); +} 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. diff --git a/web/src/routes/ExpensesPage.test.tsx b/web/src/routes/ExpensesPage.test.tsx index 958ff95f..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", () => { @@ -1116,14 +1129,15 @@ describe("ExpensesPage total is never a guess (#469, codex P2)", () => { }); describe("ExpensesPage date-range filter (#667)", () => { - // #653/#662 — mirrors Increment 3's StockPage structural guard: the width - // cap in styles.css is keyed on `.toolbar input[type="date"]`, so the wrapper - // is the only honest thing jsdom (no layout engine) can assert here. - it("puts the date range in the bounded toolbar, not a bare filters row", async () => { + // #653/#662/#831 — mirrors StockPage's structural guard: the width cap + // moved from `.toolbar input[type="date"]` to FilterDateField's own `sx`, + // so the wrapper the field renders inside is the only honest thing jsdom + // (no layout engine) can assert here. + it("puts the date range in the bounded FilterBar, not a bare filters row", async () => { renderWithProviders(, { token: ADMIN }); await waitFor(() => expect(mockListExpenses).toHaveBeenCalled()); const fromInput = screen.getByLabelText("From"); - expect(fromInput.closest("div.toolbar")).not.toBeNull(); + expect(fromInput.closest(".MuiPaper-outlined")).not.toBeNull(); }); diff --git a/web/src/routes/ExpensesPage.tsx b/web/src/routes/ExpensesPage.tsx index 10f80e7e..0b1c5761 100644 --- a/web/src/routes/ExpensesPage.tsx +++ b/web/src/routes/ExpensesPage.tsx @@ -3,6 +3,9 @@ import type { FormEvent } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { FilterX, Receipt } from "lucide-react"; +import { + Box, DialogActions, Divider, List, ListItem, ListItemText, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, +} from "@mui/material"; import { adjustExpense, createExpense, createExpenseCategory, getExpense, listExpenseCategories, listExpenses, listFlocks, updateExpenseCategory, @@ -14,6 +17,7 @@ import { FarmDate } from "../components/FarmDate"; import { BusyButton } from "../components/BusyButton"; import { Dialog } from "../components/Dialog"; import { EmptyState } from "../components/EmptyState"; +import { FilterBar, FilterDateField } from "../components/FilterBar"; import { FlockPicker } from "../components/FlockPicker"; import type { PickerSnapshot } from "../components/NamedEntityPicker"; import { DialogError } from "../components/DialogError"; @@ -30,6 +34,11 @@ function errText(err: unknown): string { } const PAGE = 100; +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 scopes that own a dialog (#703). `run` routes a failure by this and gates // a success by it; a scope outside the list — the record-expense form on the @@ -544,38 +553,38 @@ export function ExpensesPage() { return (
-

{t("title")}

- -
- {/* #667 — a from/to pair matching every sibling list screen; the - category filter beside it is not a date control and stays outside - the toolbar. */} -
- {/* No `max` on either bound. The month picker this replaced capped at - the current MONTH, which contained its own month-end default; a - day-granularity control capped at TODAY does not — the default - `to` is month-end, so the cap made the input render a value it - forbade, and made the default unreachable once changed. The - sibling range filters (Feed, Water, History) ship uncapped for the - same reason: a future window is empty by construction, which is - cheaper than a control that argues with its own value. */} - - -
- + {t("title")} + + {/* #667/#831 — a from/to pair matching every sibling list screen, the + category filter beside it in the same bar now that FilterBar governs + the whole filter row rather than only the bounded dates. */} + + {/* No `max` on either bound. The month picker this replaced capped at + the current MONTH, which contained its own month-end default; a + day-granularity control capped at TODAY does not — the default + `to` is month-end, so the cap made the input render a value it + forbade, and made the default unreachable once changed. The + sibling range filters (Feed, Water, History) ship uncapped for the + same reason: a future window is empty by construction, which is + cheaper than a control that argues with its own value. */} + setFrom(e.target.value)} /> + setTo(e.target.value)} /> + setFilterCategory(e.target.value)} + > + + {categories.map((c) => ( + + ))} + @@ -587,7 +596,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 +616,150 @@ 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")} + + + + + {/* 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("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 +781,105 @@ 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); + 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. */} + + { + 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 +888,8 @@ export function ExpensesPage() { disabled={busy || !editFlockSnapshot.canSubmit}> {t("saveCorrectionButton")} -
- + +
)} @@ -840,41 +915,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 && (
; - 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/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 && ( )} - +

{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,259 @@ 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 && ( + + )} + {/* `.actions` stays: it carries a real phone-stacking rule + (styles.css, D3.4's default), not a bare layout hook. */} +
+ +
+ + + )} - - - - - - {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")} + + )} + + )} + + + ))} + +
+
); } 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")}

diff --git a/web/src/routes/StockPage.test.tsx b/web/src/routes/StockPage.test.tsx index 26a8f898..a575da11 100644 --- a/web/src/routes/StockPage.test.tsx +++ b/web/src/routes/StockPage.test.tsx @@ -99,15 +99,15 @@ describe("StockPage", () => { ).toBeInTheDocument(); }); - it("renders the grade table as a table.data scroller (the scroll-cue hook, #150)", async () => { - // The mobile scroll-shadow affordance keys entirely off `table.data` in the - // stylesheet (no JS, no wrapper), so the class IS the contract: drop it in a - // refactor and the last-column-clipped cue silently disappears. jsdom can't + it("renders the grade table inside a MuiTableContainer scroller (the scroll-cue hook, #150/#831)", async () => { + // #831 — the mobile scroll-shadow affordance moved from `table.data` in + // the stylesheet to the theme's `MuiTableContainer` phone override + // (FarmThemeProvider.tsx), which every MUI Table now shares. jsdom can't // render the gradient, but it can guard the hook the CSS depends on. mockGetStock.mockResolvedValue(ROWS); const { container } = render(); await screen.findByText("Grade A"); - expect(container.querySelector("table.data")).not.toBeNull(); + expect(container.querySelector(".MuiTableContainer-root")).not.toBeNull(); }); }); @@ -169,12 +169,12 @@ describe("StockPage drill-down", () => { expect(mockListEggLots).toHaveBeenLastCalledWith({ gradeId: "g1", limit: 50, offset: 0 }); }); - it("puts the lot date range in the bounded toolbar, not a bare filters row", async () => { - // #653/#662 — this is a structural guard on purpose. The defect is a WIDTH - // (styles.css caps `.toolbar input[type="date"]` at 12rem and the pair was - // outside any .toolbar), and jsdom computes no layout, so the only honest - // assertion here is the wrapper the cap is keyed on. The rendered result is - // checked by the before/after screenshot pair on the PR. + it("puts the lot date range in the bounded FilterBar, not a bare filters row", async () => { + // #653/#662/#831 — this is a structural guard on purpose. The width cap + // moved from `.toolbar input[type="date"]` (12rem) to FilterDateField's + // own `sx`, and jsdom computes no layout so the only honest assertion + // here is the wrapper the field is rendered inside. The rendered result + // is checked by the before/after screenshot pair on the PR. mockListEggLots.mockResolvedValue(LOTS); await renderWithData(); const gradeA = screen.getByRole("row", { name: /Grade A\b/ }); @@ -182,7 +182,7 @@ describe("StockPage drill-down", () => { await screen.findByRole("row", { name: /07\/01\/2026/ }); const fromInput = screen.getByLabelText("From"); - expect(fromInput.closest("div.toolbar")).not.toBeNull(); + expect(fromInput.closest(".MuiPaper-outlined")).not.toBeNull(); }); it("collapses the lots again on 'hide lots'", async () => { diff --git a/web/src/routes/StockPage.tsx b/web/src/routes/StockPage.tsx index 85f4ab17..e61280c9 100644 --- a/web/src/routes/StockPage.tsx +++ b/web/src/routes/StockPage.tsx @@ -3,6 +3,9 @@ import type { FormEvent } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { Egg, FilterX } from "lucide-react"; +import { + DialogActions, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, +} from "@mui/material"; import { getStock, listEggLotMovements, listEggLots, recordEggLotMovement, } from "../api/cluckwork"; @@ -12,6 +15,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 { GlossaryLink } from "../components/GlossaryLink"; import { Dialog } from "../components/Dialog"; import { DialogError } from "../components/DialogError"; @@ -23,6 +27,7 @@ import { newId } from "../lib/ids"; // Matches the API's default page size — a full page means there may be more. const LOT_PAGE = 50; +const NOWRAP = { whiteSpace: "nowrap" as const }; // The scope that owns a dialog (#703). `run` routes a failure by this and gates // a success by it; nothing else on this screen goes through `run`. @@ -472,9 +477,9 @@ export function StockPage() { } if (errors.page && rows === null) { - return

{t("title")}

{errors.page}

; + return
{t("title")}

{errors.page}

; } - if (rows === null) return

{t("title")}

{tc("loading")}

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

{tc("loading")}

; const totalAvailable = rows.reduce((a, r) => a + r.available, 0); // Largest available across the loaded rows scales every meter fill so the bars @@ -483,7 +488,7 @@ export function StockPage() { return (
-

{t("title")}

+ {t("title")} {errors.page &&

{errors.page}

} {message &&

{message}

} {rows.length === 0 ? ( @@ -492,53 +497,52 @@ export function StockPage() { ) : ( <> - - - - - - {rows.map((r) => ( - - - - - - - ))} - -
{t("gradeHeader")}{t("availableHeader")}{t("restrictedHeader")}
{r.gradeName} - {fmt.count(r.available)} - - {r.restricted > 0 ? {fmt.count(r.restricted)} : "—"} - -
+ + + + + {t("gradeHeader")} + {t("availableHeader")} + {t("restrictedHeader")} + + + + + {rows.map((r) => ( + + {r.gradeName} + + {fmt.count(r.available)} + + + {r.restricted > 0 ? {fmt.count(r.restricted)} : "—"} + + + + + ))} + +
+

{t("totalAvailableMessage", { available: fmt.count(totalAvailable), grades: rows.length })}

{openGrade !== null && ( <>

{t("lotsHeading")}

{/* #465 — a server-side production-date window, so an old lot is - findable without paging the whole history to it. - #653 — and it belongs in the bounded toolbar: the width cap in - styles.css is keyed on `.toolbar input[type="date"]`, so under - `.filters` these two rendered at the row's full width, which is - exactly the bug #653 was filed to fix. This section has no - non-date filter, so the whole bar is the toolbar — the same - shape ReportsPage uses. */} -
- - -
+ findable without paging the whole history to it. This + section has no non-date filter, so the whole bar is the + filter bar — the same shape ReportsPage uses. */} + + void changeLotsFilter(e.target.value, lotsTo)} /> + void changeLotsFilter(lotsFrom, e.target.value)} /> + {lots.length === 0 ? ( // lotsFrom/lotsTo are this section's own filter, unrelated to // any page-head action — "filtered to nothing" offers Clear @@ -548,57 +552,64 @@ export function StockPage() { action={{ label: tc("clearFiltersButton"), onClick: () => void changeLotsFilter("", "") }} /> : ) : ( - - - - - - {lots.map((l) => ( - - - - -
{t("producedOnHeader")}{t("producedHeader")}{t("availableHeader")}
{fmt.count(l.quantityProduced)}{fmt.count(l.quantityAvailable)} - {/* #493 — the audit trail for MANUAL ADJUSTMENTS to - this lot (write-offs, recounts), distinct from - the button below: that one toggles the - inventory MOVEMENT ledger in place (quantities - in/out), a different and older trail. Two - affordances on the same row on purpose — kept - visibly separate by label so they don't read as - the same thing. - Deliberately NOT "Audit history"/viewHistoryLink - (codex review of #516): the only audit action - ever written against an EggLot's own entityId is - a manual write-off/recount - (RecordEggLotMovementHandler) — creation is - recorded against the Daily Entry that produced - the lot, allocation/restoration against the - Sales Order, so - a normal never-adjusted lot would show nothing - under the generic "full audit trail" label the - other five screens use accurately. */} - {/* Admin-gated: /api/v1/audit is AdminOnly, and - this screen is readable by non-admins too - (codex review of #516). */} - {isAdmin && ( - - {tc("recordHistory.viewAdjustmentHistoryLink")} - - )} - - {isAdmin && ( - + )} + + + ))} + +
+ )} {hasMoreLots && !lotsLoading && ( {t("writeOffSubmitButton")} - - + + )}
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 && (