// [W7 KHKK — S161 2026-07-29] "Hợp đồng cứng" (GĐ4) — mô hình 1 MỐC. // Anh chốt @S161 (verbatim): "... Upload file cứng lên -> Là xem như xong" ⇒ ký GĐ // (b.19) · đóng dấu (b.20) · thủ tục lưu/phát hành (b.21) diễn ra NGOÀI hệ thống trên // GIẤY. Hệ thống chỉ ghi nhận BẰNG CHỨNG CUỐI: 1 file scan bộ HĐ đã ký + đóng dấu // (`AttachmentPurpose.SealedCopy = 3`, có sẵn — 0 migration, 0 enum-extend). // // Khuôn: `pages/khkk/KhkkListPage.tsx` (W2 — ui/PageHeader + card-accent table + // phân trang) & `components/ContractAttachmentsSection.tsx` (multipart upload HĐ). // File MIRROR SHA256 identical với fe-admin counterpart. // // URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis): // Hdc_ThauPhu … Hdc_NguyenTacDv → /hard-copies?type=1 … ?type=7 // HopDongCung (root) có 7 con ⇒ render MenuGroup, path KHÔNG dùng — nhánh // "không query = tất cả loại" chỉ tới được bằng gõ URL tay (review W7 FLAG-2). // 🔴 Lọc loại HĐ đi qua THAM SỐ SERVER `?type=` (review F-C1/F-05) — KHÔNG lọc // client-side trên trang `pageSize` (sẽ mất HĐ khi 1 loại vượt trần trang). import { Fragment, useRef, useState, type ChangeEvent, type DragEvent } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useSearchParams } from 'react-router-dom' import { Archive, ChevronDown, ChevronRight, Download, FileText, Inbox, PenLine, Search, ShieldCheck, Upload, } from 'lucide-react' import { toast } from 'sonner' import { PageHeader } from '@/components/ui/PageHeader' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Textarea } from '@/components/ui/Textarea' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { api, TOKEN_KEY } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { ContractPhase, type ContractAttachment, type ContractDetail, type ContractListItem, } from '@/types/contracts' import { ContractTypeLabel } from '@/types/forms' import type { Paged } from '@/types/master' const PAGE_SIZE = 20 // Mirror BE `AttachmentPurpose.SealedCopy` (Domain/Contracts/ContractAttachment.cs:5-11). // 🔴 KHÔNG dùng `ScannedSigned = 2` — nghĩa của nó là "scan có chữ ký NCC ở phase // Đang in ký" (gotcha #71), không phải bộ cứng đã đóng dấu. const SEALED_COPY = 3 const BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api' function formatVnd(n: number | null | undefined): string { if (n === null || n === undefined) return '—' return n.toLocaleString('vi-VN') + ' đ' } function formatDateTime(iso: string): string { return new Date(iso).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' }) } function fmtSize(n: number): string { if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` return `${(n / (1024 * 1024)).toFixed(1)} MB` } export function HardCopiesPage() { const [searchParams] = useSearchParams() const rawType = searchParams.get('type') const parsedType = rawType === null ? Number.NaN : Number(rawType) const typeFilter = Number.isFinite(parsedType) && parsedType > 0 ? parsedType : null const [search, setSearch] = useState('') const [page, setPage] = useState(1) const [openId, setOpenId] = useState(null) const list = useQuery({ queryKey: ['hard-copies', { type: typeFilter, search, page }], queryFn: async () => ( await api.get>('/contracts', { params: { // GĐ4 chỉ theo dõi HĐ ĐÃ PHÁT HÀNH (phase 9 = terminal của GĐ3). phase: ContractPhase.DaPhatHanh, type: typeFilter ?? undefined, // `?? undefined` ⇒ axios bỏ hẳn key search: search.trim() || undefined, page, pageSize: PAGE_SIZE, }, }) ).data, }) const items = list.data?.items ?? [] const total = list.data?.total ?? 0 const totalPages = list.data?.totalPages ?? 1 const typeLabel = typeFilter !== null ? ContractTypeLabel[typeFilter] : null return (
} accent="teal" /> {/* Ghi chú 1-MỐC — nói rõ ký/đóng dấu/lưu là việc NGOÀI hệ thống, tránh user tưởng còn thiếu trạm (rủi ro §③-C của spec W7). */}
Ký giám đốc · đóng dấu · thủ tục lưu bộ gốc được thực hiện NGOÀI hệ thống (trên giấy).{' '} Hệ thống chỉ ghi nhận bằng chứng cuối: tải lên bản scan bộ hợp đồng đã ký + đóng dấu. Có file scan là xem như xong.
{/* [S162] Cây toàn trình bám trái (owner chốt AskUser 30-07: folder GĐ áp cả các trang giai đoạn khác) — folder "Hợp đồng cứng" mở sẵn ở đây. */}
{ setSearch(e.target.value) setPage(1) }} placeholder="Tìm mã HĐ, tên HĐ hoặc nhà cung cấp..." className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0" />
{/* [S162] Cột trái ăn ~19rem ⇒ bảng cuộn ngang trong thẻ, không tràn lưới. */}
{list.isLoading && ( )} {!list.isLoading && items.length === 0 && ( )} {items.map((c) => { const open = openId === c.id return ( setOpenId(open ? null : c.id)} className={cn( 'cursor-pointer border-b border-slate-100 transition', open ? 'bg-teal-50/60' : 'hover:bg-teal-50/40', )} > {open && ( )} ) })}
Mã HĐ Tên hợp đồng Nhà cung cấp Dự án Giá trị Bản cứng
Đang tải...
{typeLabel ? `Chưa có ${typeLabel} nào được phát hành.` : 'Chưa có hợp đồng nào được phát hành.'}
{open ? : } {c.maHopDong ?? '—'} {c.tenHopDong ?? '(chưa đặt tên)'} {c.supplierName} {c.projectName} {formatVnd(c.giaTri)} {/* Pattern 14 — class đầy đủ dạng literal (Tailwind JIT không thấy chuỗi ghép). Badge derive 1-mốc từ `hasSealedCopy`. */} {c.hasSealedCopy ? ( Đã lưu bản cứng ) : ( Chưa có bản cứng )}
{totalPages > 1 && (
{total} hợp đồng — Trang {page} / {totalPages}
)}
) } // ===== Panel mở dưới mỗi hàng — upload 1 purpose + danh sách file + chữ ký duyệt ===== function HardCopyPanel({ contractId }: { contractId: string }) { const qc = useQueryClient() const inputRef = useRef(null) const [dragging, setDragging] = useState(false) const [note, setNote] = useState('') // Dùng CHÍNH queryKey `['contract', id]` của module HĐ ⇒ mọi nơi invalidate // (ContractAttachmentsSection…) đều làm panel này tươi theo. const detail = useQuery({ queryKey: ['contract', contractId], queryFn: async () => (await api.get(`/contracts/${contractId}`)).data, }) const upload = useMutation({ mutationFn: async (file: File) => { // Endpoint CÓ SẴN: POST /api/contracts/{id}/attachments (multipart) — // field name khớp controller `IFormFile file` + `[FromForm] purpose/note` // (ContractsController.cs:87-92). Không đẻ endpoint mới. const form = new FormData() form.append('file', file) form.append('purpose', String(SEALED_COPY)) if (note.trim()) form.append('note', note.trim()) return ( await api.post(`/contracts/${contractId}/attachments`, form, { headers: { 'Content-Type': 'multipart/form-data' }, }) ).data }, onSuccess: () => { setNote('') qc.invalidateQueries({ queryKey: ['contract', contractId] }) qc.invalidateQueries({ queryKey: ['hard-copies'] }) // badge cột "Bản cứng" lật qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] }) // [F-7 S162] badge GĐ4 trên cây lật cùng toast.success('Đã lưu bản cứng') }, onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`), }) function handleFiles(files: FileList | null) { if (!files || files.length === 0) return for (const f of Array.from(files)) upload.mutate(f) } function onDrop(e: DragEvent) { e.preventDefault() setDragging(false) handleFiles(e.dataTransfer.files) } function onPick(e: ChangeEvent) { handleFiles(e.target.files) e.target.value = '' // cho phép chọn lại đúng file vừa chọn } async function download(att: ContractAttachment) { const token = localStorage.getItem(TOKEN_KEY) const res = await fetch(`${BASE_URL}/contracts/${contractId}/attachments/${att.id}/download`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) if (!res.ok) { toast.error(`Tải xuống lỗi (HTTP ${res.status})`) return } const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = att.fileName document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } const sealed = (detail.data?.attachments ?? []).filter((a) => a.purpose === SEALED_COPY) const opinions = detail.data?.levelOpinions ?? [] return (
{ e.preventDefault() setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={onDrop} onClick={() => inputRef.current?.click()} className={cn( 'cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition', dragging ? 'border-teal-500 bg-teal-50' : 'border-slate-300 bg-white hover:bg-slate-50', )} >
Kéo thả bản scan vào đây hoặc chọn file
Bộ HĐ đã ký + đóng dấu · PDF / DOCX / XLSX / PNG / JPG · tối đa 20 MB
{upload.isPending &&
Đang tải lên…
}