[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 = {

View File

@ -16,6 +16,7 @@ public static class MenuKeys
public const string Users = "Users";
public const string Roles = "Roles";
public const string Permissions = "Permissions";
public const string MenuVisibility = "MenuVisibility"; // [Mig 27] admin Ẩn/Hiện + Đổi tên menu fe-user
public const string Workflows = "Workflows";
// 4 master catalogs cho Details add form (autocomplete) — admin CRUD
@ -95,7 +96,7 @@ public static class MenuKeys
Contracts, Forms, Reports,
PurchaseEvaluations,
Budgets, BudgetList, BudgetCreate, BudgetPending,
System, Users, Roles, Permissions, Workflows, PeWorkflows,
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -1358,7 +1358,8 @@ public static class DbInitializer
(MenuKeys.Users, "Người dùng", MenuKeys.System, 91, "User"),
(MenuKeys.Roles, "Vai trò", MenuKeys.System, 92, "Shield"),
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
(MenuKeys.Workflows, "Quy trình HĐ", MenuKeys.System, 94, "GitBranch"),
(MenuKeys.MenuVisibility, "Menu eOffice", MenuKeys.System, 94, "Eye"),
(MenuKeys.Workflows, "Quy trình HĐ", MenuKeys.System, 95, "GitBranch"),
// Module Duyệt NCC (tiền-HĐ)
(MenuKeys.PurchaseEvaluations, "Quy trình chọn Thầu phụ - NCC", null, 25, "ClipboardCheck"),
(MenuKeys.PeWorkflows, "Quy trình Duyệt NCC", MenuKeys.System, 95, "GitCompareArrows"),