[CLAUDE] Contract: W7 KHKK GD4 "Hop dong cung" 1-moc — HardCopiesPage x2 + ?type filter + hasSealedCopy badge + 3 test PIN (suite 590/0)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s
- BE: ListContractsQuery +ContractType? type (filter server-side F-C1/F-05) + DTO HasSealedCopy derive Attachments.Any(purpose=SealedCopy=3) — append-cuoi default false, 0 policy moi, 0 migration, attachment endpoints 0-diff (O-A giu) - FE x2 SHA-PAIR: trang /hard-copies (list phase=9 hardcode + badge + panel upload multipart purpose=3 + Y kien cap duyet readonly) + 7 leaf Hdc_* re-point ?type=1..7 (coming-soon 0/0, menu 142 giu) - Tests: ContractHardCopyTests 3 PIN (phase-only + type-filter Total-server-side + badge derive purpose=3 no-IsDeleted) — probe vi-phan 5/5 phan biet ban hong; suite 587->590/0 - Review: PASS-co-dieu-kien (FLAG-1 untracked da gate; FLAG-2/3 comment da va, SHA-pair C7DCC20B giu; +31/-4 errata BE self-report) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -14,6 +14,7 @@ import { CatalogsPage } from '@/pages/master/CatalogsPage'
|
||||
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
|
||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
|
||||
import { HardCopiesPage } from '@/pages/contracts/HardCopiesPage'
|
||||
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
||||
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
|
||||
@ -63,6 +64,9 @@ function App() {
|
||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/my-contracts" element={<MyContractsPage />} />
|
||||
{/* [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. */}
|
||||
<Route path="/hard-copies" element={<HardCopiesPage />} />
|
||||
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
||||
<Route path="/purchase-evaluations/workflow-matrix" element={<WorkflowMatrixViewPage />} />
|
||||
{/* [S159-đợt5] alias cho leaf "Luồng duyệt" của 7 nhóm HĐ (?type=3) */}
|
||||
|
||||
@ -60,13 +60,16 @@ function resolvePath(key: string): string | null {
|
||||
Khkk_Create: '/khkk/create',
|
||||
Khkk_Pending: '/khkk/list?filter=ChoDuyet',
|
||||
Khkk_Approved: '/khkk/list?filter=DaDuyet',
|
||||
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',
|
||||
Contracts: '/my-contracts',
|
||||
PurchaseEvaluations: '/purchase-evaluations',
|
||||
|
||||
447
fe-user/src/pages/contracts/HardCopiesPage.tsx
Normal file
447
fe-user/src/pages/contracts/HardCopiesPage.tsx
Normal file
@ -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<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>
|
||||
|
||||
<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)' }}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 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
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -94,6 +94,10 @@ export type ContractListItem = {
|
||||
giaTri: number
|
||||
slaDeadline: string | null
|
||||
createdAt: string
|
||||
// [W7 KHKK S161] Cờ derive 1-MỐC cho trang "Hợp đồng cứng" (GĐ4): có ≥1 attachment
|
||||
// `AttachmentPurpose.SealedCopy = 3` ⇒ badge "Đã lưu bản cứng". BE append CUỐI
|
||||
// `ContractListItemDto` (projection, không trả cả list attachments).
|
||||
hasSealedCopy: boolean
|
||||
}
|
||||
|
||||
export type ContractApproval = {
|
||||
|
||||
Reference in New Issue
Block a user