diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index 5f2d629..1915ebb 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -20,6 +20,7 @@ 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 { HardCopiesPage } from '@/pages/contracts/HardCopiesPage' import { ReportsPage } from '@/pages/ReportsPage' import { UsersPage } from '@/pages/system/UsersPage' import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage' @@ -81,6 +82,9 @@ function App() { } /> } /> } /> + {/* [W7 KHKK S161] GĐ4 "Hợp đồng cứng" — 7 leaf `Hdc_*` deep-link ?type=1..7 + (Layout.tsx staticMap). Không query = tất cả loại. */} + } /> } /> } /> } /> diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index 39e9294..bd87865 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -38,13 +38,16 @@ function resolvePath(key: string): string | null { Khkk_Pending: '/khkk/list?filter=ChoDuyet', Khkk_Approved: '/khkk/list?filter=DaDuyet', Khkk_Deleted: '/khkk/list?view=deleted', // [S159-đợt5 FLAG-1] thiếu là leaf drop SILENT (gotcha #50) - Hdc_ThauPhu: '/coming-soon?stage=4&t=01', - Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02', - Hdc_NhaCungCap: '/coming-soon?stage=4&t=03', - Hdc_DichVu: '/coming-soon?stage=4&t=04', - Hdc_MuaBan: '/coming-soon?stage=4&t=05', - Hdc_NguyenTacNcc: '/coming-soon?stage=4&t=06', - Hdc_NguyenTacDv: '/coming-soon?stage=4&t=07', + // [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật + // `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên). + // gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar. + Hdc_ThauPhu: '/hard-copies?type=1', + Hdc_GiaoKhoan: '/hard-copies?type=2', + Hdc_NhaCungCap: '/hard-copies?type=3', + Hdc_DichVu: '/hard-copies?type=4', + Hdc_MuaBan: '/hard-copies?type=5', + Hdc_NguyenTacNcc: '/hard-copies?type=6', + Hdc_NguyenTacDv: '/hard-copies?type=7', Dashboard: '/dashboard', Suppliers: '/master/suppliers', Projects: '/master/projects', diff --git a/fe-admin/src/pages/contracts/HardCopiesPage.tsx b/fe-admin/src/pages/contracts/HardCopiesPage.tsx new file mode 100644 index 0000000..1c6660e --- /dev/null +++ b/fe-admin/src/pages/contracts/HardCopiesPage.tsx @@ -0,0 +1,447 @@ +// [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 { 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. +
+ +
+ + { + 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" + /> +
+ +
+ + + + + + + + + + + + + {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 đồngNhà cung cấpDự ánGiá 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 + 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…
} +
+ +
+ +