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

16 KiB
Raw Permalink Blame History

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:
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:
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:
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:
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 useApiMutations, 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 IconButtons 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 TextFields 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: Verifynpx 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).