// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ. // File MIRROR SHA256 identical với fe-admin counterpart. // // Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn" // (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến. // ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt, // KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là việc BE làm ở finalize // choke-point, KHÔNG phải việc của panel này. // // 🔴 [K3 S164/S165 — SUPERSEDE] Câu cũ ở đây khai "KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize / // CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)". KHAI ĐÓ ĐÃ HẾT HIỆU LỰC: // owner ĐẢO phán quyết S155 (@S164 — "a Chương quyết cái gì cần CEO") ⇒ BE **đã port nhánh finalize cho // KHKK ở K3 cùng wave**, và Designer type-10 đã GỠ rào (`ApprovalWorkflowsV2Page.tsx` — hằng // `earlyFinalizeLocked` xoá hẳn, 2 ô tick sống lại). Đừng đọc file này thành "BE không hỗ trợ". // 🔴 [F-1 S166 — SUPERSEDE tiếp] Câu cũ "panel không có control riêng cho đường kết-thúc-sớm" HẾT hiệu lực: // gate-K3 chấm nó là F-1 MAJOR (BE `?? true` mà 0 UI gửi false ⇒ trạm CEO chỉ đi được bằng admin bỏ cờ // Designer). Nay panel CÓ ô-tích "Cấp này KẾT THÚC" theo khuôn PE-LIVE S97 = OPT-IN (G-1 reviewer: // PE `useState(false)` :61 — owner đã ĐẢO opt-out S96 → opt-in S97; nhãn cũ "khuôn S96" trích trúng // chú-thích chết). Default KHÔNG tick ⇒ gửi false ⇒ trình tiếp; TÍCH ⇒ true ⇒ BE kết thúc tại cấp. // Ratify default treo 1 câu ở sổ CHỜ-ANH (lật = 1 edit); hành vi mặc định trạm CCM ĐỔI so trước-F-1. // // 🔴 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Ô (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`. // Panel này TỰ SUY trạng thái từ cặp con-trỏ: // • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order // • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order` // (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical // `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN // bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.) import { useState } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { Check, MessageSquare, Send, 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_PHASE_LABELS, KhkkPhase, KhkkTransitionAction, 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') } // 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: '○', } export function KhkkWorkflowPanel({ plan, onChanged, }: { plan: KhkkDetailDto /** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */ onChanged?: () => void }) { const qc = useQueryClient() const { user } = useAuth() const [action, setAction] = useState(null) const [comment, setComment] = useState('') // [F-1 S166 · G-1 reviewer] Ô-tích level-finalize theo khuôn PE-LIVE S97 = OPT-IN // (`PeWorkflowPanel.tsx:61` useState(false) — owner đảo opt-out S96 → opt-in S97 "cái này option // thôi"; đừng trích comment S96 chết ở `:769-771` file đó). Default UNCHECKED = duyệt xong TRÌNH // TIẾP cấp sau (fail-mode an toàn: không đọc thì phiếu vẫn lên CEO); TÍCH mới KẾT THÚC tại cấp. // ⚠️ Đổi hành vi mặc định trạm CCM so trước-F-1 (BE `?? true` auto-finalize) — treo 1 câu ratify // owner ở sổ CHỜ-ANH; lật lại = đổi 1 chữ false→true + 2 nhãn dưới. const [applyLevelFinalize, setApplyLevelFinalize] = useState(false) const phase = plan.phase as KhkkPhaseValue const steps = plan.workflowSteps ?? [] const opinions = plan.levelOpinions ?? [] const isWaiting = phase === KhkkPhase.ChoDuyet const isApproved = phase === KhkkPhase.DaDuyet const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai const curStepIdx = plan.currentWorkflowStepIndex const curLevelOrder = plan.currentApprovalLevelOrder // Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done; // Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng). 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 — // 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người. 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) // Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`). const actorInLevel = isAdmin || actorIsCurrentApprover const blockedByLevel = isWaiting && !actorInLevel // [F-1 S166] Ô-tích opt-out hiện theo cờ của ĐÚNG level BE sẽ chấm — mirror `ResolveActingLevel` // (`ContractSigningPlanWorkflowService.cs:455-469`): own-level TRƯỚC (kể cả Admin có slot riêng), // 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 BE là `CreatedBy == actor ∨ DeptManager(cùng phòng) ∨ Admin` // (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles} // (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với // đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155). // Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình. const canSubmit = isDraftLike && !!plan.approvalWorkflowId const transition = useMutation({ mutationFn: async (a: KhkkTransitionActionValue) => { // 🔴 BODY theo hợp-đồng LEAD chốt W3 (`action` + `comment`) + [F-1 S166] field thứ 3 // `applyLevelFinalize` CHỈ đính kèm khi approve: cấp có cờ gửi giá trị ô-tích, cấp thường // gửi true = no-op (mirror PE `PeWorkflowPanel.tsx:259`; BE `bool? = null` ⇒ absent = true). 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, ) 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('') qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] }) qc.invalidateQueries({ queryKey: ['khkk-list'] }) onChanged?.() }, 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' // Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE). const commentRequired = action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject const confirmDisabled = transition.isPending || (commentRequired && !comment.trim()) return (

Quy trình duyệt

{plan.workflowCode && ( {plan.workflowCode} {plan.workflowName && · {plan.workflowName}} )}
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */} {steps.length > 0 ? (
    {steps.map((step, idx) => { const st = stepStatus(idx) return (
  1. {STATUS_ICON[st]} Bước {step.order} — {step.name}
    {step.levels.length > 0 && (
      {step.levels.map(lv => { const ls = levelStatus(idx, lv.order) const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id) return (
    • {STATUS_ICON[ls]}
      {lv.name || `Cấp ${lv.order}`} {ls === 'Current' && đang chờ} {ls === 'Done' && đã duyệt}
      {lv.approverFullName ?? '(chưa cấu hình)'} {signed && ( ✓ ký {formatDateTime(signed.signedAt)} )}
    • ) })}
    )}
  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. Chỉ có nghĩa khi ĐANG chờ duyệt. */} {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.
)} {/* Hành động */} {(canSubmit || isWaiting) && (
{canSubmit && ( )} {isDraftLike && !plan.approvalWorkflowId && ( Chưa pin quy trình duyệt — không gửi duyệt được. )} {isWaiting && ( <> )}
)} {/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */} {opinions.length > 0 && (
Ý kiến cấp duyệt {opinions.length}
    {opinions.map(o => (
  • {o.signedByFullName} · {o.stepName ? `Bước ${o.stepOrder} — ${o.stepName}` : 'Cấp duyệt'} {o.levelOrder != null && ` · Cấp ${o.levelOrder}`} · {formatDateTime(o.signedAt)} {/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */} {o.approverUserId && o.approverUserId !== o.signedByUserId && ( duyệt thay )}
    {o.comment?.trim() || '(duyệt — không ý kiến)'}
  • ))}
)}
{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.
)} {/* [F-1 S166 · G-1/G-5 reviewer] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) = ô-tích OPT-IN, khuôn PE-LIVE S97 (`PeWorkflowPanel.tsx:61` useState(false) + JSX `:772-791`; comment "opt-out S96" ngay trên khối JSX đó là chú-thích CHẾT — G-1 bắt đúng bẫy này): default KHÔNG tick = duyệt xong TRÌNH TIẾP cấp sau; TÍCH = gửi applyLevelFinalize=true ⇒ BE kết thúc tại cấp. Không viết "(CEO)" — cấp-sau là gì tuỳ cấu hình quy trình (G-5). KHÁC PE: không nhắc "chọn giá chốt" — KHKK chốt giá per-dòng do BE tự làm ở finalize choke-point (`ApplyApprovedValuesOnFinalize`), panel không có price-picker. */} {action === KhkkTransitionAction.Approve && approverFinalizeEligible && (
)}