// [S171 KHKK 3-panel] PANEL 3 (phải) — quy trình duyệt + lịch sử của "Kế hoạch ký kết HĐ". // DỜI từ thư mục trang KHKK (file cùng tên) sang `components/khkk/` (cùng lớp với // `components/pe/PeWorkflowPanel.tsx` của Duyệt NCC — panel dùng ở NHIỀU host thì không // thuộc về một trang). File MIRROR SHA256 identical với fe-admin counterpart. // // 🔴 Khối này là bản thứ 3 của cùng một hình (PE `PeDetailTabs.tsx:705-774` · HĐ // `components/contracts/WorkflowHistoryPanel.tsx`). Gộp được KHI VÀ CHỈ KHI BE 3 module // thống-nhất shape `status` precompute — trước đó gộp chỉ đẻ `if (module === …)`. // // 7 khối theo bố-cục đích (mirror Ảnh 2 của Duyệt NCC): // 1 "Quy trình duyệt" + meta {code} v{NN} · {name} // 2 Banner kết-thúc-sớm ("không qua CEO") // 3 Sơ đồ Bước → Cấp → NV (chip phòng · ⚑ Duyệt thay CEO · mờ nhánh không chạy · liệt-kê NV) // 4 Nút Duyệt / Trả lại / Từ chối + Dialog + nút Xóa phiếu // 5 📎 File đính kèm khi duyệt (upload THẬT — owner chốt QĐ-3) // 6 Lịch sử duyệt (N) // 7 Lịch sử thay đổi // // 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do // BE precompute. KHKK BE trả CÂY THÔ — panel này TỰ SUY từ cặp con-trỏ: // • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) // • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order` // Luật so khớp OR-of-N canonical (BE `ContractSigningPlanFeatures.cs:259-260`): // `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror bằng // `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến. // // [R-8] KHKK KHÔNG có phiếu V1-legacy ⇒ KHÔNG port nhánh fallback V1 của PE (code chết). // [ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH] KHÔNG price-picker (KHKK chốt giá per-Line ở choke-point BE // `ApplyApprovedValuesOnFinalize`), KHÔNG badge "✎ NS PRO/CCM" (khái niệm riêng PE, Mig 50), // và đợt này giữ 1 return-mode (về Bước 1 · Cấp 1) — 4 mode của PE là món riêng, còn treo. // 🔴 [F-1 S166 · RATIFIED @S167] Ô-tích "Cấp này KẾT THÚC" = OPT-IN theo khuôn PE-LIVE S97 // (`PeWorkflowPanel.tsx:61` useState(false)). Default KHÔNG tick ⇒ trình tiếp cấp sau. import { useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { Check, History, Paperclip, Send, Trash2, Undo2, Workflow, X } from 'lucide-react' import { Button } from '@/components/ui/Button' import { Dialog } from '@/components/ui/Dialog' import { Label } from '@/components/ui/Label' import { Textarea } from '@/components/ui/Textarea' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { useAuth } from '@/contexts/AuthContext' import { KHKK_CHANGELOG_ACTION_LABELS, KHKK_CHANGELOG_ENTITY_TYPE_LABELS, KHKK_PHASE_LABELS, KhkkApprovalDecision, KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkApprovalDto, type KhkkAttachmentDto, type KhkkChangelogDto, type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue, type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto, } from '@/types/khkk' type NodeStatus = 'Done' | 'Current' | 'Pending' function formatDateTime(iso: string): string { return new Date(iso).toLocaleString('vi-VN') } function formatSize(b: number): string { return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB` } // Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge). const STATUS_DOT: Record = { Done: 'bg-emerald-500 text-white', Current: 'bg-brand-600 text-white', Pending: 'bg-slate-200 text-slate-500', } const STATUS_BOX: Record = { Done: 'border-emerald-200 bg-emerald-50/40', Current: 'border-brand-300 bg-brand-50/50', Pending: 'border-slate-200 bg-white', } const STATUS_ICON: Record = { Done: '✓', Current: '●', Pending: '○', } const DECISION_LABEL: Record = { 0: 'Gửi duyệt', 1: 'Duyệt', 2: 'Trả lại / Từ chối', 3: 'Tự động duyệt', } export function KhkkWorkflowPanel({ plan, onChanged, onDeleted, }: { plan: KhkkDetailDto /** gọi sau khi transition thành công — caller refetch detail/list theo id của nó. */ onChanged?: () => void /** gọi sau khi XÓA phiếu — host 3-panel bỏ chọn, trang chi tiết quay về danh sách. */ onDeleted?: () => void }) { const qc = useQueryClient() const { user } = useAuth() const [action, setAction] = useState(null) const [comment, setComment] = useState('') const [applyLevelFinalize, setApplyLevelFinalize] = useState(false) // [QĐ-3 / BE-7] File người duyệt đính kèm ngay trong dialog duyệt. const [pendingFiles, setPendingFiles] = useState([]) const fileInputRef = useRef(null) const [confirmDelete, setConfirmDelete] = useState(false) const phase = plan.phase as KhkkPhaseValue const steps = plan.workflowSteps ?? [] const approvals = plan.approvals ?? [] const isWaiting = phase === KhkkPhase.ChoDuyet const isApproved = phase === KhkkPhase.DaDuyet const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi const curStepIdx = plan.currentWorkflowStepIndex const curLevelOrder = plan.currentApprovalLevelOrder // Chữ ký thật: tra theo `approvalWorkflowLevelId` của các hàng ĐÃ DUYỆT trong lịch sử duyệt // (BE đã lọc `Pending`). Dùng nguồn này thay vì ý-kiến vì ý-kiến sống ở panel 2 (D-3). const signedByLevelId = useMemo(() => { const m = new Map() for (const a of approvals) { if (a.approvalWorkflowLevelId && a.decision !== KhkkApprovalDecision.Reject) { if (!m.has(a.approvalWorkflowLevelId)) m.set(a.approvalWorkflowLevelId, a) } } return m }, [approvals]) const stepStatus = (idx: number): NodeStatus => { if (isApproved) return 'Done' if (!isWaiting || curStepIdx == null) return 'Pending' if (idx < curStepIdx) return 'Done' if (idx === curStepIdx) return 'Current' return 'Pending' } const levelStatus = (idx: number, order: number): NodeStatus => { const s = stepStatus(idx) if (s !== 'Current' || curLevelOrder == null) return s if (order < curLevelOrder) return 'Done' if (order === curLevelOrder) return 'Current' return 'Pending' } // 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này. const currentLevels: KhkkWorkflowLevelDto[] = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null ? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder) : [] const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length ? steps[curStepIdx] : null const isAdmin = user?.roles?.includes('Admin') ?? false const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id) const actorInLevel = isAdmin || actorIsCurrentApprover const blockedByLevel = isWaiting && !actorInLevel // Ô-tích hiện theo cờ của ĐÚNG level BE sẽ chấm — mirror `ResolveActingLevel` // (`ContractSigningPlanWorkflowService.cs:455-469`): own-level TRƯỚC, Admin không có own // ⇒ level ĐẦU nhóm cùng-order. Soi cờ của level KHÁC là hiện ô-tích nói dối. const actingLevel = currentLevels.find(l => l.approverUserId === user?.id) ?? (isAdmin ? currentLevels[0] : undefined) const approverFinalizeEligible = actingLevel?.allowApproverFinalize === true // Nút "Gửi duyệt": guard THẬT ở BE (`CreatedBy ∨ DeptManager ∨ Admin`). FE KHÔNG suy được // vế DeptManager ⇒ CỐ Ý không ẩn nút theo vai (ẩn = giấu nút với đúng người có quyền, im // lặng — bug-class gotcha #44). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình. const canSubmit = isDraftLike && !!plan.approvalWorkflowId const approvalFiles: KhkkAttachmentDto[] = (plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) // [BE-5] Lịch sử thay đổi — chỉ tải khi người dùng mở khối (không tốn 1 request/phiếu). const [showChangelog, setShowChangelog] = useState(false) const changelogs = useQuery({ queryKey: ['khkk-changelogs', plan.id], queryFn: async () => (await api.get(`/contract-signing-plans/${plan.id}/changelogs`, { params: { take: 200 }, })).data, enabled: showChangelog, }) const transition = useMutation({ mutationFn: async (a: KhkkTransitionActionValue) => { const body: KhkkTransitionInput = { action: a, comment: comment.trim() || null, ...(a === KhkkTransitionAction.Approve ? { applyLevelFinalize: approverFinalizeEligible ? applyLevelFinalize : true } : {}), } const res = await api.post(`/contract-signing-plans/${plan.id}/transitions`, body) // [QĐ-3] File đính kèm khi duyệt — upload SAU khi transition thành công (transition // hỏng thì không đẻ file mồ côi). BE tự ép `purpose=ApprovalAttachment`, FE KHÔNG gửi // số enum lên (hợp-đồng lane BE §5). for (const f of pendingFiles) { const fd = new FormData() fd.append('file', f) await api.post(`/contract-signing-plans/${plan.id}/approval-attachments`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, }) } return res.data }, onSuccess: (res) => { const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}` toast.success(`Đã cập nhật — trạng thái: ${label}.`) setAction(null) setComment('') setPendingFiles([]) qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] }) qc.invalidateQueries({ queryKey: ['khkk-list'] }) qc.invalidateQueries({ queryKey: ['khkk-changelogs', plan.id] }) qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] }) onChanged?.() }, onError: (e) => toast.error(getErrorMessage(e)), }) const removePlan = useMutation({ mutationFn: async () => api.delete(`/contract-signing-plans/${plan.id}`), onSuccess: () => { toast.success('Đã xóa kế hoạch') setConfirmDelete(false) qc.invalidateQueries({ queryKey: ['khkk-list'] }) qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] }) onDeleted?.() }, onError: (e) => toast.error(getErrorMessage(e)), }) const dialogTitle = action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết' : action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết' : action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa' : '✗ Từ chối kế hoạch ký kết' const commentRequired = action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject const confirmDisabled = transition.isPending || (commentRequired && !comment.trim()) return (
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}

Quy trình duyệt

{plan.workflowCode && ( {plan.workflowCode} {plan.workflowVersion != null && ` v${plan.workflowVersion}`} {plan.workflowName && · {plan.workflowName}} )}
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */} {plan.endedByLevelFinalize && (
⚑ Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'} {plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''}
{isApproved ? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.' : 'Cấp này được cấu hình duyệt là KẾT THÚC — nếu người duyệt tích ô kết thúc thì kế hoạch không qua CEO.'}
)} {/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */} {steps.length > 0 ? (
    {steps.map((step, idx) => { const st = stepStatus(idx) // Gom Cấp CÙNG `order` thành MỘT dòng — OR-of-N: N người, chỉ 1 chữ ký là tiến. const orders = [...new Set(step.levels.map(l => l.order))].sort((a, b) => a - b) return (
  1. {STATUS_ICON[st]} Bước {step.order} — {step.name} {/* Chip PHÒNG của Bước (BE-4 join `Departments`). */} {step.departmentName && ( {step.departmentName} )}
    {orders.length > 0 && (
      {orders.map(order => { const group = step.levels.filter(l => l.order === order) const signedRows = group.map(l => signedByLevelId.get(l.id)).filter(Boolean) as KhkkApprovalDto[] const hasSigned = signedRows.length > 0 const finalizeHere = group.some(l => l.allowApproverFinalize) // Nhánh KHÔNG CHẠY: phiếu đã duyệt bằng đường kết-thúc-sớm ⇒ các cấp // sau đó không ai ký. Vẽ MỜ + trạng thái ○ thay vì bịa ✓ cho cả cây. const skipped = isApproved && plan.endedByLevelFinalize && !hasSigned const ls: NodeStatus = skipped ? 'Pending' : levelStatus(idx, order) return (
    • {STATUS_ICON[ls]}
      {group[0]?.name || `Cấp ${order}`} {ls === 'Current' && đang chờ} {ls === 'Done' && đã duyệt} {skipped && không phải qua} {finalizeHere && ( ⚑ Duyệt thay CEO )}
      {/* [F-3] Liệt-kê NV của cấp: người ĐÃ KÝ in đậm, NV cùng cấp còn lại `/ Tên` mờ — mirror `PeWorkflowPanel.tsx:453-465`. */}
      {group.length === 0 ? '(chưa cấu hình)' : group.map((l, i) => { const sign = signedByLevelId.get(l.id) return ( {i > 0 && /} {l.approverFullName ?? '(chưa cấu hình)'} {sign && ( ✓ {formatDateTime(sign.approvedAt)} )} ) })}
    • ) })}
    )}
  2. ) })}
) : (
Quy trình duyệt chưa cấu hình Bước/Cấp — liên hệ Admin để dựng quy trình loại “Kế hoạch ký kết HĐ” trước khi trình duyệt.
)} {/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. */} {isWaiting && currentStep && (
Đang chờ Bước {currentStep.order} ({currentStep.name}) — Cấp {curLevelOrder}
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
{actorInLevel ?
✓ Đến lượt bạn duyệt
:
⚠ Không phải lượt bạn — chỉ người trên mới thao tác cấp này
}
)} {phase === KhkkPhase.TraLai && (
⚠ Kế hoạch bị TRẢ LẠI — sửa nội dung rồi bấm “Gửi duyệt”, quy trình chạy lại từ Bước 1 · Cấp 1.
)} {phase === KhkkPhase.TuChoi && (
✗ Kế hoạch đã bị TỪ CHỐI — không thao tác được nữa. Lập kế hoạch mới nếu cần làm lại.
)} {/* ── 4. Hành động + nút Xóa ─────────────────────────────────────────── */} {(canSubmit || isWaiting || canDeletePlan) && (
{canSubmit && ( )} {isDraftLike && !plan.approvalWorkflowId && ( Chưa pin quy trình duyệt — không gửi duyệt được. )} {isWaiting && ( <> )} {canDeletePlan && ( )}
)} {/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */} {approvalFiles.length > 0 && (
File đính kèm khi duyệt {approvalFiles.length}
    {approvalFiles.map(a => (
  • {a.fileName}
    {formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}
    Tải xuống
  • ))}
)} {/* ── 6. Lịch sử duyệt (N) ───────────────────────────────────────────── */} {/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn còn nguyên ở "Lịch sử thay đổi" bên dưới. */}
Lịch sử duyệt {approvals.length}
{approvals.length === 0 ? (

Chưa có lượt duyệt nào.

) : (
    {approvals.map(a => (
  • {DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'} {a.approvedByFullName ?? '(hệ thống)'} · {formatDateTime(a.approvedAt)}
    {a.stepName ? `Bước ${a.stepOrder} — ${a.stepName}` : 'Cấp duyệt'} {a.levelOrder != null && ` · Cấp ${a.levelOrder}`} {a.levelName ? ` (${a.levelName})` : ''} {' '}· {KHKK_PHASE_LABELS[a.fromPhase]} → {KHKK_PHASE_LABELS[a.toPhase]}
    {a.comment?.trim() && (
    {a.comment}
    )}
  • ))}
)}
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
{showChangelog && (
{changelogs.isLoading &&

Đang tải…

} {changelogs.isError &&

Không tải được lịch sử thay đổi.

} {changelogs.data && changelogs.data.length === 0 && (

Chưa có thay đổi nào được ghi.

)}
    {(changelogs.data ?? []).map(c => (
  • {KHKK_CHANGELOG_ACTION_LABELS[c.action] ?? 'Thay đổi'} · {KHKK_CHANGELOG_ENTITY_TYPE_LABELS[c.entityType] ?? 'Khác'} · {c.userName ?? '(hệ thống)'} · {formatDateTime(c.createdAt)}
    {(c.summary || c.contextNote) && (
    {c.summary} {c.contextNote ? ` — ${c.contextNote}` : ''}
    )}
  • ))}
)}
{action !== null && ( setAction(null)} title={dialogTitle} footer={<> } > {action === KhkkTransitionAction.Submit && (
Kế hoạch chuyển sang “Chờ duyệt” và trình lên Bước 1 · Cấp 1 của quy trình {plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa được nữa.
)} {action === KhkkTransitionAction.Return && (
Kế hoạch về trạng thái “Trả lại”. Người soạn sửa xong gửi lại thì quy trình chạy từ Bước 1 · Cấp 1.
)} {action === KhkkTransitionAction.Reject && (
⚠ Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
)} {action === KhkkTransitionAction.Approve && approverFinalizeEligible && (
)}