Files
app/docs/superpowers/plans/2026-06-06-mui-migration-phase-2-vozidla.md
2026-06-06 20:43:04 +02:00

396 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MUI Migration — Phase 2: Data/Form Kit + Vozidla Pilot — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Build the data + form foundation components (`DataTable`, `Modal`, `ConfirmDialog`, field controls) and migrate the **Vozidla (Vehicles)** page fully onto them — the pilot that battle-tests the kit. Behavior stays identical; styling becomes MUI (Soft-SaaS shell + dense table).
**Architecture:** New wrappers in `src/admin/ui/` expose stable, app-friendly APIs (Czech labels) over MUI `Dialog`/`Table`/form controls, so pages import from `ui/` not `@mui`. `Modal`/`ConfirmDialog` preserve the existing `FormModal`/`ConfirmModal` prop shapes so page churn is minimal. `DataTable` is a small dense table that takes a `columns` config + `rows` + optional row actions. Vozidla is then rewritten to use them; its data hooks (`useQuery(vehicleListOptions)`, `useApiMutation`) are unchanged.
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (page-entry animations kept), TanStack Query v5, TypeScript strict.
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 kit, §5.1 DataTable, §7 dense-table aesthetic, §8 Phase 2).
**Authoritative gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (expect 148). Live visual check (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
**Reference (current Vozidla behavior to preserve):** `src/admin/pages/Vehicles.tsx` — list table (SPZ, Název, Značka/Model, Počáteční km, Aktuální km, Počet jízd, Stav, Akce) with `formatKm`; inactive rows dimmed; status badge toggles active/inactive via `toggleVehicle`; row actions edit/delete; add/edit `FormModal` with SPZ (uppercased)/Název (required) + Značka/Model/Počáteční km/aktivní checkbox + validation (`Zadejte SPZ`/`Zadejte název`); delete `ConfirmModal`; permission gate `vehicles.manage``<Forbidden/>`; empty state.
---
## File Structure
| File | Responsibility | Action |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
| `src/admin/ui/Modal.tsx` | MUI `Dialog` form modal; preserves FormModal API (`isOpen`/`onClose`/`onSubmit`/`title`/`subtitle`/`loading`/`submitDisabled`/`submitText`/`children`) | Create |
| `src/admin/ui/ConfirmDialog.tsx` | MUI `Dialog` confirm; preserves ConfirmModal API (`isOpen`/`onClose`/`onConfirm`/`title`/`message`/`confirmText`/`confirmVariant`/`loading`) | Create |
| `src/admin/ui/DataTable.tsx` | Dense MUI `Table` from a `columns` config + `rows`; right-aligned/mono numeric cells; row hover; optional `rowInactive` + actions column | Create |
| `src/admin/ui/Field.tsx` | Labeled field wrapper (label + required + error helper) for composing inputs; `SwitchField` for booleans | Create |
| `src/admin/ui/index.ts` | Export the new components | Modify |
| `src/admin/pages/Vehicles.tsx` | Rewrite to use the kit | Modify |
Import paths from `src/admin/ui/`: `../context/AlertContext`, `../theme`, etc. MUI via deep imports.
---
## Task 1: `Modal` + `ConfirmDialog` (MUI Dialog wrappers)
**Files:** Create `src/admin/ui/Modal.tsx`, `src/admin/ui/ConfirmDialog.tsx`; Modify `src/admin/ui/index.ts`.
- [ ] **Step 1: `Modal.tsx`** — MUI `Dialog` preserving the existing `FormModal` API:
```tsx
import type { ReactNode } from "react";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Typography from "@mui/material/Typography";
import MuiButton from "@mui/material/Button";
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
subtitle?: string;
children: ReactNode;
loading?: boolean;
submitDisabled?: boolean;
submitText?: string;
cancelText?: string;
maxWidth?: "xs" | "sm" | "md" | "lg";
}
export default function Modal({
isOpen,
onClose,
onSubmit,
title,
subtitle,
children,
loading = false,
submitDisabled = false,
submitText = "Uložit",
cancelText = "Zrušit",
maxWidth = "sm",
}: ModalProps) {
return (
<Dialog
open={isOpen}
onClose={loading ? undefined : onClose}
fullWidth
maxWidth={maxWidth}
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
>
<DialogTitle sx={{ pb: subtitle ? 0.5 : 1.5 }}>
{title}
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</DialogTitle>
<DialogContent>{children}</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
{cancelText}
</MuiButton>
<MuiButton
onClick={onSubmit}
variant="contained"
disabled={loading || submitDisabled}
>
{loading ? "Ukládám…" : submitText}
</MuiButton>
</DialogActions>
</Dialog>
);
}
```
- [ ] **Step 2: `ConfirmDialog.tsx`** — MUI `Dialog` preserving the `ConfirmModal` API:
```tsx
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
import MuiButton from "@mui/material/Button";
export interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: "primary" | "danger";
loading?: boolean;
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Potvrdit",
cancelText = "Zrušit",
confirmVariant = "primary",
loading = false,
}: ConfirmDialogProps) {
return (
<Dialog
open={isOpen}
onClose={loading ? undefined : onClose}
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{message}</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
{cancelText}
</MuiButton>
<MuiButton
onClick={onConfirm}
variant="contained"
color={confirmVariant === "danger" ? "error" : "primary"}
disabled={loading}
>
{loading ? "Pracuji…" : confirmText}
</MuiButton>
</DialogActions>
</Dialog>
);
}
```
- [ ] **Step 3:** add `export { default as Modal } from "./Modal";` and `export { default as ConfirmDialog } from "./ConfirmDialog";` to `src/admin/ui/index.ts`.
- [ ] **Step 4: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
- [ ] **Step 5: Commit** (msg `feat(mui): Modal + ConfirmDialog (MUI Dialog wrappers)`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
---
## Task 2: `DataTable` (dense MUI Table)
**Files:** Create `src/admin/ui/DataTable.tsx`; Modify `src/admin/ui/index.ts`.
- [ ] **Step 1: Create `DataTable.tsx`** — a generic dense table:
```tsx
import type { ReactNode } from "react";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Box from "@mui/material/Box";
export interface DataColumn<T> {
key: string;
header: string;
align?: "left" | "right";
mono?: boolean; // DM Mono numeric cell
render: (row: T) => ReactNode;
}
export interface DataTableProps<T> {
columns: DataColumn<T>[];
rows: T[];
rowKey: (row: T) => string | number;
rowInactive?: (row: T) => boolean;
empty?: ReactNode;
}
export default function DataTable<T>({
columns,
rows,
rowKey,
rowInactive,
empty,
}: DataTableProps<T>) {
if (rows.length === 0 && empty) return <>{empty}</>;
return (
<TableContainer sx={{ borderRadius: 2 }}>
<Table size="small" sx={{ "& td, & th": { borderColor: "divider" } }}>
<TableHead>
<TableRow>
{columns.map((c) => (
<TableCell
key={c.key}
align={c.align}
sx={{
textTransform: "uppercase",
letterSpacing: ".05em",
fontSize: ".64rem",
fontWeight: 700,
color: "text.secondary",
}}
>
{c.header}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={rowKey(row)}
hover
sx={rowInactive?.(row) ? { opacity: 0.55 } : undefined}
>
{columns.map((c) => (
<TableCell
key={c.key}
align={c.align}
sx={{
fontSize: ".8rem",
...(c.mono ? { fontFamily: "'DM Mono', monospace" } : {}),
}}
>
{c.render(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
```
(Numeric cells: pass `align="right" mono` and render via the existing `formatKm`/`formatCurrency` — the table only styles, never re-formats.)
- [ ] **Step 2:** add `export { default as DataTable } from "./DataTable"; export type { DataColumn } from "./DataTable";` to `index.ts`.
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
- [ ] **Step 4: Commit** (msg `feat(mui): DataTable — dense MUI table with columns config`; + trailer).
---
## Task 3: Field controls (`Field` + `SwitchField`)
**Files:** Create `src/admin/ui/Field.tsx`; Modify `src/admin/ui/index.ts`.
- [ ] **Step 1: Create `Field.tsx`** with a labeled wrapper and a boolean switch:
```tsx
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
export function Field({
label,
required,
error,
hint,
children,
}: {
label: string;
required?: boolean;
error?: string;
hint?: string;
children: ReactNode;
}) {
return (
<Box sx={{ mb: 2 }}>
<Typography
component="label"
variant="body2"
sx={{
display: "block",
mb: 0.5,
fontWeight: 600,
color: "text.secondary",
}}
>
{label}
{required && (
<Box component="span" sx={{ color: "error.main", ml: 0.25 }}>
*
</Box>
)}
</Typography>
{children}
{error ? (
<Typography variant="caption" color="error">
{error}
</Typography>
) : hint ? (
<Typography variant="caption" color="text.secondary">
{hint}
</Typography>
) : null}
</Box>
);
}
export function SwitchField({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<FormControlLabel
control={
<Switch
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
}
label={label}
/>
);
}
```
- [ ] **Step 2:** add `export { Field, SwitchField } from "./Field";` to `index.ts`. (`Button`, `Card`, `TextField` are already exported.)
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
- [ ] **Step 4: Commit** (msg `feat(mui): Field + SwitchField form controls`; + trailer).
---
## Task 4: Migrate `Vehicles.tsx` onto the kit
**Files:** Modify `src/admin/pages/Vehicles.tsx`.
- [ ] **Step 1: Rewrite the page** keeping ALL existing data logic (the `useQuery(vehicleListOptions)`, `saveVehicle`/`deleteVehicle`/`toggleVehicle` `useApiMutation`s, validation, handlers, permission gate) UNCHANGED, and swapping only the presentation:
- Permission gate + loading + empty state: keep behavior; render loading with the existing `admin-spinner` (still in base.css), or a MUI `CircularProgress` — keep `admin-spinner` for now.
- Page header: an MUI `Box` (flex, title `Typography variant="h4"` "Správa vozidel" + the kit `Button` "Přidat vozidlo" with the plus SVG). Keep the framer-motion entry animation if desired (optional).
- List: kit `Card` wrapping a `DataTable<Vehicle>` with columns: SPZ (`mono`), Název, Značka/Model (the existing `brand/model` join with `—` fallback), Počáteční km (`align right`, `mono`, `formatKm(...) + " km"`), Aktuální km (right/mono), Počet jízd (right/mono), Stav (a clickable MUI `Chip` colored `success`/`default` toggling `toggleActive`), Akce (edit + delete `IconButton`s with the existing SVGs). `rowInactive: (v) => !v.is_active`. `empty` = the existing empty-state block (can keep markup or render a simple MUI message + Button).
- Add/Edit modal: kit `Modal` (`isOpen={showModal}`, `onClose`, `onSubmit={handleSubmit}`, `title`, `loading={saving}`) containing `Field`-wrapped kit `TextField`s for SPZ (uppercase onChange)/Název/Značka/Model, a number `TextField` (type="number") for Počáteční km with the hint, and a `SwitchField` for `is_active`. Wire `errors`/`setErrors` exactly as today.
- Delete: kit `ConfirmDialog` (`isOpen={deleteConfirm.show}`, `onConfirm={handleDelete}`, `confirmVariant="danger"`, the existing message).
- Remove imports of the old `FormModal`/`ConfirmModal`/`FormField`; import from `../ui`.
- [ ] **Step 2: Verify**`npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (148 pass — no test touches this page, but confirm nothing broke).
- [ ] **Step 3: Commit** (msg `feat(mui): migrate Vozidla (Vehicles) page onto MUI kit`; + trailer).
- [ ] **Step 4: MANUAL (USER, dev server) — deferred:** list renders (dense, mono numerics, inactive dimmed); status chip toggles active/inactive; add/edit modal validates (SPZ/Název required) + saves; SPZ uppercases; delete confirm works; empty state; permission gate; dark + light; no console errors.
---
## Task 5: Phase-2 verification + final review
- [ ] **Step 1:** `npx tsc -b --noEmit` clean; `npm run build` (server+client); `npx vitest run` (148).
- [ ] **Step 2:** Final whole-Phase-2 code review (kit components + Vozidla migration; behavior parity vs the original Vehicles.tsx).
- [ ] **Step 3:** finishing-a-development-branch (merge to master after the user's visual check).
---
## Self-review notes (for the controller)
- **Spec coverage:** §5 kit (Modal, ConfirmDialog, DataTable, field controls — Tasks 13), §5.1 dense DataTable (Task 2), §8 Phase 2 Vozidla pilot (Task 4). Pages still import only from `ui/`.
- **Parity risks:** Vozidla validation (`Zadejte SPZ`/`Zadejte název`) + SPZ uppercasing + the three mutations' invalidate keys (`["vehicles","trips"]`) must be preserved verbatim. The status-toggle was a button styled as a badge → now a clickable `Chip`; behavior (toggle active) identical. `Modal`'s `onClose` is disabled while `loading` (matches FormModal's guard).
- **No CSS file to delete:** Vozidla uses shared classes (forms/tables/buttons/components.css), not a `vehicles.css`; those shared files remain for not-yet-migrated pages. This page simply stops using them.
- **Deferred:** all live visual checks (no dev server in implementer scope).