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")}
-
+
{/* 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.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). */}
-
- {/* #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. */}
-
-
+
+
{/* 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. */}
-
assignRest(g.id))}>
- {g.name}{g.active ? "" : t("inactiveGradeSuffix")}
- {/* #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). */}
-
-
-
-
-
- {t("reasonLabel")}
- setReason(e.target.value)} />
-
+ 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. */}
-
+ {tc("cancel")}
{/* #394: an adjustment has no draft state — Save stays disabled
until grading reconciles exactly, the same rule Daily
Entry's submit uses. */}
{t("saveAdjustmentButton")}
-
- {/* #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. */}
-
- {/* #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. */}
- startAdjust(e)}>{t("adjustButton")}
- 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. */}
+ startAdjust(e)}>{t("adjustButton")}
+ void onVoid(e)}>{t("voidButton")}
+ >
+ )}
+
+
+ ))}
+
+
+
{entries.canLoadMore && (
void entries.loadMore()}>
From c2ff52aead7963e3f2c2fc5ae6339b781693c8c1 Mon Sep 17 00:00:00 2001
From: mforce
Date: Thu, 17 Sep 2026 23:48:49 +0000
Subject: [PATCH 07/14] feat(web): convert Expenses to MUI (#831)
Pairs 7/9/10/11/15: FilterBar now holds the whole filter row (dates,
category select, manage-categories/clear-filters links) where
.filters used to wrap a bounded .toolbar beside a bare category
field; the categories panel converts to the same Box-between-two-
Dividers ruled region FlocksPage established for pair 15; both forms
and the ledger table convert to MUI. 1 of 71 tests rewritten from the
retired div.toolbar class to .MuiPaper-outlined, same fix as Stock's.
---
web/src/routes/ExpensesPage.test.tsx | 11 +-
web/src/routes/ExpensesPage.tsx | 527 +++++++++++++++------------
2 files changed, 301 insertions(+), 237 deletions(-)
diff --git a/web/src/routes/ExpensesPage.test.tsx b/web/src/routes/ExpensesPage.test.tsx
index 958ff95f..3c49e828 100644
--- a/web/src/routes/ExpensesPage.test.tsx
+++ b/web/src/routes/ExpensesPage.test.tsx
@@ -1116,14 +1116,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..dca5b39a 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, 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,35 @@ 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("fromLabel")}
- setFrom(e.target.value)} />
-
- {t("toLabel")}
- setTo(e.target.value)} />
-
-
- {t("categoryLabel")}
-
-
+ {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) => (
+
+ ))}
+ setShowCategories((v) => !v)}>
{showCategories ? t("hideCategoriesButton") : t("manageCategoriesButton")}
@@ -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 && (
-
+ // 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")}
+
+ { openDialog("add-category"); setAddingCategory(true); }}>
+ {t("newCategoryButton")}
+
+
+
+
+
+
-
+ {/* 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 && (
- {/* #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). */}
- { openDialog("edit"); startEdit(x); }}>
- {t("correctButton")}
-
-
-
- ))}
-
-
+
+
+
+
+ {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). */}
+ { openDialog("edit"); startEdit(x); }}>
+ {t("correctButton")}
+
+
+
+ ))}
+
+
+
)}
{expenses.canLoadMore && (
Date: Thu, 17 Sep 2026 23:52:13 +0000
Subject: [PATCH 08/14] feat(web): convert Inventory to MUI (#831)
Pairs 9/10/11/15: title row gets its New item action beside it via
Stack justify-content space-between, both catalog dialogs and the two
item-panel dialogs (purchase, adjust) convert to Stack+TextField, the
item panel becomes the same Box-between-two-Dividers ruled region
Expenses/Flocks already use, and both tables convert. Inventory has
no list-level filter row, so it does not call FilterBar. All 73
existing tests pass unchanged.
---
web/src/routes/InventoryPage.tsx | 536 ++++++++++++++++++-------------
1 file changed, 307 insertions(+), 229 deletions(-)
diff --git a/web/src/routes/InventoryPage.tsx b/web/src/routes/InventoryPage.tsx
index 137dcedd..575df2d8 100644
--- a/web/src/routes/InventoryPage.tsx
+++ b/web/src/routes/InventoryPage.tsx
@@ -3,6 +3,9 @@ import type { FormEvent } from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
+import {
+ Box, DialogActions, Divider, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography,
+} from "@mui/material";
import {
createInventoryItem, activateInventoryItem, deactivateInventoryItem, getAccount,
listInventoryItems, listInventoryLots, listInventoryMovements, parseMoneyToMinorUnits,
@@ -24,6 +27,8 @@ import { FEEDABLE_CATEGORIES } from "./FeedPage";
import i18n from "../i18n";
import { inventoryCategoryLabel, inventoryMovementLabel, statusLabel } from "../i18n/enums";
+const NOWRAP = { whiteSpace: "nowrap" as const };
+
// Feed first (spec §12); the rest of the categories get their features later.
const CATEGORIES = [
"Feed", "Supplement", "Additive", "Medication", "Vaccine",
@@ -466,80 +471,98 @@ export function InventoryPage() {
: "—";
if (errors.page && items === null) {
- return
{/* Gated like the inline form was: a role change mid-edit closes it. */}
{/* Unconditional since #479 — a dialog's failure lives in its own slot
@@ -548,202 +571,257 @@ export function InventoryPage() {
{message &&
-
- {/* One row of actions; each opens its own dialog so the ledger below
- stays put instead of being pushed down by three stacked forms. */}
-
- { openDialog("purchase"); setPurchasing(true); }}>
- {t("recordPurchaseButton")}
-
- {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 && (
- { openDialog("adjust"); setAdjusting(true); }}>
- {t("correctStockButton")}
+ // 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
+ // FlocksPage (#897) and Expenses (#831) already use for their own
+ // `.order-panel` drill-downs.
+
+
+
+
+ {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. */}
+
+ { openDialog("purchase"); setPurchasing(true); }}>
+ {t("recordPurchaseButton")}
+ {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 && (
+ { openDialog("adjust"); setAdjusting(true); }}>
+ {t("correctStockButton")}
+
+ )}
+
+
+ {/* Why an action is unavailable, in the place the button would be. */}
+ {!canFeed && (
+
- ) : null}
-
-
-
-
+
+
-
- {/* #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 &&
+ slotProps={{ htmlInput: { step: 0.001, required: true } }}
+ onChange={(e) => setAdjustQty(e.target.value)}
+ />
+ setAdjustReason(e.target.value)}
+ />
+
+
+ {tc("cancel")}
+ {/* 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 &&
+
);
}
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. */}
+
setActive(null)}>{t("closeButton")}
-
+
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() {
-
+ {/* 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 &&