[CLAUDE] FE-Admin+Domain: Chunk C — MenuVisibilityPage + menu key + seed
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

Session 20 turn 7 Chunk C. FE Admin page quản lý Ẩn/Hiện + Đổi tên menu
cho fe-user (eOffice). Admin sidebar fe-admin LUÔN dùng Tên gốc — page này
KHÔNG đụng admin navigation (user Q2=b).

Domain MenuKeys.cs:
  +const MenuVisibility = "MenuVisibility"
  All[] thêm MenuVisibility (giữa Permissions + Workflows)

DbInitializer SeedMenuTreeAsync:
  +leaf (MenuVisibility, "Menu eOffice", System, 94, "Eye")
  Workflows shift Order 94 → 95
  Idempotent — chỉ INSERT nếu chưa có trong DB
  Manual seed Mig 27 LocalDB Dev: INSERT MenuItems + Permissions cho Admin role

FE Admin:
  - types/menu.ts: MenuItem/MenuNode +isVisible bool +displayLabel string|null
  - lib/menuKeys.ts: +MenuVisibility const
  - components/Layout.tsx resolver +MenuVisibility → /system/menu-visibility
  - App.tsx +Route + import MenuVisibilityPage

NEW pages/system/MenuVisibilityPage.tsx (~210 LOC):
  - PageHeader + 4 StatCard (Tổng / Hiển thị / Đã ẩn / Đã đổi tên)
  - Search input (key | label | displayLabel)
  - Table: Key (mono + parentKey ↳) | Tên gốc | Input "Tên hiển thị" inline
    (placeholder "Mặc định: ...") | Toggle Hiển thị/Ẩn (emerald/amber) |
    Lưu (khi dirty) / Khôi phục (khi đã custom)
  - PATCH /menus/{key} body { isVisible, displayLabel } — trim whitespace,
    empty string → null
  - onSuccess: invalidate ['menus', 'all'] + ['my-menu'] + clear draft entry
  - "Khôi phục mặc định" button: PATCH isVisible=true, displayLabel=null
  - Footer hint: nhắc admin sidebar luôn dùng Tên gốc, đổi tên áp eOffice

Verify:
- npm run build × fe-admin pass

Pending Chunk D: FE Layout fe-user filter !isVisible + render displayLabel
Pending Chunk E: Docs S20 turn 7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-11 11:37:47 +07:00
parent ef394f8067
commit 059bfcbe38
7 changed files with 259 additions and 2 deletions

View File

@ -10,6 +10,7 @@ import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
import { CatalogsPage } from '@/pages/master/CatalogsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { MenuVisibilityPage } from '@/pages/system/MenuVisibilityPage'
import { RolesPage } from '@/pages/system/RolesPage'
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
import { PeWorkflowsPage } from '@/pages/system/PeWorkflowsPage'
@ -48,6 +49,7 @@ function App() {
<Route path="/system/users" element={<UsersPage />} />
<Route path="/system/roles" element={<RolesPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/system/menu-visibility" element={<MenuVisibilityPage />} />
<Route path="/system/workflows" element={<WorkflowsPage />} />
<Route path="/system/workflows/:typeCode" element={<WorkflowsPage />} />
<Route path="/system/pe-workflows" element={<PeWorkflowsPage />} />

View File

@ -39,6 +39,7 @@ function resolvePath(key: string): string | null {
Users: '/system/users',
Roles: '/system/roles',
Permissions: '/system/permissions',
MenuVisibility: '/system/menu-visibility',
Workflows: '/system/workflows',
CatalogUnits: '/master/catalogs/units',
CatalogMaterials: '/master/catalogs/materials',

View File

@ -12,6 +12,7 @@ export const MenuKeys = {
Users: 'Users',
Roles: 'Roles',
Permissions: 'Permissions',
MenuVisibility: 'MenuVisibility',
PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows',
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08)

View File

@ -0,0 +1,247 @@
// Admin page (Session 20 Mig 27): Ẩn/Hiện + Đổi tên hiển thị menu cho fe-user
// (eOffice). Admin sidebar fe-admin LUÔN render Label gốc — page này KHÔNG đụng
// admin nav. User Q2=b chốt "edit hiển thị bên ngoài, chỉ của eOffice thôi".
//
// Pattern table inline edit per-row + Save button khi dirty. Reuse api `/menus`
// list + PATCH `/menus/{key}` từ MenusController.
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Eye, EyeOff, RotateCcw, Save, Search } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { MenuItem } from '@/types/menu'
type DraftMap = Record<string, { isVisible: boolean; displayLabel: string }>
export function MenuVisibilityPage() {
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [draft, setDraft] = useState<DraftMap>({})
const menus = useQuery({
queryKey: ['menus', 'all'],
queryFn: async () => (await api.get<MenuItem[]>('/menus')).data,
})
const update = useMutation({
mutationFn: async (p: { key: string; isVisible: boolean; displayLabel: string | null }) => {
await api.patch(`/menus/${encodeURIComponent(p.key)}`, {
isVisible: p.isVisible,
displayLabel: p.displayLabel,
})
},
onSuccess: (_, vars) => {
toast.success(`Đã lưu menu "${vars.key}".`)
qc.invalidateQueries({ queryKey: ['menus', 'all'] })
qc.invalidateQueries({ queryKey: ['my-menu'] })
setDraft(prev => {
const next = { ...prev }
delete next[vars.key]
return next
})
},
onError: err => toast.error(getErrorMessage(err)),
})
const filteredMenus = useMemo(() => {
const all = menus.data ?? []
if (!search.trim()) return all
const q = search.toLowerCase()
return all.filter(m =>
m.label.toLowerCase().includes(q)
|| m.key.toLowerCase().includes(q)
|| (m.displayLabel?.toLowerCase().includes(q) ?? false),
)
}, [menus.data, search])
const stats = useMemo(() => {
const total = menus.data?.length ?? 0
const hidden = (menus.data ?? []).filter(m => !m.isVisible).length
const renamed = (menus.data ?? []).filter(m => m.displayLabel != null && m.displayLabel !== m.label).length
return { total, hidden, visible: total - hidden, renamed }
}, [menus.data])
// Current effective draft (merge BE data + local edits)
function getCurrent(m: MenuItem) {
const d = draft[m.key]
return {
isVisible: d?.isVisible ?? m.isVisible,
displayLabel: d?.displayLabel ?? (m.displayLabel ?? ''),
}
}
function isDirty(m: MenuItem) {
const d = draft[m.key]
if (!d) return false
return d.isVisible !== m.isVisible || d.displayLabel !== (m.displayLabel ?? '')
}
function setDraftField(key: string, patch: Partial<DraftMap[string]>) {
setDraft(prev => {
const current = prev[key] ?? {
isVisible: menus.data?.find(m => m.key === key)?.isVisible ?? true,
displayLabel: menus.data?.find(m => m.key === key)?.displayLabel ?? '',
}
return { ...prev, [key]: { ...current, ...patch } }
})
}
function save(m: MenuItem) {
const cur = getCurrent(m)
update.mutate({
key: m.key,
isVisible: cur.isVisible,
displayLabel: cur.displayLabel.trim() === '' ? null : cur.displayLabel.trim(),
})
}
function resetDefault(m: MenuItem) {
// Khôi phục: isVisible=true + displayLabel=null (xóa override)
update.mutate({ key: m.key, isVisible: true, displayLabel: null })
}
return (
<div className="space-y-4">
<PageHeader
title="Menu eOffice — Ẩn/Hiện + Đổi tên"
description='Quản lý hiển thị menu cho fe-user (eOffice). Admin sidebar luôn dùng Tên gốc, KHÔNG bị ảnh hưởng.'
/>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<StatCard title="Tổng menu" value={stats.total} tone="slate" />
<StatCard title="Hiển thị (eOffice)" value={stats.visible} tone="emerald" />
<StatCard title="Đã ẩn" value={stats.hidden} tone="amber" />
<StatCard title="Đã đổi tên" value={stats.renamed} tone="brand" />
</div>
<div className="flex items-center gap-2">
<div className="relative max-w-sm flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Tìm theo key / label / tên hiển thị..."
className="pl-9"
/>
</div>
<span className="text-xs text-slate-500">{filteredMenus.length}/{stats.total}</span>
</div>
{menus.isLoading ? (
<div className="rounded border border-slate-200 bg-white p-6 text-center text-sm text-slate-500">
Đang tải...
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="min-w-full text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="border-b border-slate-200 px-3 py-2 text-left">Key</th>
<th className="border-b border-slate-200 px-3 py-2 text-left">Tên gốc</th>
<th className="border-b border-slate-200 px-3 py-2 text-left">Tên hiển thị cho eOffice</th>
<th className="border-b border-slate-200 px-3 py-2 text-center">Hiển thị eOffice</th>
<th className="border-b border-slate-200 px-3 py-2 text-right">Hành đng</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredMenus.map(m => {
const cur = getCurrent(m)
const dirty = isDirty(m)
const customLabel = cur.displayLabel.trim() !== ''
const hidden = !cur.isVisible
return (
<tr key={m.key} className={cn(hidden && 'bg-amber-50/40')}>
<td className="px-3 py-2">
<div className="font-mono text-[11px] text-slate-700">{m.key}</div>
{m.parentKey && (
<div className="text-[10px] text-slate-400"> {m.parentKey}</div>
)}
</td>
<td className="px-3 py-2 text-slate-700">{m.label}</td>
<td className="px-3 py-2">
<Input
value={cur.displayLabel}
onChange={e => setDraftField(m.key, { displayLabel: e.target.value })}
placeholder={`Mặc định: ${m.label}`}
maxLength={200}
className={cn(
'h-8 text-xs',
customLabel && 'border-brand-300 bg-brand-50/40',
)}
/>
</td>
<td className="px-3 py-2 text-center">
<button
type="button"
onClick={() => setDraftField(m.key, { isVisible: !cur.isVisible })}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-medium transition',
cur.isVisible
? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-amber-300 bg-amber-100 text-amber-700 hover:bg-amber-200',
)}
title={cur.isVisible ? 'Click để ẩn khỏi eOffice' : 'Click để hiện trên eOffice'}
>
{cur.isVisible ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
{cur.isVisible ? 'Hiển thị' : 'Đã ẩn'}
</button>
</td>
<td className="px-3 py-2 text-right">
<div className="inline-flex items-center gap-1">
{dirty && (
<Button
onClick={() => save(m)}
disabled={update.isPending}
className="h-7 gap-1 px-2 text-[11px]"
>
<Save className="h-3 w-3" /> Lưu
</Button>
)}
{(hidden || customLabel) && !dirty && (
<button
type="button"
onClick={() => resetDefault(m)}
disabled={update.isPending}
className="inline-flex items-center gap-1 rounded border border-slate-200 px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 hover:text-slate-700"
title="Khôi phục mặc định: hiển thị + dùng Tên gốc"
>
<RotateCcw className="h-3 w-3" /> Khôi phục
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<p className="text-[11px] text-slate-500">
💡 Thay đi áp dụng cho eOffice (fe-user) user sẽ thấy menu n/Hiện hoặc tên đi sau khi reload.
Sidebar trang Admin này luôn dùng <strong>Tên gốc</strong> đ tránh nhầm lẫn khi quản trị.
</p>
</div>
)
}
function StatCard({ title, value, tone }: { title: string; value: number; tone: 'slate' | 'emerald' | 'amber' | 'brand' }) {
const toneClass = {
slate: 'border-slate-200 bg-slate-50 text-slate-700',
emerald: 'border-emerald-200 bg-emerald-50 text-emerald-700',
amber: 'border-amber-200 bg-amber-50 text-amber-700',
brand: 'border-brand-200 bg-brand-50 text-brand-700',
}[tone]
return (
<div className={cn('rounded-lg border px-4 py-3', toneClass)}>
<div className="text-[11px] uppercase tracking-wide opacity-70">{title}</div>
<div className="mt-0.5 text-2xl font-semibold">{value}</div>
</div>
)
}

View File

@ -8,6 +8,8 @@ export type MenuNode = {
canCreate: boolean
canUpdate: boolean
canDelete: boolean
isVisible: boolean
displayLabel: string | null
children: MenuNode[]
}
@ -17,6 +19,8 @@ export type MenuItem = {
parentKey: string | null
order: number
icon: string | null
isVisible: boolean
displayLabel: string | null
}
export type Role = {