From 48e91fe7ca40099270d182b4544034d7b16b3ea0 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 22:25:00 +0700 Subject: [PATCH] [CLAUDE] Domain+Infra+App+FE-Admin: per-ContractType nested sidebar menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User request: mỗi loại HĐ có menu riêng với 3 action Danh sách / Thao tác / Duyệt. Sidebar giờ 3-level under "Hợp đồng": Hợp đồng (group, expandable) ├── HĐ Thầu phụ (sub-group) │ ├── Danh sách → /contracts?type=1 │ ├── Thao tác → /contracts/new?type=1 │ └── Duyệt → /contracts?type=1&pendingMe=1 ├── HĐ Giao khoán (sub-group) ├── HĐ NCC / Dịch vụ / Mua bán / Nguyên tắc NCC / Nguyên tắc DV └── ... (7 types × 4 = 28 new menu items) BE: - MenuKeys.cs: ContractTypeCodes array + helpers ContractTypeGroup/ List/Create/Pending → key format Ct_[_] - DbInitializer.SeedMenuTreeAsync: loop seeds 28 entries under Contracts - GetMyMenuTreeQuery.BuildChildren: descendants of `Contracts` inherit parent permission (avoid adding 28 rows to Permissions table per role) FE: - Layout.tsx recursive: MenuNodeRenderer dispatches group vs leaf by depth; nested groups collapsed by default (top-level expanded). Deeper levels get smaller padding/text + left border guide. - Pattern-based resolvePath: Ct__ → URL with query. - Contract type code → int map (matches Domain ContractType enum). - ContractsListPage reads ?type + ?pendingMe, filters client-side. Header title + description reflect active filter. "← Tất cả loại" quick-reset button. - ContractCreatePage new cho admin (copy từ fe-user), pre-select type từ ?type URL param. - App.tsx route /contracts/new → ContractCreatePage. Pure navigation UX; no new permissions needed. Admin + any role with Contracts.Read see full menu; leaves click-through to filtered views. Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-admin/src/App.tsx | 2 + fe-admin/src/components/Layout.tsx | 103 ++++++++--- .../pages/contracts/ContractCreatePage.tsx | 160 ++++++++++++++++++ .../src/pages/contracts/ContractsListPage.tsx | 52 +++++- .../GetMyMenuTree/GetMyMenuTreeQuery.cs | 20 ++- .../SolutionErp.Domain/Identity/MenuKeys.cs | 13 ++ .../Persistence/DbInitializer.cs | 29 +++- 7 files changed, 341 insertions(+), 38 deletions(-) create mode 100644 fe-admin/src/pages/contracts/ContractCreatePage.tsx diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index df881f5..946c10d 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -12,6 +12,7 @@ import { PermissionsPage } from '@/pages/system/PermissionsPage' import { FormsPage } from '@/pages/forms/FormsPage' import { ContractsListPage } from '@/pages/contracts/ContractsListPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' +import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage' import { ReportsPage } from '@/pages/ReportsPage' import { UsersPage } from '@/pages/system/UsersPage' @@ -36,6 +37,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index 2801d75..13a220e 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -7,46 +7,89 @@ import { TopBar } from '@/components/TopBar' import type { MenuNode } from '@/types/menu' import { cn } from '@/lib/cn' -// Map icon name → component (fallback Circle) function getIcon(name: string | null): LucideIcon { if (!name) return Circle const candidate = (Icons as unknown as Record)[name] return candidate ?? Circle } -// Map menu key → route path -const KEY_TO_PATH: Record = { - Dashboard: '/dashboard', - Suppliers: '/master/suppliers', - Projects: '/master/projects', - Departments: '/master/departments', - Contracts: '/contracts', - Forms: '/forms', - Reports: '/reports', - Users: '/system/users', - Roles: '/system/roles', - Permissions: '/system/permissions', +// Map contract type code → ContractType int enum value (mirrors +// Domain/Contracts/ContractType.cs for URL filter). +const TYPE_CODE_TO_INT: Record = { + ThauPhu: 1, + GiaoKhoan: 2, + NhaCungCap: 3, + DichVu: 4, + MuaBan: 5, + NguyenTacNcc: 6, + NguyenTacDv: 7, } -function MenuGroup({ node }: { node: MenuNode }) { - const [open, setOpen] = useState(true) +// Resolve menu key → route. Static map for top-level items, pattern for +// Ct__ sub-menu entries. +function resolvePath(key: string): string | null { + const staticMap: Record = { + Dashboard: '/dashboard', + Suppliers: '/master/suppliers', + Projects: '/master/projects', + Departments: '/master/departments', + Contracts: '/contracts', + Forms: '/forms', + Reports: '/reports', + Users: '/system/users', + Roles: '/system/roles', + Permissions: '/system/permissions', + } + if (staticMap[key]) return staticMap[key] + + const match = key.match(/^Ct_([^_]+)_(List|Create|Pending)$/) + if (match) { + const [, code, action] = match + const typeInt = TYPE_CODE_TO_INT[code] + if (!typeInt) return null + if (action === 'List') return `/contracts?type=${typeInt}` + if (action === 'Create') return `/contracts/new?type=${typeInt}` + if (action === 'Pending') return `/contracts?type=${typeInt}&pendingMe=1` + } + return null +} + +function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number }) { + const hasChildren = node.children.length > 0 + if (hasChildren) return + return +} + +function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) { + // Top-level groups expanded by default, nested ones collapsed to reduce noise + const [open, setOpen] = useState(depth === 0) const Icon = getIcon(node.icon) + const isTopLevel = depth === 0 + return (
{open && ( -
+
{node.children.map(c => ( - + ))}
)} @@ -54,21 +97,29 @@ function MenuGroup({ node }: { node: MenuNode }) { ) } -function MenuLeaf({ node }: { node: MenuNode }) { +function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) { const Icon = getIcon(node.icon) - const path = KEY_TO_PATH[node.key] + const path = resolvePath(node.key) if (!path) return null + const isDeep = depth >= 2 + return ( cn( - 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition', - isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900', + 'flex items-center gap-2.5 rounded-md transition', + isDeep ? 'px-3 py-1 text-[12px]' : 'px-3 py-2 text-sm font-medium', + isActive + ? 'bg-brand-50 text-brand-700' + : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900', ) } > - + {node.label} ) @@ -87,7 +138,9 @@ export function Layout() {
diff --git a/fe-admin/src/pages/contracts/ContractCreatePage.tsx b/fe-admin/src/pages/contracts/ContractCreatePage.tsx new file mode 100644 index 0000000..08a9477 --- /dev/null +++ b/fe-admin/src/pages/contracts/ContractCreatePage.tsx @@ -0,0 +1,160 @@ +import { useState, type FormEvent } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { toast } from 'sonner' +import { PageHeader } from '@/components/PageHeader' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { Select } from '@/components/ui/Select' +import { Textarea } from '@/components/ui/Textarea' +import { api } from '@/lib/api' +import { getErrorMessage } from '@/lib/apiError' +import type { Paged, Project, Supplier } from '@/types/master' +import type { ContractTemplate } from '@/types/forms' +import { ContractTypeLabel } from '@/types/forms' + +export function ContractCreatePage() { + const navigate = useNavigate() + const [searchParams] = useSearchParams() + + // Pre-select type from sidebar menu link (?type=X). Fallback: Giao khoán. + const urlType = searchParams.get('type') + const initialType = urlType && !isNaN(Number(urlType)) ? Number(urlType) : 2 + + const [type, setType] = useState(initialType) + const [supplierId, setSupplierId] = useState('') + const [projectId, setProjectId] = useState('') + const [templateId, setTemplateId] = useState('') + const [giaTri, setGiaTri] = useState('') + const [tenHopDong, setTenHopDong] = useState('') + const [noiDung, setNoiDung] = useState('') + const [bypass, setBypass] = useState(false) + + const suppliers = useQuery({ + queryKey: ['suppliers-all'], + queryFn: async () => (await api.get>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const projects = useQuery({ + queryKey: ['projects-all'], + queryFn: async () => (await api.get>('/projects', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const templates = useQuery({ + queryKey: ['templates-by-type', type], + queryFn: async () => (await api.get('/forms/templates', { params: { type } })).data, + }) + + const create = useMutation({ + mutationFn: async () => { + const res = await api.post<{ id: string }>('/contracts', { + type: Number(type), + supplierId, + projectId, + departmentId: null, + templateId: templateId || null, + giaTri: giaTri ? Number(giaTri) : 0, + tenHopDong: tenHopDong || null, + noiDung: noiDung || null, + bypassProcurementAndCCM: bypass, + draftData: null, + }) + return res.data.id + }, + onSuccess: id => { + toast.success('Đã tạo HĐ draft') + navigate(`/contracts/${id}`) + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + function submit(e: FormEvent) { + e.preventDefault() + if (!supplierId || !projectId) { + toast.error('Chọn NCC và dự án') + return + } + create.mutate() + } + + const typeLabel = ContractTypeLabel[type] ?? 'HĐ' + + return ( +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" /> +
+
+ + setGiaTri(e.target.value)} /> +
+
+ setBypass(e.target.checked)} + className="h-4 w-4 accent-brand-600" + /> + +
+
+ +