// [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 { Download, Eye, History, Paperclip } from 'lucide-react' import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog' 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) // [S175 · B1] File đang xem-trước inline (khuôn PE `PeWorkflowPanel.tsx:60`). const [previewAtt, setPreviewAtt] = useState(null) const phase = plan.phase as KhkkPhaseValue const steps = plan.workflowSteps ?? [] const approvals = plan.approvals ?? [] const isWaiting = phase === KhkkPhase.ChoDuyet const isApproved = phase === KhkkPhase.DaDuyet // 🧊 [S175] `isDraftLike` / `canSubmit` / `canDeletePlan` ĐÃ GỠ cùng 2 nút "Gửi duyệt" + // "Xóa phiếu" — panel này nay chỉ lo màn ĐANG DUYỆT (Duyệt / Trả lại). 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 approvalFiles: KhkkAttachmentDto[] = (plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) // [BE-5] Lịch sử thay đổi. // 🔴 [S175 · B1] Khối này TRƯỚC ĐÂY gập lại và query `enabled: showChangelog` (lazy — // tiết-kiệm 1 request/phiếu). Đợt này bỏ gập theo khuôn PE ⇒ **BẮT BUỘC bỏ luôn cờ // `enabled`**: giữ lại thì khối luôn hiện nhưng query KHÔNG BAO GIỜ chạy, và nó hỏng // dưới dạng "Chưa có thay đổi nào được ghi." — tức trông y hệt phiếu chưa từng sửa, // KHÔNG phải trông như lỗi. Đổi lấy: +1 request mỗi lần mở phiếu, đúng giá PE đang trả. const changelogs = useQuery({ queryKey: ['khkk-changelogs', plan.id], queryFn: async () => (await api.get(`/contract-signing-plans/${plan.id}/changelogs`, { params: { take: 200 }, })).data, }) 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()) // [S174 · A1 — LỖI THẬT, không phải style] Trước đây tải file bằng `` TRẦN. // 🔴 JWT chỉ được gắn bởi interceptor axios (`lib/api.ts:18`); thẻ `` KHÔNG đi qua axios // ⇒ request tới `[Authorize(Policy="KeHoachKyKet.Read")]` // (`ContractSigningPlansController.cs:222-223`) đi TAY KHÔNG ⇒ **401, nút không tải được**. // Bê đúng khuôn PE (`PeWorkflowPanel.tsx:327-342`): axios lấy blob → objectURL → click ngầm. // Phát hiện bởi lane L1 của ensemble; lead verify bằng đối chứng `responseType:'blob'` ở PE. async function downloadAttachment(a: KhkkAttachmentDto) { try { const r = await api.get( `/contract-signing-plans/${plan.id}/attachments/${a.id}/download`, { responseType: 'blob' }, ) const url = window.URL.createObjectURL(r.data as Blob) const link = document.createElement('a') link.href = url link.download = a.fileName link.click() window.URL.revokeObjectURL(url) } catch (e) { toast.error(getErrorMessage(e)) } } return ( // [S175 · B1 · X2] Panel TRẦN khuôn PE (`PeWorkflowPanel.tsx:345`). Trước đây bọc // `card-accent` ⇒ thẻ-trong-thẻ, lệch đập vào mắt trước mọi mục owner liệt kê. // 🔴 GỠ HẲN CLASS, KHÔNG đè `border-0`/`shadow-none`: `.card-accent` (`index.css:112`) // nằm NGOÀI `@layer` nên thắng utility Tailwind v4 — đè là vô hiệu (gotcha #66, đã cắn thật).
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}

Quy trình duyệt

{plan.workflowCode && (

{plan.workflowCode} {plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`} {plan.workflowName && <> · {plan.workflowName}}

)}
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */} {/* [S175 · B1] violet → EMERALD khuôn PE (`:360-377`). PE dành violet RIÊNG cho "⚑ Duyệt thay CEO"; dùng violet cả ở đây là trộn 2 nghĩa vào một màu. */} {plan.endedByLevelFinalize && (
{isApproved ? ( ✅ Kế hoạch đã kết thúc tại{' '} {plan.finalizeStepName ?? 'cấp duyệt'} {plan.finalizeLevelName ? <> · {plan.finalizeLevelName} : null} — không qua CEO. ) : ( ⚑ Quy trình rút gọn: duyệt đến{' '} {plan.finalizeStepName ?? 'cấp này'} {plan.finalizeLevelName ? <> · {plan.finalizeLevelName} : null} là KẾT THÚC, không trình CEO. Các cấp trước vẫn duyệt như thường. )}
)} {/* ── 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`). [S175 · B1] viền-xám-nền-trắng → EMERALD đặc khuôn PE (`:406-410`). */} {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 ─────────────────────────────────────────── */} {/* 🧊 [S175 — anh: "Bỏ hết đi" + "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi"] "Gửi duyệt" và "Xóa phiếu" ĐÃ RỜI khỏi đây → nay nằm ở THANH NÚT ĐÁY của `KhkkDetailContent`. Panel này từ nay CHỈ còn **Duyệt / Trả lại** — đúng phạm vi "màn đang duyệt". Máy `KhkkTransitionAction.Submit` vẫn nguyên ở BE và ở thanh đáy; gỡ ở đây chỉ là gỡ ĐƯỜNG BẤM TRÙNG, không phải bỏ chức năng. */} {isWaiting && (
{isWaiting && ( <> {/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối ~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện (BE gỡ khỏi `nextPhases` + FE lọc, UAT S60) ⇒ KHKK còn hiện là lệch. 🔴 Chỉ gỡ ĐƯỜNG BẤM ở UI. Máy BE (`KhkkTransitionAction.Reject`, phase `TuChoi=99`) GIỮ NGUYÊN: phiếu cũ đã từ-chối vẫn hiển thị đúng, và mở lại sau này chỉ là thêm nút. Gỡ cả máy = mất dữ-liệu lịch sử. */} )}
)} {/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */} {/* [S175 · B1] +`border-t pt-4` +`

` +nút `` xem trước. Route BE ĐÃ CÓ `ContractSigningPlansController.cs:231` (`/view`, cùng hình với PE `:317`) ⇒ chỉ cần truyền `basePath` cho dialog dùng chung. 🔴 Nhãn "Tải xuống" chuyển vào `title=` chứ KHÔNG bỏ: giữ đúng chuỗi đó trong bundle để phép verify prod còn control-dương soi được. */} {approvalFiles.length > 0 && (

📎 File đính kèm khi duyệt

File do người duyệt tải lên trong quá trình duyệt.

{approvalFiles.map(a => (
{a.fileName} {formatSize(a.fileSize)} {isPreviewable(a.fileName) && ( )}
))}
{previewAtt && ( setPreviewAtt(null)} /> )}
)} {/* ── 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. */} {/* [S175 · B1] Bỏ pill đếm → `(n)` trong ngoặc · thẻ `bg-slate-50/60 text-[11px]` → `bg-white p-3 text-sm` · mốc-giờ ĐẨY PHẢI bằng `justify-between`. 🔴 Badge 2-ngả → 3-NGẢ: trước đây MỌI thứ không phải `Reject` đều tô emerald ⇒ "Trả lại" (amber ở PE) bị vẽ xanh như đã-duyệt — đọc ngược nghĩa. `toPhase` đã có sẵn (`khkk.ts:307`) nên phân được TraLai ⟂ TuChoi ⟂ Duyệt. */}

Lịch sử duyệt ({approvals.length})

{approvals.length === 0 ? (

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

) : (
    {approvals.map(a => { const isReject = a.toPhase === KhkkPhase.TuChoi const isReturn = a.toPhase === KhkkPhase.TraLai return (
  • {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 ────────────────────────────────────────────── */} {/* [S175 · B1] BỎ GẬP — PE render thẳng (`PeDetailTabs.tsx:787-794`). Nội-dung đã tải sẵn qua query; giấu sau 1 cú bấm chỉ thêm ma-sát, không tiết-kiệm gì. */}

Lịch sử thay đổi

{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 && (
)} {/* [S175 · B1 micro-copy] Nhãn Ô NHẬP theo PE (`PeWorkflowPanel.tsx:869` "Ghi chú (tùy chọn)"). 🔴 CHỈ đổi tên TRƯỜNG — chuỗi "Ý kiến cấp duyệt" ở dòng trợ-giúp bên dưới là TÊN THẬT của mục đích đến trong phiếu (Section 5, Mig 26), đổi nó là trỏ người dùng tới một mục không tồn tại. */}