Files
solution-erp/fe-admin/src/pages/contracts/HardCopiesPage.tsx
pqhuy1987 bfc7b79f72
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s
[CLAUDE] FE-User+FE-Admin: cay 4 folder GD duoi tung goi thau (Duyet NCC / Ke hoach HD / Duyet HD / HD cung) x2 app + 5 fix review F-1/3/4/6/7
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:04:32 +07:00

461 lines
20 KiB
TypeScript

// [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<string | null>(null)
const list = useQuery({
queryKey: ['hard-copies', { type: typeFilter, search, page }],
queryFn: async () =>
(
await api.get<Paged<ContractListItem>>('/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 (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 4"
title={typeLabel ? `Hợp đồng cứng — ${typeLabel}` : 'Hợp đồng cứng'}
subtitle="Theo dõi bộ hợp đồng bản cứng của các HĐ đã phát hành"
icon={<Archive className="h-5 w-5" />}
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). */}
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] leading-relaxed text-amber-900">
<span className="font-semibold"> 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).</span>{' '}
Hệ thống chỉ ghi nhận bằng chứng cuối: tải lên bản scan bộ hợp đng đã + đóng dấu.
file scan xem như <span className="font-semibold">xong</span>.
</div>
{/* [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. */}
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
<PipelineTreePanel currentStage={4} />
<div className="min-w-0 space-y-5">
<div
className="card-accent flex items-center gap-3 px-4 py-3"
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
>
<Search className="h-4 w-4 shrink-0 text-slate-400" />
<Input
value={search}
onChange={(e) => {
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"
/>
</div>
<div
className="card-accent overflow-hidden"
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
>
{/* [S162] Cột trái ăn ~19rem ⇒ bảng cuộn ngang trong thẻ, không tràn lưới. */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow w-8 px-4 py-2.5 text-left" />
<th className="label-eyebrow px-4 py-2.5 text-left"> </th>
<th className="label-eyebrow px-4 py-2.5 text-left">Tên hợp đng</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Nhà cung cấp</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
<th className="label-eyebrow px-4 py-2.5 text-right">Giá trị</th>
<th className="label-eyebrow px-4 py-2.5 text-left">Bản cứng</th>
</tr>
</thead>
<tbody>
{list.isLoading && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
Đang tải...
</td>
</tr>
)}
{!list.isLoading && items.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
<span
className="icon-chip mx-auto mb-2 flex"
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
aria-hidden
>
<Inbox className="h-4 w-4" />
</span>
{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.'}
</td>
</tr>
)}
{items.map((c) => {
const open = openId === c.id
return (
<Fragment key={c.id}>
<tr
onClick={() => 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',
)}
>
<td className="px-4 py-2.5 text-slate-400">
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-teal-700">{c.maHopDong ?? '—'}</td>
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-slate-800">
{c.tenHopDong ?? '(chưa đặt tên)'}
</td>
<td className="max-w-[12rem] truncate px-4 py-2.5 text-xs text-slate-600">{c.supplierName}</td>
<td className="max-w-[12rem] truncate px-4 py-2.5 text-xs text-slate-600">{c.projectName}</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-slate-700">
{formatVnd(c.giaTri)}
</td>
<td className="px-4 py-2.5">
{/* 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 ? (
<span className="inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
<ShieldCheck className="h-3.5 w-3.5" />
Đã lưu bản cứng
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
<Upload className="h-3.5 w-3.5" />
Chưa bản cứng
</span>
)}
</td>
</tr>
{open && (
<tr className="border-b border-slate-100">
<td colSpan={7} className="bg-slate-50/70 px-4 py-4">
<HardCopyPanel contractId={c.id} />
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
<div className="text-slate-500">
{total} hợp đng Trang {page} / {totalPages}
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Trước
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Sau
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
// ===== 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<HTMLInputElement>(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<ContractDetail>(`/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<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
handleFiles(e.dataTransfer.files)
}
function onPick(e: ChangeEvent<HTMLInputElement>) {
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 (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-3">
<div
onDragOver={(e) => {
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',
)}
>
<Upload className="mx-auto h-5 w-5 text-slate-400" />
<div className="mt-2 text-sm font-medium text-slate-600">
Kéo thả bản scan vào đây hoặc <span className="text-teal-700">chọn file</span>
</div>
<div className="mt-0.5 text-xs text-slate-400">
Bộ đã + đóng dấu · PDF / DOCX / XLSX / PNG / JPG · tối đa 20 MB
</div>
<input
ref={inputRef}
type="file"
multiple
onChange={onPick}
className="hidden"
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
/>
{upload.isPending && <div className="mt-2 text-xs text-teal-700">Đang tải lên</div>}
</div>
<div>
<label className="label-eyebrow mb-1 block">Ghi chú (tùy chọn)</label>
<Textarea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Số bộ gốc · đã giao CCM/NTP · nơi lưu…"
/>
<p className="mt-1 text-[11px] text-slate-400">
Ghi chú đính theo file sắp tải lên bỏ trống cũng đưc.
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white">
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2 text-xs font-semibold text-slate-600">
<FileText className="h-3.5 w-3.5" />
Bản scan đã lưu ({sealed.length})
</div>
{detail.isLoading && <div className="px-3 py-4 text-xs text-slate-500">Đang tải</div>}
{!detail.isLoading && sealed.length === 0 && (
<div className="px-3 py-4 text-xs text-slate-500">
Chưa bản scan nào. Tải lên bản scan bộ đã + đóng dấu đ hoàn tất.
</div>
)}
{sealed.length > 0 && (
<ul className="divide-y divide-slate-100">
{sealed.map((a) => (
<li key={a.id} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-700">{a.fileName}</div>
<div className="text-[11px] text-slate-400">
{fmtSize(a.fileSize)} · {formatDateTime(a.createdAt)}
</div>
{a.note && <div className="mt-0.5 text-[11px] text-slate-500">Ghi chú: {a.note}</div>}
</div>
<button
onClick={() => download(a)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
title="Tải xuống"
>
<Download className="h-4 w-4" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
{/* Chữ ký duyệt điện tử từ GĐ3 — CHỈ ĐỌC (khớp "Duyệt qua (nếu có)" @S161).
HĐ V1 legacy không pin workflow V2 ⇒ opinions rỗng ⇒ khối VẪN HIỆN với
câu placeholder (không ẩn — review W7 FLAG-3). */}
<div className="rounded-lg border border-slate-200 bg-white">
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2 text-xs font-semibold text-slate-600">
<PenLine className="h-3.5 w-3.5" />
Ý kiến cấp duyệt ({opinions.length})
</div>
{opinions.length === 0 ? (
<div className="px-3 py-4 text-xs text-slate-500">
Hợp đng này không chữ duyệt điện tử.
</div>
) : (
<ul className="divide-y divide-slate-100">
{opinions.map((o) => (
<li key={o.id} className="px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[13px] font-medium text-slate-700">
{o.approverFullName ?? '—'}
</span>
<span className="shrink-0 text-[11px] text-slate-400">{formatDateTime(o.signedAt)}</span>
</div>
<div className="text-[11px] text-slate-500">
{o.stepName}
{o.levelName ? ` · ${o.levelName}` : ` · Cấp ${o.levelOrder}`}
</div>
<div className="mt-0.5 text-[12px] text-slate-600">
{o.comment?.trim() ? o.comment : '(duyệt — không ý kiến)'}
</div>
{o.signedByUserId !== o.approverUserId && (
<div className="mt-1 inline-flex items-center rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
Admin duyệt thay ({o.signedByFullName ?? '—'})
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
)
}