diff --git a/fe-admin/src/pages/khkk/KhkkDetailPage.tsx b/fe-admin/src/pages/khkk/KhkkDetailPage.tsx index ad3f9d9..a154832 100644 --- a/fe-admin/src/pages/khkk/KhkkDetailPage.tsx +++ b/fe-admin/src/pages/khkk/KhkkDetailPage.tsx @@ -6,7 +6,8 @@ // per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế // hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File // đính kèm (mở MỌI phase — triết lý PE S147). -// 🔴 CỐ Ý KHÔNG có panel trình/duyệt + banner cấp duyệt: transitions = W3. +// [W3 S161] +panel duyệt `KhkkWorkflowPanel` NGAY DƯỚI header (trên "Thông tin kế hoạch"): +// sơ đồ Bước→Cấp + banner đến-lượt + Gửi duyệt / Duyệt / Trả lại / Từ chối. import { useState, type ReactNode } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router-dom' @@ -31,6 +32,7 @@ import { type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue, type UpsertKhkkDossierItemInput, } from '@/types/khkk' +import { KhkkWorkflowPanel } from './KhkkWorkflowPanel' function formatVnd(n: number | null): string { if (n === null || n === undefined) return '—' @@ -316,6 +318,11 @@ export function KhkkDetailPage() { } /> + {/* [W3 S161] Panel duyệt — đặt NGAY dưới header để người duyệt thấy việc-phải-làm + trước nội dung phiếu (mirror thứ tự PE: workflow panel là thứ đầu tiên trong + màn Duyệt). `onChanged` dùng `invalidate` của trang để refetch theo id URL. */} + + {/* Section 1: Thông tin */} 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('') + + 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 + + // 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 LITERAL theo hợp-đồng LEAD chốt: đúng 2 field `action` + `comment`. + const body: KhkkTransitionInput = { action: a, comment: comment.trim() || null } + 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. +
+ )} + +