docs(plan): MUI migration Phase 3 (kit expansion)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,362 @@
|
|||||||
|
# MUI Migration — Phase 3: Kit Expansion — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (or direct execution with gates). Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Build the reusable `src/admin/ui/` components the page migrations keep needing, so later phases are fast and consistent. No page is migrated in this phase; the dev-only `/ui-kit` route is extended to showcase + visually verify each new component.
|
||||||
|
|
||||||
|
**Why these (scout reuse counts):** `Select` ~34 pages, `Tabs` ~41 files, `StatusChip` ~34, plus paginated/sortable lists, dates, checkboxes, inline alerts.
|
||||||
|
|
||||||
|
**Tech Stack:** MUI v7 (`@mui/material`) + `@mui/x-date-pickers` v8 + `date-fns` (cs locale) + Emotion. TypeScript strict.
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (`/ui-kit`) is the user's. No dev server started by implementers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Component(s) | API |
|
||||||
|
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------- | --------- | ---------------------------------------- |
|
||||||
|
| `src/admin/ui/Select.tsx` | `Select` | `{ value, onChange(v), options?: {value,label}[], children?, ...TextFieldProps }` over `<TextField select>` — composes inside `Field` |
|
||||||
|
| `src/admin/ui/StatusChip.tsx` | `StatusChip` | `{ label, color?: "default" | "success" | "error" | "warning" | "info", onClick?, size?, ...ChipProps }` |
|
||||||
|
| `src/admin/ui/Tabs.tsx` | `Tabs`, `TabPanel` | `Tabs: { value, onChange(v), tabs: {value,label}[] }`; `TabPanel: { value, current, children }` |
|
||||||
|
| `src/admin/ui/Pagination.tsx` | `Pagination` | `{ page, pageCount, onChange(p) }` (renders nothing if `pageCount<=1`) |
|
||||||
|
| `src/admin/ui/Checkbox.tsx` | `CheckboxField` | `{ label, checked, onChange(b) }` (FormControlLabel + Checkbox) |
|
||||||
|
| `src/admin/ui/Alert.tsx` | `Alert` | `{ severity, children, onClose? }` (inline MUI Alert) |
|
||||||
|
| `src/admin/ui/DataTable.tsx` | extend | add `DataColumn.sortKey?`; `DataTableProps.{ sortBy?, sortDir?, onSort?(key) }`; render `TableSortLabel` in sortable headers |
|
||||||
|
| `src/admin/ui/DateField.tsx` | `DateField` | `{ value: string("yyyy-MM-dd" | ""), onChange(v: string), ...}`over MUI X`DatePicker`, format `dd.MM.yyyy` |
|
||||||
|
| `src/admin/ui/MuiProvider.tsx` | extend | wrap children in `LocalizationProvider` (`AdapterDateFns`, cs locale) so date pickers work app-wide |
|
||||||
|
| `src/admin/ui/index.ts` | extend | export all new components |
|
||||||
|
| `src/admin/pages/UiKit.tsx` | extend | showcase each new component in both themes |
|
||||||
|
|
||||||
|
Deferred (build when their page is migrated): `Autocomplete` (warehouse pickers), `FilterBar` layout helper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `Select`, `StatusChip`, `CheckboxField`, `Alert` (simple wrappers)
|
||||||
|
|
||||||
|
**Files:** Create the four files; modify `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `Select.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
type Props = Omit<TextFieldProps, "onChange" | "select" | "value"> & {
|
||||||
|
value: string | number;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options?: SelectOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dropdown styled like the kit TextField; composes inside <Field>. */
|
||||||
|
export default function Select({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<MuiTextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options
|
||||||
|
? options.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
: children}
|
||||||
|
</MuiTextField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `StatusChip.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Chip, { type ChipProps } from "@mui/material/Chip";
|
||||||
|
|
||||||
|
type Props = Omit<ChipProps, "color"> & {
|
||||||
|
color?: "default" | "success" | "error" | "warning" | "info";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Semantic status chip. Pass onClick to make it a toggle. */
|
||||||
|
export default function StatusChip({
|
||||||
|
color = "default",
|
||||||
|
size = "small",
|
||||||
|
onClick,
|
||||||
|
sx,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{ fontWeight: 600, ...(onClick ? { cursor: "pointer" } : {}), ...sx }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: `Checkbox.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import MuiCheckbox from "@mui/material/Checkbox";
|
||||||
|
|
||||||
|
export function CheckboxField({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<MuiCheckbox
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Import `React` type or use `import type { ReactNode } from "react"` and type `label: ReactNode`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: `Alert.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import MuiAlert from "@mui/material/Alert";
|
||||||
|
|
||||||
|
export default function Alert({
|
||||||
|
severity = "info",
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
severity?: "success" | "error" | "warning" | "info";
|
||||||
|
children: ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
|
||||||
|
{children}
|
||||||
|
</MuiAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:** export from `index.ts`: `Select` (+ `type SelectOption`), `StatusChip`, `{ CheckboxField }`, `Alert`.
|
||||||
|
- [ ] **Step 6: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Select, StatusChip, CheckboxField, Alert`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `Tabs` + `TabPanel`, `Pagination`
|
||||||
|
|
||||||
|
**Files:** Create `Tabs.tsx`, `Pagination.tsx`; modify `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `Tabs.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import MuiTabs from "@mui/material/Tabs";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
|
export interface TabDef {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tabs,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tabs: TabDef[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MuiTabs
|
||||||
|
value={value}
|
||||||
|
onChange={(_, v) => onChange(v)}
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
minHeight: 44,
|
||||||
|
"& .MuiTab-root": { textTransform: "none", fontWeight: 600 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<Tab key={t.value} value={t.value} label={t.label} />
|
||||||
|
))}
|
||||||
|
</MuiTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPanel({
|
||||||
|
value,
|
||||||
|
current,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
current: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
if (value !== current) return null;
|
||||||
|
return <Box sx={{ pt: 2.5 }}>{children}</Box>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `Pagination.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import MuiPagination from "@mui/material/Pagination";
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
if (pageCount <= 1) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||||
|
<MuiPagination
|
||||||
|
page={page}
|
||||||
|
count={pageCount}
|
||||||
|
onChange={(_, p) => onChange(p)}
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** export `{ Tabs, TabPanel }` (+ `type TabDef`) and `Pagination` from `index.ts`.
|
||||||
|
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Tabs/TabPanel + Pagination`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Sortable `DataTable`
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/ui/DataTable.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Extend the types:
|
||||||
|
- `DataColumn<T>`: add `sortKey?: string;`
|
||||||
|
- `DataTableProps<T>`: add `sortBy?: string; sortDir?: "asc" | "desc"; onSort?: (key: string) => void;`
|
||||||
|
- [ ] **Step 2:** In the header cell render, if `c.sortKey && onSort` is set, wrap the header in `TableSortLabel`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import TableSortLabel from "@mui/material/TableSortLabel";
|
||||||
|
// ...header cell:
|
||||||
|
{
|
||||||
|
c.sortKey && onSort ? (
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === c.sortKey}
|
||||||
|
direction={sortBy === c.sortKey ? (sortDir ?? "asc") : "asc"}
|
||||||
|
onClick={() => onSort(c.sortKey!)}
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
</TableSortLabel>
|
||||||
|
) : (
|
||||||
|
c.header
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-sortable columns (no `sortKey`) render the plain header (unchanged). The existing `bold`/`mono`/`align`/`rowInactive`/`empty` behavior is untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify** `tsc -b` clean, `build:client` OK, `vitest run` (148). **Commit** (`feat(mui): kit — sortable DataTable headers (TableSortLabel)`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `DateField` + LocalizationProvider
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/DateField.tsx`; modify `MuiProvider.tsx`, `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `LocalizationProvider` to `MuiProvider.tsx`** wrapping its children, so date pickers work everywhere:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
|
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
|
||||||
|
import { cs } from "date-fns/locale";
|
||||||
|
// inside MuiProvider, wrap {children} (and the sync) :
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
|
||||||
|
<MuiColorSchemeSync />
|
||||||
|
{children}
|
||||||
|
</LocalizationProvider>;
|
||||||
|
```
|
||||||
|
|
||||||
|
> Verify the exact adapter import path for the installed `@mui/x-date-pickers@8` against its docs (it may be `@mui/x-date-pickers/AdapterDateFns` rather than `...V3`). Pick the one that resolves; report which.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `DateField.tsx`** — string-in / string-out (`yyyy-MM-dd`) to match existing form state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
|
import { parseISO, format, isValid } from "date-fns";
|
||||||
|
|
||||||
|
export default function DateField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: { value: string; onChange: (v: string) => void } & Record<string, unknown>) {
|
||||||
|
const dateValue = value ? parseISO(value) : null;
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
value={dateValue && isValid(dateValue) ? dateValue : null}
|
||||||
|
onChange={(d) => onChange(d && isValid(d) ? format(d, "yyyy-MM-dd") : "")}
|
||||||
|
format="dd.MM.yyyy"
|
||||||
|
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** export `DateField` from `index.ts`.
|
||||||
|
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — DateField over MUI X + cs LocalizationProvider`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Extend `/ui-kit` showcase + final review
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/UiKit.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add a section rendering each new component with sample data, in the existing `ScopedCssBaseline` page: a `Select` (3 options), `StatusChip`s (each color), `Tabs`+`TabPanel` (2 tabs), `Pagination` (page 2 of 5), `CheckboxField`, `Alert` (one of each severity), a sortable `DataTable` (3 columns, one `sortKey`, local sort state), and a `DateField`.
|
||||||
|
- [ ] **Step 2: Verify** `tsc -b` clean, `npm run build` (server+client) OK, `npx vitest run` (148). **Commit** (`feat(mui): showcase new kit components in /ui-kit`; + trailer).
|
||||||
|
- [ ] **Step 3:** Final review of all new components (parallel/adversarial), then finishing-a-development-branch after the user's `/ui-kit` visual check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- All new components compose with the existing `Field` (Select/DateField are inputs; Field provides the label) and follow the size=small/outlined/fullWidth defaults.
|
||||||
|
- `DataTable` sort is **additive** — existing callers (Vehicles) are unaffected (no `sortKey`/`onSort` → plain headers).
|
||||||
|
- `DateField` is string-in/string-out so it drops into existing form state (`start_date: ""`), and uses cs locale + `dd.MM.yyyy` (matching the date convention in CLAUDE.md).
|
||||||
|
- Deferred: `Autocomplete`, `FilterBar` — built when warehouse/list pages are migrated.
|
||||||
Reference in New Issue
Block a user