6.5 KiB
MUI Migration — Phase 4: Users (Uživatelé) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (
- [ ]) syntax.
Goal: Migrate src/admin/pages/Users.tsx onto the MUI kit, mirroring the already-migrated Vehicles.tsx, preserving ALL data logic. Presentation only changes.
Tech Stack: MUI v7 kit (src/admin/ui/): Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, SwitchField, Select (new), StatusChip (new). MUI Avatar/IconButton used directly (as Vehicles uses IconButton).
Reference: src/admin/pages/Vehicles.tsx is the canonical migrated page — copy its structure (Box + motion.div header, Button startIcon, Card + DataTable, Modal with Field/TextField, ConfirmDialog). The Users data layer already exists; do not change it.
Gates: npx tsc -b --noEmit, npm run build, npx vitest run (148). Visual check (dev server) is the user's.
Behavior to preserve VERBATIM (from the scout)
- Three mutations + invalidation:
USER_INVALIDATE = ["users","trips","attendance","leave-requests","leave","projects"].saveUser(PUT ifeditingUserelse POST),deleteUser(DELETE),toggleUser(PUT{id,is_active}). Keep allonSuccessmessages. - Self-edit AuthContext sync (critical): in
saveUser.onSuccess, wheneditingUser && currentUser && Number(editingUser.id)===Number(currentUser.id), callupdateUser({ username, email, fullName: \${first_name} ${last_name}`.trim() }). ThensetShowModal(false); setEditingUser(null);thenawait new Promise(r=>setTimeout(r,300))` (intentional delay — keep it) before the success toast. - Auth guard:
const { user: currentUser, updateUser, hasPermission } = useAuth();→if (!hasPermission("users.view")) return <Forbidden/>; - Self-protection: the delete action is hidden and the status toggle is disabled when
user.id === currentUser?.id. These reference the livecurrentUser— keep them in the column render closures. - Password: required only on create (
!editingUser); empty string on edit means "keep existing". - Role dropdown: options from
roleListOptions()query; default toroles[0]?.idon create.role_idisnumber|stringin form state. FormDatashape:{ username, email, password, first_name, last_name, role_id, is_active }.
Task 1: Migrate Users.tsx
Files: Modify src/admin/pages/Users.tsx (read it first; it's the source of truth for the logic above).
-
Step 1: Read
src/admin/pages/Users.tsxfully andsrc/admin/pages/Vehicles.tsx(the template). Keep ALL hooks, mutations, state, handlers, and the guard exactly; change only the rendered JSX. -
Step 2: Imports — from
../ui:Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, SwitchField, Select, StatusChip, type DataColumn. From@mui/material:Box, Typography, Avatar, IconButton. Keepmotion(framer-motion),useQuery,roleListOptions,useApiMutation,useAlert,useAuth,Forbidden. Remove old imports:FormModal,ConfirmModal,FormField. -
Step 3: Page shell — mirror Vehicles: outer
Box;motion.divheader (Typography variant="h4""Správa uživatelů" +Button startIcon={PlusIcon}"Přidat uživatele");motion.div+Cardwrapping theDataTable. Loading state keeps theadmin-spinnermarkup. -
Step 4: DataTable columns (
DataColumn<User>[], defined inside the component so they close overcurrentUser/handlers):- Uživatel: composite render — MUI
Avatar(initial offull_name/username,bgcolor:"primary.main") + name (Typography) +@username(Typography variant="caption" color="text.secondary"). - Email:
render: (u) => u.email. - Role:
StatusChipcolored by role — map"admin"→color="warning", else"default"; label =roleDisplay/role name. - Stav: clickable
StatusChip(color={u.is_active?"success":"default"},onClick→toggleActive(u)), but non-clickable / no onClick whenu.id===currentUser?.id(self-protection). - Akce:
IconButtonedit (always);IconButtondelete (color="error") only whenu.id !== currentUser?.id. Use the same edit/delete SVGs as Vehicles.
- Uživatel: composite render — MUI
-
Step 5: Add/Edit
Modal—Field+ kitTextFieldfor username/email/first_name/last_name; passwordTextField type="password"(label notes optional on edit);Select(inside aFieldlabel="Role") forrole_idwithoptionsfrom the roles query (value: String(r.id), label: r.display_name||r.name) — noteSelectis string-based, so store/comparerole_idas string orString(...)at the boundary;SwitchFieldforis_active. Add a localerrorsstate + validation inhandleSubmit(required: username, email, first_name, last_name; password required when!editingUser) mirroring Vehicles, beforesaveUser.mutateAsync. -
Step 6: Delete
ConfirmDialog— drop-in replacement forConfirmModal(title "Smazat uživatele", message with the user's name,confirmVariant="danger"). -
Step 7: Verify
npx tsc -b --noEmitclean;npm run buildOK;npx vitest run(148). Drop alladmin-*class names. -
Step 8: Commit (
feat(mui): migrate Users (Uživatelé) page onto MUI kit; +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>trailer). -
Step 9: MANUAL (USER, dev server) — deferred: list renders (avatar/role/status); status toggle works + is disabled for self; delete hidden for self; add (password required) + edit (password optional, role Select) save; self-edit updates the sidebar name; validation; dark+light; no console errors.
Task 2: Review + finish
- Spec-compliance review (esp. the self-edit AuthContext sync, self-protection, password-on-create-only, role Select string boundary, USER_INVALIDATE) then code-quality review.
- finishing-a-development-branch after the user's visual check.
Self-review notes
- The role
Selectis string-based (per the Phase 3 review fix); convertrole_idat the boundary (String(r.id)for options/value; the server accepts the string or coerce on submit if the API needs a number — check how the existing code sends it and match). - Self-protection guards and the self-edit
updateUsersync are the highest-risk items — the spec reviewer must confirm both survive the column-render refactor. - No
users.cssexists; Users uses shared classes — nothing to delete, it just stops using them.