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" + /> + +
+
+ +