Files
solution-erp/fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx
pqhuy1987 99cabd8ebe
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m11s
[CLAUDE] Contract: KHKK UAT S175 dot-F — gom nut thao tac + hang muc du an + loc nhom (0-mig)
Anh review truc tiep tren prod, khoanh do tung cho. Land tron cum:

=== 1. GOM NUT THAO TAC (anh: "Bo het di" + "phia duoi them cac nut thao tac"
       + "cho menu dang duyet -> Duyet/Tra lai vay thoi") ===
· THANH NUT DAY moi o `KhkkDetailContent` (sticky bottom-0, khuon PE workspace):
  [Xoa phieu] · dong "Cac thay doi da tu dong luu" · [Luu & Gui duyet ->]
· Panel 3 GO "Gui duyet" + "Xoa phieu" => nay CHI con **Duyet / Tra lai**.
· GO nut "+ Lap ke hoach" (header) · nut "Sua" (muc 1) · dai banner "Dang xem ke hoach".
🔴 THU TU CO CHU DICH: KHONG go "Gui duyet" o panel 3 TRUOC khi thanh day co mat — go truoc
  la nguoi soan mat duong trinh duyet, phieu ket o Nhap. Vi vay ca cum di CHUNG 1 commit.
🔴 Thanh day chi om nhanh **Submit** — nhanh Approve/Return (o-tich level-finalize + upload
  file sau transition) O LAI panel 3. Nhan ban nhanh do sang day la de su-that thu hai cho
  duong DUYET.
🔸 KHONG lam nut "Luu" roi: moi o o man nay luu NGAY khi roi o. Them nut khong lam gi la
  hua hao — noi thang bang dong chu ben canh thay vi ve nut gia.
🔸 Giu lai dung 1 dong nho "Ve form tao" — bo han thi mo 1 phieu la ket, khong co duong lui.
· `onDeleted` noi vao ca 3 host (List / Create / Detail) de xoa xong khong dung o phieu ma.

=== 2. HANG MUC CUA DU AN (anh: "hang muc cua Du an -. Duyet NCC ay") ===
🔴 Lead bay NHAM o luot truoc: bay `line.tenHangMuc` = danh-muc **SP-002** (86 dong, dung
  phan loai HOP DONG). Anh muon **hang muc CONG VIEC cua du an** tu phieu Duyet NCC
  (`PE.WorkItemId`). Hai thu trung ten tieng Viet nhung KHAC TRUC — tron la chi sai goi thau.
· BE: `ContractSigningPlanDetailDto` +`PeWorkItemName`; handler join `WorkItems` (loose-Guid
  Mig 49, KHONG FK vat ly => join tay null-safe).
· FE: o "Hang muc (du an)" doc `plan.peWorkItemName`; go het khoi liet-ke SP-002 khoi muc 1.

=== 3. MUC 2 RUT VE "HANG MUC + GIA -> TONG" ===
Anh: "Cho nay doi lai la hang muc la dc roi, chon hang muc tien -> thanh tong con lai bo het"
 + "cho chon nhieu dong nhap gia thanh gia tong, ko can so sanh j voi so duyet PRO hay CCM".
· Tieu de: "2. Gia de xuat theo NCC trung thau" -> "2. Hang muc".
· Cot: 6 -> 3 (HANG MUC | GIA | thao-tac). GO **Nha cung cap · Gia tham chieu · Gia duyet**.
· Ghi chu xuong DONG PHU duoi o hang muc (van nhap duoc, khong chiem cot).
· +hang TONG = Sigma gia cac dong.
🔸 Du-lieu 2 cot go di VAN trong DTO; `peReferenceAmount` con dung lam DAU phan biet
  dong-goc / dong-them-tay o nut xoa.

=== 4. LOC THEO NHOM (anh: "Loc theo quy trinh luon") ===
Tang duoi man Thao tac hien phieu N1/N7 trong khi dang dung o leaf N2 => doc nham "nhom nay
dang co viec". Nay loc theo `approvalGroup` cua leaf menu. `group == null` => khong loc
(giu duong cu cho host khong truyen).

=== 5. GO DAI ACCENT + 2 O LINK HO SO/GHI CHU ===
· `card-accent` boc `KhkkDetailContent` GO HAN (dai xanh doc man Thao tac). GO CLASS chu
  KHONG de `border-0` — class nay ngoai `@layer` nen thang utility (gotcha #66).
· Khoi "Link ho so + Ghi chu" GO TRON ca 2 nhanh ternary. Luot truoc lead moi go o luoi tren
  nen 2 o van hien o khoi duoi — anh chup lai dung cho do.

=== CON NO ===
🔴 Cay man Thao tac: anh muon **Nam > Du an > Hang muc > 4 giai doan** (giong cay o man Danh
  sach). Hien moi co Du an > Hang muc (gap/mo duoc). Chua land.
🔴 CHUA soi mat prod 375/1366/1440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:47:12 +07:00

760 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// [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<NodeStatus, string> = {
Done: 'bg-emerald-500 text-white',
Current: 'bg-brand-600 text-white',
Pending: 'bg-slate-200 text-slate-500',
}
const STATUS_BOX: Record<NodeStatus, string> = {
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<NodeStatus, string> = {
Done: '✓',
Current: '●',
Pending: '○',
}
const DECISION_LABEL: Record<number, string> = {
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<KhkkTransitionActionValue | null>(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<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false)
// [S175 · B1] File đang xem-trước inline (khuôn PE `PeWorkflowPanel.tsx:60`).
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(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<string, KhkkApprovalDto>()
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<KhkkChangelogDto[]>(`/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<KhkkTransitionResult>(`/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 `<a href="/api/…">` TRẦN.
// 🔴 JWT chỉ được gắn bởi interceptor axios (`lib/api.ts:18`); thẻ `<a>` 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).
<div className="space-y-4">
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
{plan.workflowCode && (
<p className="mt-0.5 font-mono text-[11px] text-slate-500">
{plan.workflowCode}
{plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`}
{plan.workflowName && <> · <span className="font-sans">{plan.workflowName}</span></>}
</p>
)}
</div>
{/* ── 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 && (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
{isApproved ? (
<span>
Kế hoạch đã kết thúc tại{' '}
<span className="font-semibold">{plan.finalizeStepName ?? 'cấp duyệt'}</span>
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} không qua CEO.
</span>
) : (
<span>
Quy trình rút gọn: duyệt đến{' '}
<span className="font-semibold">{plan.finalizeStepName ?? 'cấp này'}</span>
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} KẾT THÚC,
không trình CEO. Các cấp trước vẫn duyệt như thường.
</span>
)}
</div>
)}
{/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */}
{steps.length > 0 ? (
<ol className="space-y-2">
{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 (
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
<div className="flex flex-wrap items-center gap-1.5 text-xs font-medium">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
{STATUS_ICON[st]}
</span>
<span className="text-slate-800">Bước {step.order} {step.name}</span>
{/* 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 && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName}
</span>
)}
</div>
{orders.length > 0 && (
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{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 (
<li key={`${step.id}-${order}`} className={cn('text-[11px]', skipped && 'opacity-50')}>
<div className="flex items-start gap-1.5">
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
{STATUS_ICON[ls]}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{group[0]?.name || `Cấp ${order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
{skipped && <span className="ml-1.5 text-[10px] font-normal text-slate-500">không phải qua</span>}
{finalizeHere && (
<span
className="ml-1.5 inline-flex items-center rounded bg-violet-100 px-1.5 py-0.5 text-[9px] font-semibold text-violet-800"
title="Cấp này duyệt là KẾT THÚC quy trình — duyệt THAY CEO, không trình lên Ban Giám đốc"
>
Duyệt thay CEO
</span>
)}
</div>
{/* [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`. */}
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
{group.length === 0
? '(chưa cấu hình)'
: group.map((l, i) => {
const sign = signedByLevelId.get(l.id)
return (
<span key={l.id} className="inline-flex items-center gap-1">
{i > 0 && <span className="text-slate-300">/</span>}
<span className={cn(sign ? 'font-semibold text-slate-800' : 'text-slate-400')}>
{l.approverFullName ?? '(chưa cấu hình)'}
</span>
{sign && (
<span className="text-[10px] text-emerald-600"> {formatDateTime(sign.approvedAt)}</span>
)}
</span>
)
})}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
) : (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
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
&ldquo;Kế hoạch kết &rdquo; trước khi trình duyệt.
</div>
)}
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. */}
{isWaiting && currentStep && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInLevel ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {currentStep.order} ({currentStep.name}) Cấp {curLevelOrder}
</div>
<div className="mt-0.5">
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
</div>
{actorInLevel
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ người trên mới thao tác cấp này</div>}
</div>
)}
{phase === KhkkPhase.TraLai && (
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
Kế hoạch bị TRẢ LẠI sửa nội dung rồi bấm &ldquo;Gửi duyệt&rdquo;, quy trình chạy lại từ Bước 1 · Cấp 1.
</div>
)}
{phase === KhkkPhase.TuChoi && (
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
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.
</div>
)}
{/* ── 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 && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{isWaiting && (
<>
<button
type="button"
disabled={blockedByLevel}
onClick={() => {
if (!blockedByLevel) {
setComment('')
setPendingFiles([])
// reset về default KHÔNG-tick (opt-IN, khuôn PE S97) mỗi lần mở dialog.
setApplyLevelFinalize(false)
setAction(KhkkTransitionAction.Approve)
}
}}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn(
'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)}
>
Duyệt
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn(
'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50',
)}
>
Trả lại
</button>
{/* 🧊 [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ử. */}
</>
)}
</div>
</div>
)}
{/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */}
{/* [S175 · B1] +`border-t pt-4` +`<h3 text-sm>` +nút `<Eye>` 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 && (
<div className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
<p className="mt-0.5 text-[11px] text-slate-500">File do người duyệt tải lên trong quá trình duyệt.</p>
<div className="mt-2 space-y-1">
{approvalFiles.map(a => (
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
{isPreviewable(a.fileName) && (
<button
type="button"
onClick={() => setPreviewAtt(a)}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
<Eye className="h-3 w-3" />
</button>
)}
<button
type="button"
onClick={() => downloadAttachment(a)}
className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
>
<Download className="h-3 w-3" />
</button>
</div>
))}
</div>
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</div>
)}
{/* ── 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. */}
<div className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-900">Lịch sử duyệt ({approvals.length})</h3>
{approvals.length === 0 ? (
<p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p>
) : (
<ul className="mt-2 space-y-1.5">
{approvals.map(a => {
const isReject = a.toPhase === KhkkPhase.TuChoi
const isReturn = a.toPhase === KhkkPhase.TraLai
return (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-slate-700">
<div className="flex flex-wrap items-center gap-x-1.5">
<span className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-semibold',
isReject ? 'bg-rose-100 text-rose-800'
: isReturn ? 'bg-amber-100 text-amber-800'
: 'bg-emerald-100 text-emerald-800',
)}>
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
</span>
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
</div>
<span className="text-[11px] text-slate-500">{formatDateTime(a.approvedAt)}</span>
</div>
<div className="mt-1 text-[12px] text-slate-600">
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
{a.levelName ? ` (${a.levelName})` : ''}
<span className="text-slate-400">
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
</span>
</div>
{a.comment?.trim() && (
<div className="mt-1 whitespace-pre-wrap text-[12px] text-slate-600">{a.comment}</div>
)}
</li>
)
})}
</ul>
)}
</div>
{/* ── 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ì. */}
<div className="border-t border-slate-200 pt-4">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
<History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi
</h3>
<div className="mt-2">
{changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>}
{changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>}
{changelogs.data && changelogs.data.length === 0 && (
<p className="text-[11px] italic text-slate-400">Chưa thay đi nào đưc ghi.</p>
)}
<ul className="space-y-1">
{(changelogs.data ?? []).map(c => (
<li key={c.id} className="rounded border border-slate-100 bg-white px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-600">
<span className="font-medium text-slate-700">
{KHKK_CHANGELOG_ACTION_LABELS[c.action] ?? 'Thay đổi'}
</span>
<span className="text-slate-400">·</span>
<span>{KHKK_CHANGELOG_ENTITY_TYPE_LABELS[c.entityType] ?? 'Khác'}</span>
<span className="text-slate-400">·</span>
<span>{c.userName ?? '(hệ thống)'}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(c.createdAt)}</span>
</div>
{(c.summary || c.contextNote) && (
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{c.summary}
{c.contextNote ? `${c.contextNote}` : ''}
</div>
)}
</li>
))}
</ul>
</div>
</div>
{action !== null && (
<Dialog
open
onClose={() => setAction(null)}
title={dialogTitle}
footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Hủy</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
</>}
>
{action === KhkkTransitionAction.Submit && (
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
Kế hoạch chuyển sang &ldquo;Chờ duyệt&rdquo; 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.
</div>
)}
{action === KhkkTransitionAction.Return && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
Kế hoạch về trạng thái &ldquo;Trả lại&rdquo;. Người soạn sửa xong gửi lại thì quy trình chạy
từ Bước 1 · Cấp 1.
</div>
)}
{action === KhkkTransitionAction.Reject && (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
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.
</div>
)}
{action === KhkkTransitionAction.Approve && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<label className="flex cursor-pointer items-start gap-2">
<input type="checkbox" className="mt-0.5" checked={applyLevelFinalize} onChange={e => setApplyLevelFinalize(e.target.checked)} />
<span>
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
{applyLevelFinalize
? 'Đã tích — duyệt xong là kế hoạch "Đã duyệt", KẾT THÚC tại cấp này, KHÔNG trình tiếp cấp sau.'
: 'Mặc định: duyệt xong kế hoạch TRÌNH TIẾP lên cấp sau của quy trình. TÍCH vào nếu muốn KẾT THÚC ngay tại cấp này.'}
</span>
</span>
</label>
</div>
)}
{/* [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. */}
<Label>
Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ghi chú của bạn (ghi vào phiếu)...'}
/>
{/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && (
<div className="mt-3">
<Label className="text-xs">File đính kèm (tùy chọn)</Label>
<div className="mt-1 flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => {
setPendingFiles(Array.from(e.target.files ?? []))
e.target.value = ''
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-50"
>
<Paperclip className="h-3 w-3" /> Chọn file
</button>
{pendingFiles.length === 0
? <span className="text-[11px] text-slate-400">Chưa chọn file nào</span>
: (
<span className="text-[11px] text-slate-600">
{pendingFiles.map(f => f.name).join(', ')}
<button type="button" onClick={() => setPendingFiles([])} className="ml-2 text-red-600 underline">bỏ chọn</button>
</span>
)}
</div>
<p className="mt-1 text-[11px] text-slate-500">
File đưc tải lên SAU khi thao tác duyệt thành công hiện mục &ldquo;File đính kèm khi duyệt&rdquo;.
</p>
</div>
)}
{action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500">
Ghi chú sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ).
</p>
)}
</Dialog>
)}
{confirmDelete && (
<Dialog
open
onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết"
footer={<>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Hủy</Button>
<Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button>
</>}
>
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-800">
Phiếu <span className="font-mono font-semibold">{plan.maKeHoach ?? '(chưa cấp mã)'}</span> sẽ bị
xóa mềm: biến khỏi danh sách khỏi cây toàn trình, chỉ còn xem đưc mục &ldquo;Đã xóa&rdquo;.
</div>
</Dialog>
)}
</div>
)
}