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