[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:
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
|
||||
|
||||
Reference in New Issue
Block a user