All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
461 lines
20 KiB
TypeScript
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">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).</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 đã ký + đóng dấu.
|
|
Có file scan là 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">Mã HĐ</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 có 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ộ HĐ đã ký + đó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 có bản scan nào. Tải lên bản scan bộ HĐ đã ký + đó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 có chữ ký 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>
|
|
)
|
|
}
|