16 KiB
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— MUIDialogpreserving the existingFormModalAPI:
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— MUIDialogpreserving theConfirmModalAPI:
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";andexport { default as ConfirmDialog } from "./ConfirmDialog";tosrc/admin/ui/index.ts. -
Step 4: Verify
npx tsc -b --noEmitclean;npm run build:clientsucceeds. -
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:
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";toindex.ts. - Step 3: Verify
npx tsc -b --noEmitclean;npm run build:clientsucceeds. - 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.tsxwith a labeled wrapper and a boolean switch:
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";toindex.ts. (Button,Card,TextFieldare already exported.) - Step 3: Verify
npx tsc -b --noEmitclean;npm run build:clientsucceeds. - 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/toggleVehicleuseApiMutations, 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 MUICircularProgress— keepadmin-spinnerfor now. - Page header: an MUI
Box(flex, titleTypography variant="h4""Správa vozidel" + the kitButton"Přidat vozidlo" with the plus SVG). Keep the framer-motion entry animation if desired (optional). - List: kit
Cardwrapping aDataTable<Vehicle>with columns: SPZ (mono), Název, Značka/Model (the existingbrand/modeljoin 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 MUIChipcoloredsuccess/defaulttogglingtoggleActive), Akce (edit + deleteIconButtons 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}) containingField-wrapped kitTextFields for SPZ (uppercase onChange)/Název/Značka/Model, a numberTextField(type="number") for Počáteční km with the hint, and aSwitchFieldforis_active. Wireerrors/setErrorsexactly 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.
- Permission gate + loading + empty state: keep behavior; render loading with the existing
-
Step 2: Verify —
npx tsc -b --noEmitclean;npm run build:clientsucceeds;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 --noEmitclean;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 1–3), §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 clickableChip; behavior (toggle active) identical.Modal'sonCloseis disabled whileloading(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).