Files
solution-erp/fe-admin/src/components/contracts/ContractDetailContent.tsx

484 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Reusable detail body — used by full-page ContractDetailPage AND embedded
// in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase
// + actions) + Info + Comments + Attachments + transition Dialog. Workflow +
// approval history live separately in WorkflowHistoryPanel (Panel 3).
// File MIRROR SHA256 identical với fe-admin counterpart.
import { useState, type FormEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { ArrowLeft, CheckCircle2, MessageSquare, Trash2, XCircle, ListChecks, GitBranch } from 'lucide-react'
import { toast } from 'sonner'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
// [W6 S187] Màn duyệt V2-trạm — CHỈ dùng cho HĐ đã pin `approvalWorkflowId` (xem khối
// dual-render ở dưới). HĐ V1 legacy không import nhánh này lúc chạy.
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
ApprovalDecision,
ContractPhase,
ContractPhaseLabel,
type ContractDetail,
} from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
// [W5 S187] Nhãn 8 nhóm duyệt KHKK — IMPORT, KHÔNG chép chuỗi. Map này là single-source
// (`types/khkk.ts:140-149`, đã đối chiếu từng ký tự với nhãn BE seed @K4a); đẻ map thứ 2 ở
// đây là phá đúng cái acceptance nó phục vụ.
import { KHKK_APPROVAL_GROUP_LABELS } from '@/types/khkk'
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
export function ContractDetailContent({
contract: c,
onBack,
onDeleted,
}: {
contract: ContractDetail
/** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */
onBack?: () => void
/**
* [W5c S190] Gọi SAU khi xoá HĐ ở thanh nút đáy — host bỏ chọn / quay về danh sách.
* KHÔNG truyền ⇒ chỉ có 3 `invalidateQueries` chạy: danh sách tự rụng dòng, còn màn chi
* tiết đang mở refetch ra 404 và host rơi về nhánh "không tìm thấy / chưa chọn HĐ" của nó.
* (Tính đến wave này chưa host nào truyền — xem `sub-b6-w5c-fe.md`.)
*/
onDeleted?: () => void
}) {
const qc = useQueryClient()
const { user: currentUser } = useAuth()
const [actionOpen, setActionOpen] = useState(false)
const [targetPhase, setTargetPhase] = useState<number>(0)
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
const [comment, setComment] = useState('')
const [commentInput, setCommentInput] = useState('')
const transition = useMutation({
mutationFn: async () => {
await api.post(`/contracts/${c.id}/transitions`, { targetPhase, decision, comment: comment || null })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', c.id] })
qc.invalidateQueries({ queryKey: ['my-contracts'] })
qc.invalidateQueries({ queryKey: ['inbox'] })
toast.success('Đã chuyển phase')
setActionOpen(false)
setComment('')
},
onError: err => toast.error(getErrorMessage(err)),
})
const addComment = useMutation({
mutationFn: async (content: string) => {
await api.post(`/contracts/${c.id}/comments`, { content })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', c.id] })
setCommentInput('')
toast.success('Đã gửi')
},
onError: err => toast.error(getErrorMessage(err)),
})
// ===== [W5c S190 · YC-029] XOÁ HỢP ĐỒNG ================================================
// Đường xoá MỀM (BE `ContractFeatures.cs` khối DELETE, W5b): HĐ rời danh sách thường chứ
// không mất khỏi DB. Mọi rào ở đây là BẢN SAO HIỂN THỊ của guard BE, KHÔNG thay nó — chỗ
// chặn thật nằm trong handler (`ContractsController.cs` DELETE còn `[Authorize]` trần vì
// quyền per-action là W8, owner đã HOÃN).
const deleteContract = useMutation({
// `reason` đi QUERY-STRING vì verb DELETE không mang body — đúng hình endpoint W5b
// (`[FromQuery] string? reason`). Màn này CHƯA thu lý do: khuôn KHKK
// (`KhkkDetailContent.tsx:1090`) cũng chỉ `window.confirm`, và tự đẻ `window.prompt` ở
// đây là quyết-định UX chưa ai chốt. Gọi không truyền ⇒ axios bỏ hẳn key ⇒ request y hệt
// `DELETE /contracts/{id}`; chỗ cắm để sẵn cho lúc owner chốt có hỏi lý do hay không.
mutationFn: async (reason?: string) =>
api.delete(`/contracts/${c.id}`, { params: reason?.trim() ? { reason: reason.trim() } : undefined }),
onSuccess: () => {
// Cùng bộ key với `transition` ở trên (:57-61) — HĐ vừa xoá phải rụng khỏi CẢ danh sách
// của tôi LẪN hộp chờ duyệt, không riêng màn đang đứng.
qc.invalidateQueries({ queryKey: ['contract', c.id] })
qc.invalidateQueries({ queryKey: ['my-contracts'] })
qc.invalidateQueries({ queryKey: ['inbox'] })
toast.success('Đã xóa hợp đồng.')
onDeleted?.()
},
onError: err => toast.error(getErrorMessage(err)),
})
// Rào HIỂN THỊ mirror guard BE — 2 vế:
// • phase ∈ allow-list `< DangInKy(5)` {TraLai(98), TuChoi(99)}. ChoDuyet(10) KHÔNG có
// kể cả với người soạn (đang chờ người khác duyệt); DaPhatHanh(9) đã sinh mã ⇒ chặn mọi
// đường. Viết `<` chứ không liệt 1/2/3/4 vì allow-list HĐ gồm cả phase legacy.
// • người soạn HOẶC Admin. `isDrafter` theo khuôn null-safe `PeDetailTabs.tsx:141`:
// `currentUser?.id != null` phải ĐỨNG TRƯỚC vì `drafterUserId` là `string | null`
// (`types/contracts.ts:154`) — thiếu vế đó thì `null === null` cho HĐ vô chủ hiện nút.
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
const isDrafter = currentUser?.id != null && c.drafterUserId === currentUser.id
const canDelete =
(c.phase < ContractPhase.DangInKy || c.phase === ContractPhase.TraLai || c.phase === ContractPhase.TuChoi)
&& (isDrafter || isAdmin)
const availableTargets = c.workflow?.nextPhases ?? []
// ===== [W6 S187 · YC-023 GĐ3] DUAL-RENDER V2-trạm ⟂ V1-legacy ==========================
// 🔴 `approvalWorkflowId != null` là ĐÚNG cờ phân nhánh — cùng cờ mà BE dùng để chọn đường
// duyệt (`ContractWorkflowService.cs:111` `if (contract.ApprovalWorkflowId is Guid awId)`)
// VÀ cùng cờ mục "Ý kiến cấp duyệt (Quy trình V2)" bên dưới đã dùng từ S29. Một cờ, ba nơi
// đọc, không đẻ khái niệm "HĐ mới" thứ hai.
// • V2 ⇒ `ContractWorkflowPanel` (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại,
// có gating theo Cấp đang chờ).
// • V1 legacy (không pin — 7 HĐ prod chạy nhánh này, `ContractWorkflowService.cs:110`)
// ⇒ GIỮ NGUYÊN 2 nút "Yêu cầu sửa"/"Duyệt → tiếp" ở header + Dialog chọn phase.
// 2 khối là THAY THẾ nhau, không chồng: để cả hai thì cùng một HĐ V2 có 2 đường bấm duyệt
// (đường header không nói được đang ở Bước/Cấp nào, cũng không gating theo người) — mâu
// thuẫn hiển thị chứ không phải tiện thêm.
const isV2 = c.approvalWorkflowId != null
// ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) =====================================
// 🔴 Điều kiện render là `!= null` (LOOSE — bắt cả `undefined`) CÓ CHỦ ĐÍCH: `types/contracts.ts:174-182`
// khai 2 nghĩa KHÁC NHAU — `undefined` = payload/cache TanStack CHƯA có W2; `null` = BE nói
// "HĐ này không có nguồn KHKK". Cả 2 đều KHÔNG có gì để vẽ, nhưng KHÔNG được suy `undefined`
// thành "HĐ tạo tay" ở bất kỳ chỗ nào khác.
// Nguồn dữ liệu: BE reverse-join `ContractSigningPlanLines WHERE ContractId = id`
// (`ContractFeatures.cs:720-748`) — `Contract` KHÔNG có con-trỏ ngược sang kế hoạch.
const src = c.source
const srcGroup = src?.approvalGroup ?? null
// Nhãn nhóm: tra map single-source. Truthy-check theo đúng khuôn `khkkGroupMenuLabel`
// (`types/khkk.ts:159-162`) vì `Record<number,string>` trả `string` cho MỌI số — nhóm lạ
// (ngoài 1..8, vd dữ liệu SQL thô) phải hiện "chưa có trong danh mục" thay vì ô trống CÂM.
const srcGroupLabel = srcGroup != null ? KHKK_APPROVAL_GROUP_LABELS[srcGroup] : undefined
function openAction(decisionType: number) {
const targets = c.workflow?.nextPhases ?? []
const defaultTarget = decisionType === ApprovalDecision.Reject
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
: targets[0]
setTargetPhase(defaultTarget)
setDecision(decisionType)
setActionOpen(true)
}
return (
<div className="space-y-4">
{/* Header — sticky inside scroll container so actions luôn visible */}
<div className="sticky top-0 z-10 -mx-5 -mt-5 border-b border-slate-200 bg-white px-5 pt-5 pb-3 md:-mx-6 md:-mt-6 md:px-6 md:pt-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{onBack && (
<button onClick={onBack} className="text-slate-400 hover:text-slate-700" aria-label="Quay lại">
<ArrowLeft className="h-5 w-5" />
</button>
)}
<h1 className="truncate text-[18px] font-semibold tracking-tight text-slate-900">
{c.tenHopDong ?? 'HĐ chưa đặt tên'}
</h1>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-slate-500">
<span className="font-mono">{c.maHopDong ?? '(chưa có mã)'}</span>
<PhaseBadge phase={c.phase} />
</div>
</div>
{/* [W6 S187] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 thao tác duyệt ở
`ContractWorkflowPanel` bên dưới (đúng Bước/Cấp + đúng người), không ở đây. */}
{!isV2 && availableTargets.length > 0 && (
<div className="flex shrink-0 gap-2">
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
<XCircle className="h-4 w-4" />
Yêu cầu sửa
</Button>
<Button onClick={() => openAction(ApprovalDecision.Approve)}>
<CheckCircle2 className="h-4 w-4" />
Duyệt tiếp
</Button>
</div>
)}
</div>
</div>
{/* Tổng quan content — luôn hiển thị, không tabs */}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold text-slate-700">Thông tin </h2>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div><dt className="text-slate-500">Loại</dt><dd>{ContractTypeLabel[c.type] ?? '—'}</dd></div>
<div><dt className="text-slate-500">Giá trị</dt><dd>{fmtMoney(c.giaTri)}</dd></div>
<div><dt className="text-slate-500">NCC</dt><dd>{c.supplierName}</dd></div>
<div><dt className="text-slate-500">Dự án</dt><dd>{c.projectName}</dd></div>
<div><dt className="text-slate-500">Người soạn</dt><dd>{c.drafterName ?? '—'}</dd></div>
<div className="col-span-2">
<dt className="text-slate-500 mb-1">SLA</dt>
<dd><SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} variant="full" /></dd>
</div>
</dl>
{c.noiDung && (
<div className="mt-3">
<dt className="text-sm text-slate-500">Nội dung</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{c.noiDung}</dd>
</div>
)}
</section>
{/* [W5 S187 · YC-023 GĐ3] Card "Nguồn gốc Kế hoạch ký kết" — đứng NGAY SAU khối thông
tin chính: người mở HĐ cần biết nó đẻ ra từ đâu trước khi đọc góp ý / chi tiết.
Tông VIOLET = GĐ2 (KHKK) theo bảng màu 4 giai đoạn `PipelineStageFolders.tsx:41-46`;
link sang Phiếu Duyệt NCC dùng tông BRAND = GĐ1 (PE).
⚠️ Chỉ dùng stop violet CÓ THẬT trong `index.css` `@theme` (50/100/500/600/700) —
violet-300/800 rơi về bảng Tailwind mặc định, lệch hệ màu (đã thấy ở
`KhkkDetailContent.tsx:477`, pre-existing, không sửa ở wave này).
Vỏ `<section className="rounded-lg border … p-5">` + `<h2 className="mb-3 …">` bám
ĐÚNG khuôn các khối cùng trang — 2 app đều KHÔNG có shadcn `Card`. */}
{src != null && (
<section className="rounded-lg border border-violet-100 bg-violet-50/60 p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-violet-700">
<GitBranch className="h-4 w-4" />
Nguồn gốc Kế hoạch kết
</h2>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="col-span-2">
<dt className="text-slate-500"> kế hoạch</dt>
<dd className="mt-1 flex flex-wrap items-center gap-2">
{/* 🔴 CHỈ link khi phiếu cha còn đọc được. `maKeHoach === null` ⇔ phiếu đã xoá
mềm (BE `ContractFeatures.cs:738-740` lấy mã qua query CÓ global filter), mà
màn `/khkk/:id` đọc qua `GetContractSigningPlanQuery`
(`ContractSigningPlanFeatures.cs:957-962`) cũng KHÔNG `IgnoreQueryFilters`
⇒ 404. Link tới đó = ngõ cụt. Trạng thái này nói bằng CHỮ NHÌN THẤY ĐƯỢC,
không giấu sau tooltip. */}
{src.maKeHoach != null ? (
<Link
// `?group=` KHÔNG phải param chết: `KhkkDetailPage.tsx:50-52` (cả 2 app) đọc
// và validate bằng REGEX CHUỖI `/^[1-8]$/` rồi dựng đường "quay lại danh sách"
// `/khkk/list?group=n` ⇒ giữ đúng chỗ đứng + sidebar sáng đúng leaf nhóm.
to={`/khkk/${src.planId}${srcGroup != null ? `?group=${srcGroup}` : ''}`}
className="font-mono font-medium text-violet-600 underline underline-offset-2 hover:text-violet-700"
>
{src.maKeHoach}
</Link>
) : (
<>
<span className="font-mono text-slate-500">(phiếu đã xoá)</span>
<span className="text-[11px] text-slate-500"> không mở đưc phiếu gốc</span>
</>
)}
{srcGroup != null && (
<span className="rounded bg-violet-100 px-2 py-0.5 text-[11px] font-medium text-violet-700">
Nhóm N{srcGroup} · {srcGroupLabel ? srcGroupLabel : 'chưa có trong danh mục'}
</span>
)}
</dd>
</div>
<div className="col-span-2">
<dt className="text-slate-500">Hạng mục đưa vào ({src.tenHangMucs.length})</dt>
<dd className="mt-1">
{src.tenHangMucs.length === 0 ? (
// BE dựng `Source` chỉ khi có ≥1 dòng nối ⇒ nhánh này gần như bất khả; giữ chữ
// trung tính để rỗng-vì-dữ-liệu không trông giống lỗi tải.
<span className="text-slate-400">(không hạng mục nào)</span>
) : (
<ul className="flex flex-wrap gap-1.5">
{src.tenHangMucs.map((ten, i) => (
<li
key={`${i}-${ten}`}
className="rounded border border-violet-100 bg-white px-2 py-0.5 text-[12px] text-slate-700"
>
{ten}
</li>
))}
</ul>
)}
</dd>
</div>
<div>
<dt className="text-slate-500">Σ giá duyệt chốt</dt>
{/* Dùng ĐÚNG formatter của file (`fmtMoney`) — không đẻ khuôn tiền thứ 2. */}
<dd className="mt-0.5 font-medium text-slate-900">
{src.approvedAmountTotal != null ? fmtMoney(src.approvedAmountTotal) : '—'}
</dd>
</div>
<div>
<dt className="text-slate-500">Phiếu Duyệt NCC gốc</dt>
<dd className="mt-0.5">
{src.purchaseEvaluationId ? (
<Link
to={`/purchase-evaluations/${src.purchaseEvaluationId}`}
className="font-medium text-brand-600 underline underline-offset-2 hover:text-brand-700"
>
Mở phiếu Duyệt NCC
</Link>
) : (
<span className="text-slate-400"></span>
)}
</dd>
</div>
</dl>
</section>
)}
{/* [W6 S187 · YC-023 GĐ3] Màn duyệt V2-trạm — nửa V2 của dual-render.
🔴 ĐỨNG CAO, ngay dưới khối nhận-dạng HĐ: 2 nút duyệt của nhánh V1 vốn nằm ở HEADER
dính (`sticky top-0`), nên nếu nhánh V2 đẩy thao tác duyệt xuống tận cuối trang thì
đúng người-đang-chờ-duyệt lại phải cuộn đi tìm — mất chỗ đứng cũ chứ không phải sắp
xếp lại. Cùng lý do khuôn nguồn KHKK để panel ở cột-3 luôn nhìn thấy.
Vỏ `<section className="rounded-lg border … p-5">` bám đúng khuôn các khối cùng trang
(2 app đều KHÔNG có shadcn `Card`). */}
{isV2 && (
<section className="rounded-lg border border-brand-200 bg-white p-5">
<ContractWorkflowPanel contract={c} />
</section>
)}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<MessageSquare className="h-4 w-4" />
Góp ý ({c.comments.length})
</h2>
<div className="space-y-3">
{c.comments.length === 0 && <div className="text-sm text-slate-400">Chưa góp ý.</div>}
{c.comments.map(cm => (
<div key={cm.id} className="rounded-md border border-slate-100 p-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span className="font-medium text-slate-700">{cm.userName}</span>
<span>{fmt(cm.createdAt)} · {ContractPhaseLabel[cm.phase]}</span>
</div>
<div className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{cm.content}</div>
</div>
))}
</div>
<form
className="mt-4 flex gap-2"
onSubmit={(e: FormEvent) => {
e.preventDefault()
if (!commentInput.trim()) return
addComment.mutate(commentInput.trim())
}}
>
<Textarea rows={2} placeholder="Thêm góp ý…" value={commentInput} onChange={e => setCommentInput(e.target.value)} />
<Button type="submit" disabled={addComment.isPending || !commentInput.trim()}>
Gửi
</Button>
</form>
</section>
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
{/* Chi tiết HĐ full-width — Lịch sử điều chỉnh đã move sang Panel 3 */}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<ListChecks className="h-4 w-4" />
Chi tiết ({ContractTypeLabel[c.type] ?? '—'})
</h2>
<ContractDetailsTab contract={c} />
</section>
{/* [Plan B S29 2026-05-22 Chunk E3] Section 5 — Ý kiến cấp duyệt V2 dynamic.
Mirror PE LevelOpinionsSectionV2 pattern. Chỉ render khi V2 pin
(approvalWorkflowId set). V1 legacy contract KHÔNG hiển thị. */}
{c.approvalWorkflowId && (
<section className="rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-emerald-800">
<ListChecks className="h-4 w-4" />
Ý kiến cấp duyệt (Quy trình V2)
</h2>
{(c.levelOpinions?.length ?? 0) === 0 ? (
<p className="text-sm text-slate-500">Chưa ý kiến workflow vừa bắt đu hoặc chưa ai duyệt.</p>
) : (
<ul className="space-y-3">
{c.levelOpinions!.map(o => {
const adminProxy = o.signedByUserId !== o.approverUserId
return (
<li key={o.id} className="rounded-md border border-emerald-200 bg-white p-3">
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-emerald-900">
Bước {o.stepOrder} {o.stepName ? `(${o.stepName})` : ''} Cấp {o.levelOrder}
{o.levelName ? ` (${o.levelName})` : ''}
</span>
<span className="text-slate-500">{new Date(o.signedAt).toLocaleString('vi-VN')}</span>
</div>
<p className="mb-1 text-sm text-slate-800">{o.comment}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<span>NV duyệt: <strong>{o.approverFullName ?? o.approverUserId.slice(0, 8)}</strong></span>
{adminProxy && (
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-800">
Admin duyệt thay ({o.signedByFullName ?? o.signedByUserId.slice(0, 8)})
</span>
)}
</div>
</li>
)
})}
</ul>
)}
</section>
)}
<Dialog
open={actionOpen}
onClose={() => setActionOpen(false)}
title={decision === ApprovalDecision.Reject ? 'Yêu cầu sửa' : 'Chuyển phase tiếp'}
footer={
<>
<Button variant="outline" onClick={() => setActionOpen(false)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending || !targetPhase}>
{transition.isPending ? 'Đang xử lý…' : 'Xác nhận'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">Chuyển đến phase</label>
<Select value={targetPhase} onChange={e => setTargetPhase(Number(e.target.value))}>
{availableTargets.map(p => (
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">Ghi chú (optional)</label>
<Textarea rows={3} value={comment} onChange={e => setComment(e.target.value)} />
</div>
</div>
</Dialog>
{/* [W5c S190 · YC-029] THANH NÚT ĐÁY — khuôn `KhkkDetailContent.tsx:1076-1097`
(🔴 KHÔNG lấy `KhkkWorkflowPanel`: nhánh xoá trong đó là code chết từ S175).
🔴 `sticky bottom-0`: trang HĐ dài (thông tin + nguồn KHKK + góp ý + file + chi tiết
+ ý kiến duyệt) — nút đứng cuối luồng thì phải cuộn hết mới với tới.
🔸 Hỏi bằng `window.confirm` chứ KHÔNG mở hộp thoại `Dialog` thứ hai: hộp thoại duy
nhất của file này đang giữ ĐÚNG MỘT nghĩa — chuyển phase (chọn phase + ghi chú). Xoá
không có gì để nhập (lý do chưa thu, xem mutation ở trên) nên hộp thứ hai chỉ là ô
trống 2 nút — và nó cũng làm hỏng chính ô đo C-12 vế 3 (đếm token mở hộp thoại theo
DÒNG: bình luận nhắc tên token cũng bị tính, bẫy tự-trích-dẫn S188). Câu hỏi
mang MÃ HĐ + ĐÍCH đi tới ("mục Đã xóa") nói đủ hậu quả — mã để bấm nhầm HĐ khác thì
nhìn ra, đích để không tưởng là xoá vĩnh viễn. */}
{canDelete && (
<div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
<button
type="button"
onClick={() => {
if (window.confirm(`Xóa hợp đồng ${c.maHopDong ?? c.tenHopDong ?? '(chưa có mã)'}? Hợp đồng chuyển sang mục "Đã xóa".`)) deleteContract.mutate(undefined)
}}
disabled={deleteContract.isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
{deleteContract.isPending ? 'Đang xóa…' : 'Xóa hợp đồng'}
</button>
<span className="min-w-0 flex-1 text-[12px] text-slate-500">
Xoá mềm dữ liệu không mất khỏi hệ thống.
</span>
</div>
)}
</div>
)
}