484 lines
26 KiB
TypeScript
484 lines
26 KiB
TypeScript
// 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 HĐ</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ý kết
|
||
</h2>
|
||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||
<div className="col-span-2">
|
||
<dt className="text-slate-500">Mã 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 HĐ ({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 có 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 có 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 có ý kiến — workflow vừa bắt đầu hoặc chưa có 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>
|
||
)
|
||
}
|
||
|