[CLAUDE] Domain+Infra+App+FE-Admin: per-ContractType nested sidebar menu
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s
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_<TypeCode>[_<Action>] - 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_<Type>_<Action> → 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) <noreply@anthropic.com>
This commit is contained in:
@ -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() {
|
||||
<Route path="/system/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/forms" element={<FormsPage />} />
|
||||
<Route path="/contracts" element={<ContractsListPage />} />
|
||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@ -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<string, LucideIcon>)[name]
|
||||
return candidate ?? Circle
|
||||
}
|
||||
|
||||
// Map menu key → route path
|
||||
const KEY_TO_PATH: Record<string, string> = {
|
||||
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<string, number> = {
|
||||
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_<Type>_<Action> sub-menu entries.
|
||||
function resolvePath(key: string): string | null {
|
||||
const staticMap: Record<string, string> = {
|
||||
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 <MenuGroup node={node} depth={depth} />
|
||||
return <MenuLeaf node={node} depth={depth} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-xs font-semibold uppercase text-slate-500 hover:bg-slate-100"
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-md transition',
|
||||
isTopLevel
|
||||
? 'px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500 hover:bg-slate-100'
|
||||
: 'px-3 py-1.5 text-[13px] font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{node.label}
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition', !open && '-rotate-90')} />
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition', !open && '-rotate-90')} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-1 space-y-0.5 pl-2">
|
||||
<div className={cn(
|
||||
'mt-0.5 space-y-0.5',
|
||||
depth === 0 ? 'pl-2' : 'ml-3 mt-1 border-l border-slate-100 pl-3',
|
||||
)}>
|
||||
{node.children.map(c => (
|
||||
<MenuLeaf key={c.key} node={c} />
|
||||
<MenuNodeRenderer key={c.key} node={c} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@ -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 (
|
||||
<NavLink
|
||||
to={path}
|
||||
// NavLink's default "startsWith" match causes /contracts?type=1 and
|
||||
// /contracts to both highlight. Use `end` for query-param variants.
|
||||
end={path.includes('?')}
|
||||
className={({ isActive }) =>
|
||||
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',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||||
{node.label}
|
||||
</NavLink>
|
||||
)
|
||||
@ -87,7 +138,9 @@ export function Layout() {
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
||||
{menu.map(n => (n.children.length > 0 ? <MenuGroup key={n.key} node={n} /> : <MenuLeaf key={n.key} node={n} />))}
|
||||
{menu.map(n => (
|
||||
<MenuNodeRenderer key={n.key} node={n} depth={0} />
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
160
fe-admin/src/pages/contracts/ContractCreatePage.tsx
Normal file
160
fe-admin/src/pages/contracts/ContractCreatePage.tsx
Normal file
@ -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<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||
})
|
||||
|
||||
const projects = useQuery({
|
||||
queryKey: ['projects-all'],
|
||||
queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||
})
|
||||
|
||||
const templates = useQuery({
|
||||
queryKey: ['templates-by-type', type],
|
||||
queryFn: async () => (await api.get<ContractTemplate[]>('/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 (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={urlType ? `Tạo ${typeLabel}` : 'Tạo hợp đồng mới'}
|
||||
description="Điền thông tin cơ bản. Sau đó bổ sung nội dung + submit lên phase góp ý."
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="max-w-3xl space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Loại HĐ *</Label>
|
||||
<Select value={type} onChange={e => setType(Number(e.target.value))}>
|
||||
{Object.entries(ContractTypeLabel).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Template (optional)</Label>
|
||||
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}>
|
||||
<option value="">— Chưa chọn —</option>
|
||||
{templates.data?.filter(t => t.isActive).map(t => (
|
||||
<option key={t.id} value={t.id}>{t.formCode} — {t.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>NCC *</Label>
|
||||
<Select value={supplierId} onChange={e => setSupplierId(e.target.value)} required>
|
||||
<option value="">— Chọn —</option>
|
||||
{suppliers.data?.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.code} — {s.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Dự án *</Label>
|
||||
<Select value={projectId} onChange={e => setProjectId(e.target.value)} required>
|
||||
<option value="">— Chọn —</option>
|
||||
{projects.data?.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Tên HĐ</Label>
|
||||
<Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Giá trị (VND)</Label>
|
||||
<Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="bypass"
|
||||
checked={bypass}
|
||||
onChange={e => setBypass(e.target.checked)}
|
||||
className="h-4 w-4 accent-brand-600"
|
||||
/>
|
||||
<Label htmlFor="bypass" className="cursor-pointer">HĐ với Chủ đầu tư (bypass CCM)</Label>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Nội dung / ghi chú</Label>
|
||||
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>Hủy</Button>
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
@ -16,20 +16,52 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
export function ContractsListPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
// URL-driven filters — sidebar menu links pass type + pendingMe via query.
|
||||
// In-page filters (search, phase) are local state so admin can tweak freely.
|
||||
const urlType = searchParams.get('type')
|
||||
const urlPendingMe = searchParams.get('pendingMe') === '1'
|
||||
const typeFilter = urlType ? Number(urlType) : null
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [phase, setPhase] = useState<string>('')
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['contracts', { page, search, phase }],
|
||||
queryKey: ['contracts', { page, search, phase, typeFilter, urlPendingMe }],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Paged<ContractListItem>>('/contracts', {
|
||||
params: { page, pageSize: 20, search: search || undefined, phase: phase || undefined },
|
||||
params: {
|
||||
page,
|
||||
pageSize: 20,
|
||||
search: search || undefined,
|
||||
phase: phase || undefined,
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
// BE doesn't filter by type in listContracts yet — do it client-side.
|
||||
// Same for pendingMe; backend has /inbox but that returns different shape.
|
||||
// This keeps the existing API surface small; can promote to BE later.
|
||||
let items = res.data.items
|
||||
if (typeFilter != null) items = items.filter(c => c.type === typeFilter)
|
||||
return { ...res.data, items }
|
||||
},
|
||||
})
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (typeFilter != null) {
|
||||
const label = ContractTypeLabel[typeFilter] ?? 'HĐ'
|
||||
return urlPendingMe ? `${label} — Chờ duyệt` : label
|
||||
}
|
||||
return 'Hợp đồng'
|
||||
}, [typeFilter, urlPendingMe])
|
||||
|
||||
const headerDesc = useMemo(() => {
|
||||
if (urlPendingMe) return 'Các HĐ đang ở phase cần vai trò của bạn duyệt.'
|
||||
if (typeFilter != null) return `Danh sách ${ContractTypeLabel[typeFilter] ?? 'HĐ'}.`
|
||||
return 'Danh sách toàn bộ HĐ — filter theo phase, NCC, dự án.'
|
||||
}, [typeFilter, urlPendingMe])
|
||||
|
||||
const columns: Column<ContractListItem>[] = [
|
||||
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
|
||||
{ key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
|
||||
@ -43,7 +75,7 @@ export function ContractsListPage() {
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader title="Hợp đồng" description="Danh sách toàn bộ HĐ — filter theo phase, NCC, dự án." />
|
||||
<PageHeader title={headerTitle} description={headerDesc} />
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
<Input
|
||||
@ -58,6 +90,14 @@ export function ContractsListPage() {
|
||||
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
|
||||
))}
|
||||
</Select>
|
||||
{typeFilter != null && (
|
||||
<button
|
||||
onClick={() => navigate('/contracts')}
|
||||
className="rounded-md border border-slate-300 bg-white px-3 text-xs text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
← Tất cả loại
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@ -46,19 +46,31 @@ public class GetMyMenuTreeQueryHandler(
|
||||
Update: g.Any(p => p.CanUpdate),
|
||||
Delete: g.Any(p => p.CanDelete)));
|
||||
|
||||
// Build tree
|
||||
List<MenuNodeDto> BuildChildren(string? parentKey) => menus
|
||||
// Build tree. Children of `Contracts` (type submenu) inherit parent's
|
||||
// permission so we don't need per-subitem permission rows. This keeps
|
||||
// the permission matrix clean while allowing deep menu structures for
|
||||
// pure navigation.
|
||||
var (contractsRead, contractsCreate, contractsUpdate, contractsDelete) =
|
||||
resolved.TryGetValue(MenuKeys.Contracts, out var cf) ? cf : (false, false, false, false);
|
||||
|
||||
List<MenuNodeDto> BuildChildren(string? parentKey, bool inheritContractsPerm) => menus
|
||||
.Where(m => m.ParentKey == parentKey)
|
||||
.Select(m =>
|
||||
{
|
||||
var flags = resolved.TryGetValue(m.Key, out var f) ? f : (false, false, false, false);
|
||||
// If this node is a descendant of Contracts and has no explicit
|
||||
// perms, take parent Contracts perms.
|
||||
if (inheritContractsPerm && !resolved.ContainsKey(m.Key))
|
||||
flags = (contractsRead, contractsCreate, contractsUpdate, contractsDelete);
|
||||
|
||||
var childInherits = inheritContractsPerm || m.Key == MenuKeys.Contracts;
|
||||
return new MenuNodeDto(m.Key, m.Label, m.ParentKey, m.Order, m.Icon,
|
||||
flags.Item1, flags.Item2, flags.Item3, flags.Item4,
|
||||
BuildChildren(m.Key));
|
||||
BuildChildren(m.Key, childInherits));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var tree = BuildChildren(null);
|
||||
var tree = BuildChildren(null, inheritContractsPerm: false);
|
||||
|
||||
// Filter: chỉ trả về node có CanRead=true (hoặc có child CanRead=true)
|
||||
static bool HasAccess(MenuNodeDto n) => n.CanRead || n.Children.Any(HasAccess);
|
||||
|
||||
@ -17,6 +17,19 @@ public static class MenuKeys
|
||||
public const string Roles = "Roles";
|
||||
public const string Permissions = "Permissions";
|
||||
|
||||
// Per-contract-type menu groups + 3 action leaves each.
|
||||
// Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from
|
||||
// top-level. Menu tree endpoint (GetMyMenuTreeQuery) treats descendants of
|
||||
// `Contracts` as inheriting the parent permission so we don't need per-
|
||||
// child permission rows for all roles.
|
||||
public static readonly string[] ContractTypeCodes =
|
||||
["ThauPhu", "GiaoKhoan", "NhaCungCap", "DichVu", "MuaBan", "NguyenTacNcc", "NguyenTacDv"];
|
||||
|
||||
public static string ContractTypeGroup(string typeCode) => $"Ct_{typeCode}";
|
||||
public static string ContractTypeList(string typeCode) => $"Ct_{typeCode}_List";
|
||||
public static string ContractTypeCreate(string typeCode) => $"Ct_{typeCode}_Create";
|
||||
public static string ContractTypePending(string typeCode) => $"Ct_{typeCode}_Pending";
|
||||
|
||||
public static readonly string[] All =
|
||||
[
|
||||
Dashboard,
|
||||
|
||||
@ -88,7 +88,18 @@ public static class DbInitializer
|
||||
|
||||
private static async Task SeedMenuTreeAsync(ApplicationDbContext db, ILogger logger)
|
||||
{
|
||||
var tree = new (string Key, string Label, string? Parent, int Order, string Icon)[]
|
||||
var typeLabels = new Dictionary<string, string>
|
||||
{
|
||||
["ThauPhu"] = "HĐ Thầu phụ",
|
||||
["GiaoKhoan"] = "HĐ Giao khoán",
|
||||
["NhaCungCap"] = "HĐ Nhà cung cấp",
|
||||
["DichVu"] = "HĐ Dịch vụ",
|
||||
["MuaBan"] = "HĐ Mua bán",
|
||||
["NguyenTacNcc"] = "HĐ Nguyên tắc NCC",
|
||||
["NguyenTacDv"] = "HĐ Nguyên tắc Dịch vụ",
|
||||
};
|
||||
|
||||
var tree = new List<(string Key, string Label, string? Parent, int Order, string Icon)>
|
||||
{
|
||||
(MenuKeys.Dashboard, "Tổng quan", null, 10, "LayoutDashboard"),
|
||||
(MenuKeys.Master, "Danh mục", null, 20, "Database"),
|
||||
@ -104,12 +115,24 @@ public static class DbInitializer
|
||||
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
|
||||
};
|
||||
|
||||
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||
// (Danh sách / Thao tác / Duyệt).
|
||||
var order = 31;
|
||||
foreach (var code in MenuKeys.ContractTypeCodes)
|
||||
{
|
||||
var label = typeLabels.GetValueOrDefault(code, code);
|
||||
tree.Add((MenuKeys.ContractTypeGroup(code), label, MenuKeys.Contracts, order++, "FileText"));
|
||||
tree.Add((MenuKeys.ContractTypeList(code), "Danh sách", MenuKeys.ContractTypeGroup(code), order++, "List"));
|
||||
tree.Add((MenuKeys.ContractTypeCreate(code), "Thao tác", MenuKeys.ContractTypeGroup(code), order++, "Plus"));
|
||||
tree.Add((MenuKeys.ContractTypePending(code),"Duyệt", MenuKeys.ContractTypeGroup(code), order++, "CheckCircle2"));
|
||||
}
|
||||
|
||||
var existingKeys = await db.MenuItems.Select(m => m.Key).ToListAsync();
|
||||
var added = 0;
|
||||
foreach (var (key, label, parent, order, icon) in tree)
|
||||
foreach (var (key, label, parent, o, icon) in tree)
|
||||
{
|
||||
if (existingKeys.Contains(key)) continue;
|
||||
db.MenuItems.Add(new MenuItem { Key = key, Label = label, ParentKey = parent, Order = order, Icon = icon });
|
||||
db.MenuItems.Add(new MenuItem { Key = key, Label = label, ParentKey = parent, Order = o, Icon = icon });
|
||||
added++;
|
||||
}
|
||||
if (added > 0)
|
||||
|
||||
Reference in New Issue
Block a user