[CLAUDE] Domain+Infra+App+FE-Admin: per-ContractType nested sidebar menu
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:
pqhuy1987
2026-04-21 22:25:00 +07:00
parent fb3a410a1b
commit 48e91fe7ca
7 changed files with 341 additions and 38 deletions

View 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 *</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 </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"> với Chủ đu (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>
)
}

View File

@ -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