[CLAUDE] FE-Admin+Domain: Chunk C — MenuVisibilityPage + menu key + seed
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
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:
@ -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 />} />
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
247
fe-admin/src/pages/system/MenuVisibilityPage.tsx
Normal file
247
fe-admin/src/pages/system/MenuVisibilityPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
];
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user