Files
app/docs/superpowers/plans/2026-06-06-mui-migration-phase-1-appshell.md
2026-06-06 19:57:53 +02:00

358 lines
19 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 1: AppShell — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Replace the admin chrome (`AdminLayout` + `Sidebar`) with a MUI `<AppShell>` in the Soft-SaaS look — a `Drawer`-based sidebar (permanent on desktop, temporary on mobile) with a white active "pill", a minimal topbar (hamburger + theme toggle), reusing the existing permission-filtered nav data and preserving all behavior (auth/2FA guards, logout scale+blur animation, ShortcutsHelp).
**Architecture:** The valuable, well-tested parts are **reused, not rewritten**: the `menuSections` nav data (icons, permissions, active-match rules) and the active/permission logic move into `navData.tsx`. `SidebarNav` renders that data with MUI `List`. `AppShell` provides the responsive `Drawer` + topbar + auth guards + the framer-motion logout wrapper, and renders `<Outlet/>`. `AdminLayout`/`Sidebar`/`layout.css` are deleted when `AppShell` lands.
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (kept for logout), react-router v6, TypeScript strict.
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 AppShell, §7 aesthetic, §8 Phase 1, §9 animation).
**Authoritative gates:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file), `npm run build:client`, `npx vitest run`. Live visual verification (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
**Behavior parity checklist (must all be preserved from `AdminLayout.tsx` + `Sidebar.tsx`):**
- Auth: `loading` → spinner; `!isAuthenticated``<Navigate to="/login" replace/>`; 2FA-setup redirect (`user.require2FA && !user.totpEnabled && pathname !== "/"``/`).
- Logout: `handleLogout` sets a logout alert (`setLogoutAlert()`), closes the mobile drawer, and calls `logout()` after 400ms; the layout plays the framer-motion `{scale:1.5, opacity:0, filter:"blur(12px)"}` exit over 0.4s.
- Sidebar: permission-filtered sections/items (empty sections hidden); custom active matching (`matchPrefix`/`matchAlso`/`matchExclude`/`end`); company logo with light/dark variant + `onError` fallback to `/images/logo-{dark,light}.png`; user chip (avatar initial + name + `roleDisplay`); logout button; mobile open/close.
- `ShortcutsHelp` still rendered; `<Outlet/>` for page content.
---
## File Structure
| File | Responsibility | Action |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
| `src/admin/ui/navData.tsx` | `MenuItem`/`MenuSection` types, the `menuSections` array (moved verbatim from Sidebar), pure helpers `isItemActive(item, pathname)` + `hasItemPermission(item, hasPermission)` | Create (move) |
| `src/admin/ui/SidebarNav.tsx` | Renders `navData` with MUI `List`: logo header, sections, white-pill active items, user+logout footer | Create |
| `src/admin/ui/AppShell.tsx` | Responsive `Drawer` + topbar + auth guards + logout animation + `<Outlet/>` + `ShortcutsHelp` | Create |
| `src/admin/components/Sidebar.tsx` | (interim) import `menuSections`/helpers from `navData` instead of defining inline | Modify, then Delete |
| `src/admin/components/AdminLayout.tsx` | Replaced by `AppShell` | Delete |
| `src/admin/AdminApp.tsx` | Route uses `<AppShell/>` instead of `<AdminLayout/>`; remove `import "./layout.css"` | Modify |
| `src/admin/layout.css` | Chrome styling now in MUI | Delete |
Import paths from `src/admin/ui/` (same depth as `components/`): `../context/AuthContext`, `../../context/ThemeContext`, `../utils/api`, `../hooks/...`, `../theme`.
---
## Task 1: Extract nav data into `navData.tsx`
**Files:** Create `src/admin/ui/navData.tsx`; Modify `src/admin/components/Sidebar.tsx`.
- [ ] **Step 1: Create `src/admin/ui/navData.tsx`.** MOVE (cut) the following from `src/admin/components/Sidebar.tsx` **verbatim**: the `MenuItem` and `MenuSection` interfaces and the entire `menuSections` array (all 6 sections with their inline SVG icons — do not retype, move them unchanged). Then add the two pure helpers (extracted from Sidebar's inline closures), and export everything:
```tsx
import { type ReactNode } from "react";
export interface MenuItem {
path: string;
label: string;
end?: boolean;
permission?: string | string[];
matchPrefix?: string;
matchAlso?: string[];
matchExclude?: string[];
icon: ReactNode;
}
export interface MenuSection {
label: string;
items: MenuItem[];
}
export const menuSections: MenuSection[] = [
/* ... the 6 sections, MOVED verbatim from Sidebar.tsx ... */
];
/** Active-state matching (mirrors the original Sidebar logic, incl. matchAlso). */
export function isItemActive(item: MenuItem, pathname: string): boolean {
if (item.matchPrefix) {
let active = pathname.startsWith(item.matchPrefix);
if (active && item.matchExclude) {
active = !item.matchExclude.some((ex) => pathname.startsWith(ex));
}
if (!active && item.matchAlso) {
active = item.matchAlso.some((p) => pathname.startsWith(p));
}
return active;
}
if (item.matchAlso && item.matchAlso.some((p) => pathname.startsWith(p))) {
return true;
}
if (item.end) return pathname === item.path;
return pathname.startsWith(item.path);
}
/** Permission check: true if no permission, or any listed permission is held. */
export function hasItemPermission(
item: MenuItem,
hasPermission: (p: string) => boolean,
): boolean {
if (!item.permission) return true;
if (Array.isArray(item.permission)) {
return item.permission.some((p) => hasPermission(p));
}
return hasPermission(item.permission);
}
```
> Note: the original Sidebar computed `matchAlso` separately in the `NavLink` className callback. The consolidated `isItemActive` above folds `matchAlso` in so a single function is the source of truth. Preserve every section/item exactly.
- [ ] **Step 2: Update `Sidebar.tsx` to import from `navData`** (interim — Sidebar is deleted in Task 4). Remove the inline `MenuItem`/`MenuSection`/`menuSections` definitions and the inline `isItemActive`/`hasItemPermission`; import them from `../ui/navData`. In the `NavLink` `className` callback, replace the inline active+matchAlso logic with `isItemActive(item, location.pathname)`.
- [ ] **Step 3: Verify**`npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (147 pass — no behavior change).
- [ ] **Step 4: Commit** `src/admin/ui/navData.tsx` + `src/admin/components/Sidebar.tsx` (msg: `refactor(mui): extract sidebar nav data + active/permission helpers to navData`; end with the `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
---
## Task 2: `SidebarNav.tsx` (MUI drawer content)
**Files:** Create `src/admin/ui/SidebarNav.tsx`.
Renders the permission-filtered nav with MUI. Props: `{ onNavigate?: () => void; onLogout: () => void }` (`onNavigate` closes the mobile drawer; `onLogout` is owned by `AppShell`).
- [ ] **Step 1: Create the component.** Requirements (write complete MUI code):
- `const { user, hasPermission } = useAuth();` (`../context/AuthContext`), `const { theme } = useTheme();` aliased from `../../context/ThemeContext`, `const location = useLocation();`.
- `visibleSections` = `menuSections` mapped to filter items by `hasItemPermission(item, hasPermission)`, dropping empty sections.
- Root: a flex column `Box`, `height: "100%"`, padding ~`1.5`.
- **Logo header:** `Box component="img"` with `src` = `/api/admin/company-settings/logo?variant=${theme === "dark" ? "dark" : "light"}` and `onError` falling back to `/images/logo-${theme}.png`; `sx={{ maxHeight: 40, maxWidth: "100%", objectFit: "contain" }}`.
- **Nav:** scrollable `Box` (`flex: 1, overflowY: "auto"`). For each section: a `Typography variant="caption"` subheader (uppercase, `letterSpacing: ".06em"`, `fontWeight: 700`, `color: "text.secondary"`, `fontSize: ".64rem"`, `px: 1.5`), then a `List disablePadding` of items. Each item:
```tsx
<ListItemButton
component={NavLink}
to={item.path}
end={item.end}
onClick={onNavigate}
selected={isItemActive(item, location.pathname)}
sx={{
borderRadius: 2,
mb: 0.25,
py: 0.6,
color: "text.secondary",
"& svg": { width: 18, height: 18 },
"&.Mui-selected, &.Mui-selected:hover": {
bgcolor: "background.paper",
color: "primary.main",
fontWeight: 700,
boxShadow: "0 2px 8px rgba(20,20,40,0.07)",
},
}}
>
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{ fontSize: ".82rem", fontWeight: "inherit" }}
/>
</ListItemButton>
```
(Use MUI's `selected` prop for the active "pill" — do NOT rely on NavLink's default active class, because the active rule is custom.)
- **Footer:** top-bordered `Box` (`borderColor: "divider"`) with a user chip (MUI `Avatar` `bgcolor: "primary.main"` showing `user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U"`, plus name + `roleDisplay` `Typography` with `noWrap`), and a logout `Button` (`color="inherit"`, `fullWidth`, the existing logout SVG as `startIcon`, label "Odhlásit se", `onClick={onLogout}`).
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds. (Component not yet mounted; visual check is later.)
- [ ] **Step 3: Commit** `src/admin/ui/SidebarNav.tsx` (msg: `feat(mui): SidebarNav — MUI List nav with white-pill active state`; + trailer).
---
## Task 3: `AppShell.tsx` (responsive layout + guards + animation)
**Files:** Create `src/admin/ui/AppShell.tsx`.
- [ ] **Step 1: Create the component** with this exact structure (fill the guard bodies from the parity checklist):
```tsx
import { useState, useCallback } from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
import { useAuth } from "../context/AuthContext";
import { useTheme as useAppTheme } from "../../context/ThemeContext";
import { setLogoutAlert } from "../utils/api";
import SidebarNav from "./SidebarNav";
import ShortcutsHelp from "../components/ShortcutsHelp";
const DRAWER_WIDTH = 248;
export default function AppShell() {
const { isAuthenticated, loading, user, logout } = useAuth();
const { theme, toggleTheme } = useAppTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const location = useLocation();
const handleLogout = useCallback(() => {
setLoggingOut(true);
setMobileOpen(false);
setLogoutAlert();
setTimeout(() => logout(), 400);
}, [logout]);
if (loading) {
return (
<ScopedCssBaseline>
<Box
sx={{
display: "flex",
minHeight: "100vh",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
}}
>
<div className="admin-spinner" />
</Box>
</ScopedCssBaseline>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
return <Navigate to="/" replace />;
}
return (
<ScopedCssBaseline>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={
loggingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" }
}
transition={{
duration: loggingOut ? 0.4 : 0.25,
ease: [0.4, 0, 0.2, 1],
}}
style={{ minHeight: "100vh" }}
>
<Box
sx={{
display: "flex",
minHeight: "100vh",
bgcolor: "background.default",
}}
>
{/* Desktop permanent drawer */}
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
width: DRAWER_WIDTH,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: DRAWER_WIDTH,
boxSizing: "border-box",
border: 0,
bgcolor: "background.default",
},
}}
>
<SidebarNav onLogout={handleLogout} />
</Drawer>
{/* Mobile temporary drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
sx={{
display: { xs: "block", md: "none" },
"& .MuiDrawer-paper": {
width: DRAWER_WIDTH,
boxSizing: "border-box",
},
}}
>
<SidebarNav
onNavigate={() => setMobileOpen(false)}
onLogout={handleLogout}
/>
</Drawer>
{/* Main column */}
<Box
sx={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
}}
>
<Box
component="header"
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 2,
py: 1.5,
minHeight: 64,
}}
>
<IconButton
onClick={() => setMobileOpen(true)}
aria-label="Otevřít menu"
sx={{ display: { md: "none" } }}
>
{/* hamburger SVG (3 lines), 24x24, stroke currentColor */}
</IconButton>
<Box sx={{ flex: 1 }} />
<IconButton
onClick={toggleTheme}
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
{/* sun SVG when theme==='dark' is off / moon — keep the two SVGs from AdminLayout, render the one for the current theme */}
</IconButton>
</Box>
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
<Outlet />
</Box>
</Box>
</Box>
<ShortcutsHelp />
</motion.div>
</ScopedCssBaseline>
);
}
```
Fill the two `IconButton` SVGs from the existing `AdminLayout.tsx` (the hamburger 3-line icon; the sun/moon — render the moon when `theme === "dark"`, the sun when light, matching the current toggle's meaning). Keep the `admin-spinner` class for the loading state (it's defined in a CSS file that still exists in Phase 1).
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
- [ ] **Step 3: Commit** `src/admin/ui/AppShell.tsx` (msg: `feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation`; + trailer).
---
## Task 4: Wire `AppShell`, delete legacy chrome
**Files:** Modify `src/admin/AdminApp.tsx`; Delete `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, `src/admin/layout.css`.
- [ ] **Step 1: Swap the route.** In `src/admin/AdminApp.tsx`: change the import `import AdminLayout from "./components/AdminLayout";` → `import AppShell from "./ui/AppShell";`, and the route `<Route element={<AdminLayout />}>` → `<Route element={<AppShell />}>`. Remove the line `import "./layout.css";`.
- [ ] **Step 2: Delete** `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, and `src/admin/layout.css`. Then grep the repo for any remaining import of those modules and fix/remove (there should be none beyond AdminApp).
- [ ] **Step 3: Verify (full gates)** — `npx tsc -b --noEmit` clean; `npm run build` (server+client) succeeds and the prod bundle has no broken refs; `npx vitest run` (147 pass). Confirm `layout.css` is no longer imported anywhere.
- [ ] **Step 4: Commit** (msg: `feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css`; + trailer).
- [ ] **Step 5: MANUAL verification (USER, dev server) — deferred:** sidebar shows permission-filtered sections; active item is the white pill; navigation works + closes the mobile drawer; theme toggle works; logout plays the scale+blur animation; logo light/dark + fallback; responsive (permanent drawer ≥ md, hamburger + temporary drawer < md); no console errors; dark + light both correct.
---
## Self-review notes (for the controller)
- **Spec coverage:** Phase 1 (spec §8) — AppShell chrome via MUI Drawer + topbar (Tasks 24), reusing nav data/permissions (Task 1), preserving the logout animation + guards (Task 3), deleting `layout.css` (Task 4). Login stays OUT of this (it's a sibling route — Phase 3), untouched.
- **Type consistency:** `navData` exports `MenuItem`/`MenuSection`/`menuSections`/`isItemActive`/`hasItemPermission`; `SidebarNav` and (interim) `Sidebar` both consume them. `AppShell` props to `SidebarNav`: `onLogout` (required), `onNavigate` (optional).
- **Parity risks to watch in review:** the consolidated `isItemActive` must reproduce the original `matchAlso` behavior (the original applied `matchAlso` only in the NavLink callback) — the spec reviewer must diff active states for `/attendance/admin` (matchAlso `/attendance/create`,`/attendance/location`) and `/offers` (matchExclude). The `selected` prop must not double-apply with NavLink's own active class. `ScopedCssBaseline` wraps the shell so MUI reset applies; unmigrated page content inside `<Outlet/>` still uses its own CSS.
- **Deferred:** all live visual checks (no dev server in implementer scope).