diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index cf337a9..d5e700c 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index 7aae9f6..c92f0a2 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -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', diff --git a/fe-admin/src/lib/menuKeys.ts b/fe-admin/src/lib/menuKeys.ts index e1844e6..7349bd4 100644 --- a/fe-admin/src/lib/menuKeys.ts +++ b/fe-admin/src/lib/menuKeys.ts @@ -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) diff --git a/fe-admin/src/pages/system/MenuVisibilityPage.tsx b/fe-admin/src/pages/system/MenuVisibilityPage.tsx new file mode 100644 index 0000000..07ea0d8 --- /dev/null +++ b/fe-admin/src/pages/system/MenuVisibilityPage.tsx @@ -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 + +export function MenuVisibilityPage() { + const qc = useQueryClient() + const [search, setSearch] = useState('') + const [draft, setDraft] = useState({}) + + const menus = useQuery({ + queryKey: ['menus', 'all'], + queryFn: async () => (await api.get('/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) { + 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 ( +
+ + +
+ + + + +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Tìm theo key / label / tên hiển thị..." + className="pl-9" + /> +
+ {filteredMenus.length}/{stats.total} +
+ + {menus.isLoading ? ( +
+ Đang tải... +
+ ) : ( +
+ + + + + + + + + + + + {filteredMenus.map(m => { + const cur = getCurrent(m) + const dirty = isDirty(m) + const customLabel = cur.displayLabel.trim() !== '' + const hidden = !cur.isVisible + return ( + + + + + + + + ) + })} + +
KeyTên gốcTên hiển thị cho eOfficeHiển thị eOfficeHành động
+
{m.key}
+ {m.parentKey && ( +
↳ {m.parentKey}
+ )} +
{m.label} + 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', + )} + /> + + + +
+ {dirty && ( + + )} + {(hidden || customLabel) && !dirty && ( + + )} +
+
+
+ )} + +

+ 💡 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 Tên gốc để tránh nhầm lẫn khi quản trị. +

+
+ ) +} + +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 ( +
+
{title}
+
{value}
+
+ ) +} diff --git a/fe-admin/src/types/menu.ts b/fe-admin/src/types/menu.ts index 2808753..21675fb 100644 --- a/fe-admin/src/types/menu.ts +++ b/fe-admin/src/types/menu.ts @@ -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 = { diff --git a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs index c3d4a43..f92ed4d 100644 --- a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs +++ b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs @@ -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 ]; diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index b2e33d5..6c1cad2 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -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"),