From 8c4b4da951115ea077da741a3e256f23dd6cb450 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 23 Apr 2026 10:48:29 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-User+FE-Admin:=202-panel=20layout?= =?UTF-8?q?=20cho=20Thao=20t=C3=A1c=20(Ct=5F*=5FCreate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trang /contracts/new?type=X (menu "Thao tác") redesign từ single form → 2-panel: Panel 1 list HĐ theo type | Panel 2 Header form + Chi tiết. ## Layout Panel 1 (320px) — flex column 3 vùng: - Top: Search box (filter mã/tên/NCC client-side) - Middle: List HĐ theo type (scroll, click row chọn) - Bottom: + Thêm mới button (sticky, ring-brand khi active mode=new) Panel 2 (flex) — 3 trạng thái theo URL: - Empty state — chưa chọn HĐ và chưa bấm + Thêm mới - ContractHeaderForm (mode=new) — form trống, sau Tạo HĐ draft → URL update ?id=newId chuyển edit mode - ContractEditForm (id=abc) — form populated từ /contracts/{id}, + section Chi tiết bên dưới (ContractDetailsTab reuse) ## URL state - ?type=X → empty - ?type=X&mode=new → form trống - ?type=X&id=abc → edit form + Chi tiết - ?type=X&q=keyword → search filter Panel 1 ## Edit constraints ContractEditForm respect UpdateContractDraftCommand limits: - Editable khi Phase=DangSoanThao: Tên HĐ, Giá trị, Template, Nội dung - Read-only luôn: Loại HĐ, NCC, Dự án, Bypass CCM (không đổi sau create qua BE command hiện tại) - Khi Phase != DangSoanThao: warning amber + tất cả input disabled, nhưng Chi tiết section vẫn render để user xem (ContractDetailsTab tự disable add/delete khi không phải draft) ## Components ContractCreatePage.tsx (rewrite) — page entry ContractHeaderForm — create mode (full fields editable) ContractEditForm — edit mode (limited fields + Chi tiết section) FormFields helper — shared form layout cho create ## Build verify - fe-user: tsc + vite pass (374ms) - fe-admin: tsc + vite pass (987ms) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pages/contracts/ContractCreatePage.tsx | 505 ++++++++++++++++-- .../pages/contracts/ContractCreatePage.tsx | 500 +++++++++++++++-- 2 files changed, 901 insertions(+), 104 deletions(-) diff --git a/fe-admin/src/pages/contracts/ContractCreatePage.tsx b/fe-admin/src/pages/contracts/ContractCreatePage.tsx index 08a9477..2db956b 100644 --- a/fe-admin/src/pages/contracts/ContractCreatePage.tsx +++ b/fe-admin/src/pages/contracts/ContractCreatePage.tsx @@ -1,8 +1,24 @@ -import { useState, type FormEvent } from 'react' -import { useMutation, useQuery } from '@tanstack/react-query' -import { useNavigate, useSearchParams } from 'react-router-dom' +// "Thao tác HĐ" — 2-panel UX cho Ct_*_Create menu. Panel 1 list HĐ theo +// type + nút "Thêm mới" cuối list | Panel 2 form Header + Chi tiết section +// (khi edit mode). Giữ tên file ContractCreatePage để route /contracts/new +// không cần đổi. +// +// URL state: +// ?type=X → Panel 2 empty state +// ?type=X&mode=new → Form trống (create mode) +// ?type=X&id=abc → Form populated (edit mode) + Chi tiết +// +// Sau khi tạo xong → redirect URL ?id=newId tự động chuyển edit mode + +// hiển thị Chi tiết section. +import { useState, useMemo, type FormEvent, useEffect } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useSearchParams } from 'react-router-dom' +import { FileText, Plus, Search, Save } from 'lucide-react' import { toast } from 'sonner' -import { PageHeader } from '@/components/PageHeader' +import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab' +import { PhaseBadge } from '@/components/PhaseBadge' +import { SlaTimer } from '@/components/SlaTimer' +import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Label } from '@/components/ui/Label' @@ -10,19 +26,216 @@ import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' +import { cn } from '@/lib/cn' import type { Paged, Project, Supplier } from '@/types/master' import type { ContractTemplate } from '@/types/forms' import { ContractTypeLabel } from '@/types/forms' +import { + ContractPhase, + type ContractDetail, + type ContractListItem, +} from '@/types/contracts' + +const fmtMoney = (v: number) => v.toLocaleString('vi-VN') export function ContractCreatePage() { - const navigate = useNavigate() - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() + const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : 2 + const selectedId = searchParams.get('id') + const isNewMode = searchParams.get('mode') === 'new' + const search = searchParams.get('q') ?? '' - // 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 list = useQuery({ + queryKey: ['my-contracts', typeFilter], + queryFn: async () => + (await api.get>('/contracts', { params: { page: 1, pageSize: 100 } })).data, + }) - const [type, setType] = useState(initialType) + const detail = useQuery({ + queryKey: ['contract', selectedId], + queryFn: async () => (await api.get(`/contracts/${selectedId}`)).data, + enabled: !!selectedId, + }) + + const rows = useMemo(() => { + let items = list.data?.items ?? [] + items = items.filter(c => c.type === typeFilter) + if (search.trim()) { + const q = search.toLowerCase() + items = items.filter(c => + (c.maHopDong ?? '').toLowerCase().includes(q) || + (c.tenHopDong ?? '').toLowerCase().includes(q) || + (c.supplierName ?? '').toLowerCase().includes(q), + ) + } + return items + }, [list.data, typeFilter, search]) + + function setParam(key: string, value: string | null) { + const next = new URLSearchParams(searchParams) + if (value == null || value === '') next.delete(key) + else next.set(key, value) + setSearchParams(next, { replace: key === 'q' }) + } + + function selectContract(id: string) { + const next = new URLSearchParams(searchParams) + next.set('id', id) + next.delete('mode') + setSearchParams(next, { replace: false }) + } + + function startNew() { + const next = new URLSearchParams(searchParams) + next.set('mode', 'new') + next.delete('id') + setSearchParams(next, { replace: false }) + } + + const typeLabel = ContractTypeLabel[typeFilter] ?? 'HĐ' + + return ( +
+
+
+ +

+ Thao tác · {typeLabel} +

+ + {rows.length} + +
+
+ +
+ {/* Panel 1 — List + Thêm mới button cuối */} + + + {/* Panel 2 — Form (new/edit) hoặc empty state */} +
+ {!isNewMode && !selectedId && ( +
+ +
+ )} + {isNewMode && ( + { + // Sau khi tạo: refresh list + chuyển sang edit mode để user nhập Chi tiết + const next = new URLSearchParams(searchParams) + next.delete('mode') + next.set('id', newId) + setSearchParams(next, { replace: true }) + }} + /> + )} + {!isNewMode && selectedId && detail.data && ( + { + /* mutation tự invalidate */ + }} + /> + )} + {!isNewMode && selectedId && detail.isLoading && ( +
Đang tải HĐ…
+ )} +
+
+
+ ) +} + +// ===== Form components ===== + +function ContractHeaderForm({ + defaultType, + onCreated, +}: { + defaultType: number + onCreated: (newId: string) => void +}) { + const [type, setType] = useState(defaultType) const [supplierId, setSupplierId] = useState('') const [projectId, setProjectId] = useState('') const [templateId, setTemplateId] = useState('') @@ -31,21 +244,23 @@ export function ContractCreatePage() { const [noiDung, setNoiDung] = useState('') const [bypass, setBypass] = useState(false) + // Reset type về default khi typeFilter (parent prop) thay đổi + useEffect(() => { setType(defaultType) }, [defaultType]) + 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 qc = useQueryClient() const create = useMutation({ mutationFn: async () => { const res = await api.post<{ id: string }>('/contracts', { @@ -64,7 +279,8 @@ export function ContractCreatePage() { }, onSuccess: id => { toast.success('Đã tạo HĐ draft') - navigate(`/contracts/${id}`) + qc.invalidateQueries({ queryKey: ['my-contracts'] }) + onCreated(id) }, onError: err => toast.error(getErrorMessage(err)), }) @@ -78,28 +294,118 @@ export function ContractCreatePage() { create.mutate() } - const typeLabel = ContractTypeLabel[type] ?? 'HĐ' + return ( +
+
+

Tạo HĐ mới — Header

+ +
+ +
+
+
+ Chi tiết HĐ (line items) sẽ hiện ở đây sau khi tạo Header xong. +
+
+ ) +} + +function ContractEditForm({ + contract, + onSaved, +}: { + contract: ContractDetail + onSaved: () => void +}) { + const isDraft = contract.phase === ContractPhase.DangSoanThao + const [templateId, setTemplateId] = useState(contract.templateId ?? '') + const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0)) + const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '') + const [noiDung, setNoiDung] = useState(contract.noiDung ?? '') + + const templates = useQuery({ + queryKey: ['templates-by-type', contract.type], + queryFn: async () => (await api.get('/forms/templates', { params: { type: contract.type } })).data, + }) + + const qc = useQueryClient() + const update = useMutation({ + mutationFn: async () => { + await api.put(`/contracts/${contract.id}`, { + id: contract.id, + giaTri: giaTri ? Number(giaTri) : 0, + tenHopDong: tenHopDong || null, + noiDung: noiDung || null, + templateId: templateId || null, + draftData: null, + }) + }, + onSuccess: () => { + toast.success('Đã lưu thay đổi') + qc.invalidateQueries({ queryKey: ['contract', contract.id] }) + qc.invalidateQueries({ queryKey: ['my-contracts'] }) + qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] }) + onSaved() + }, + onError: err => toast.error(getErrorMessage(err)), + }) return ( -
- +
+
+
+
+

Chỉnh sửa HĐ — Header

+
+ {contract.maHopDong ?? '(chưa có mã)'} + +
+
+ {isDraft && ( + + )} +
+ + {!isDraft && ( +
+ ⚠ HĐ đã chuyển khỏi phase Đang soạn thảo → Header read-only. Bạn vẫn có thể xem Chi tiết phía dưới. +
+ )} -
- - + +
- - setTemplateId(e.target.value)} + disabled={!isDraft} + > {templates.data?.filter(t => t.isActive).map(t => ( @@ -107,54 +413,141 @@ export function ContractCreatePage() {
- - + +
- - + +
- setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" /> + setTenHopDong(e.target.value)} disabled={!isDraft} />
- setGiaTri(e.target.value)} /> + setGiaTri(e.target.value)} disabled={!isDraft} />
setBypass(e.target.checked)} + checked={contract.bypassProcurementAndCCM} + disabled className="h-4 w-4 accent-brand-600" /> - +
-