Files
solution-erp/fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx
pqhuy1987 43e1bd7bc2
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m37s
[CLAUDE] Contract: UAT S174 dot-B — be style PE sang KHKK (FE-only, 0-mig)
Nguon: spec runs/2026-08-05-S174-khkk-style-audit/spec-khkk-style-dong-bo-05-08-2026.md
(ensemble /fable-clone frontend-designer 5 lane). Owner S175: "KHKK theo khuon PE".
Mirror 7 CAP file, sha256 khop 7/7. Build XANH ca 2 app, 0 error TS.

=== B1 panel Quy trinh duyet (KhkkWorkflowPanel) ===
X2 GO card-accent -> panel TRAN khuon PE (:345).
  🔴 GO HAN CLASS, khong de border-0/shadow-none: .card-accent (index.css:112) nam NGOAI
  @layer nen thang utility Tailwind v4 (gotcha #66).
- Tieu de h3 tran + bo icon-chip; so hieu WF padStart(2,'0') => v01 (PE :350).
- Banner ket-thuc-som violet -> EMERALD (PE :360-377). PE danh violet RIENG cho
  "Duyet thay CEO"; dung violet ca 2 cho la tron 2 nghia vao mot mau.
- Chip PHONG vien-xam-nen-trang -> emerald dac (PE :406-410).
- Nut lucide -> glyph '✓ Duyet' / '← Tra lai' (PE :540); nut Xoa xam -> DO (PE :574).
- File dinh kem: +border-t pt-4 +h3 +nut <Eye> xem truoc. Route BE DA CO
  ContractSigningPlansController.cs:231 (/view, cung hinh PE :317).
  Nhan "Tai xuong" chuyen vao title= chu KHONG bo — giu chuoi do trong bundle
  de phep verify prod con control-duong soi duoc.
- Lich su duyet: bo pill dem -> (n) trong ngoac; the bg-slate-50/60 -> bg-white p-3 text-sm;
  moc-gio day phai bang justify-between.
  🔴 Badge 2-nga -> 3-NGA: truoc day MOI thu khong phai Reject deu to emerald => "Tra lai"
  (amber o PE) bi ve xanh nhu da-duyet, doc NGUOC nghia. toPhase da co san (khkk.ts:307).
- Lich su thay doi: BO GAP, render thang (PE PeDetailTabs.tsx:787-794).
  🔴 BAT BUOC bo luon `enabled: showChangelog` cua useQuery: giu lai thi khoi luon hien
  nhung query KHONG BAO GIO chay, va no hong duoi dang "Chua co thay doi nao duoc ghi."
  — tuc trong y het phieu chua tung sua, KHONG phai trong nhu loi. Doi lay +1 request/phieu,
  dung gia PE dang tra.
- Micro-copy: Huy / tuy chon; nhan O NHAP -> "Ghi chu" (PE :869).
  🔴 CHI doi ten TRUONG — chuoi "Y kien cap duyet" o dong tro-giup la TEN THAT cua muc dich
  den trong phieu (Section 5, Mig 26), doi no la tro nguoi dung toi mot muc khong ton tai.
- KHONG be chip "NS PRO"/"NS CCM" (2 co do mo quyen sua ngan sach per-goi-thau, khai niem
  KHKK khong co). GIU moc chu-ky KHKK (cho KHKK tot hon PE).

=== B2 noi dung phieu (KhkkDetailContent) — 6 P0 ===
- 2 bang: table-fixed + <colgroup> + min-w-[880px] trong overflow-x-auto. Bang co gian
  tu-do truoc day de cot tien nhay be rong theo do dai ten NCC => 3 cot so khong thang hang.
- O RONG -> em-dash MO (text-slate-300) gom vao helper orDash/moneyOrDash: truoc day em-dash
  thua-huong dung mau chu that => luot bang thi "—" trong y nhu mot GIA-TRI.
  moneyOrDash rieng vi formatVnd tra CHUOI "—" nen khong to mo duoc.
- empty-block -> text-sm italic text-slate-400 (3 cho).
- emoji 🔒 -> icon lucide <Lock>: emoji render khac nhau theo font he dieu-hanh va KHONG an
  currentColor nen luon lech tong voi chu amber ben canh.
- thong nhat text-xs ca hang bang; +hover:bg-slate-50/60 cho <tr>.

=== B3 panel danh sach (KhkkListPanel + KhkkWorkListPanel + PipelineTreePanel) ===
- DAU COT: che-do CAY truoc day di thang vao than, khong tieu-de/so dem => cot "cut dau",
  lech ca voi man Thao tac (Tier header) LAN voi chinh che-do phang (co dem o chan).
  Mirror khuon Tier.
- Gop 2 o tim chong nhau: +prop hideSearch cho PipelineTreePanel (MAC DINH giu nguyen —
  4/6 host kia khong truyen, 0 doi hanh vi). O trong cay KHONG bao gio dung toi vi o ngoai
  di server-side va go vao la chuyen han sang flatMode => cay bien mat.
- Thang chu 4 nac (13/11/10/9) -> 3 nac chuan 13/11/10.
- 🔴 WCAG: text-slate-400 = 2.56:1 tren nen trang, DUOI nguong AA 4.5:1 — nang len
  slate-500 (4.76:1) cho 7 dong MANG THONG TIN (6 note/empty-state + 1 so dem).
  GIU slate-400 cho 2 cho TRANG TRI (icon Search, dau cham phan cach) — WCAG khong doi
  tuong phan cho noi dung trang tri.
- Nut phan trang ky-tu tran ←/→ -> icon ChevronLeft/Right + aria-label: mui ten dang chu
  doi hinh theo font he dieu-hanh va KHONG co ten cho trinh doc man hinh.
- Chip nhom: inline + co chu khac dong chua => hop chip cao khac chu ben canh nen tut
  baseline; inline-flex items-center leading-none ghim thang hang.

=== B4 host (KhkkCreatePage) ===
- Grid 260/320 DA land tu dot A — chi con COMMENT stale ghi 300px, da va.
- 🔴 Va comment SAI SU THAT (L3 H-5): comment khai Panel 1 co "nut hanh-dong dinh day"
  nhung grep -c Button KhkkWorkListPanel = 0 (control-duong PeListPanel = 8). Do la mo ta
  CUA PE be nguyen sang => tu khai mot thu chua dung.

=== 2 component DUNG CHUNG — mo rong kieu MAC-DINH-GIU-NGUYEN ===
AttachmentPreviewDialog +basePath? (mac dinh /purchase-evaluations) => 6 call-site PE
KHONG phai sua dong nao (module da golive; bat sua het call-site cu = tu de rui-ro o cho
dang chay tot). PipelineTreePanel +hideSearch? y het. Da verify: 0 call-site PE bi dong.

=== CON NO ===
🔴 CHUA soi mat tren prod 375/1366/1440 — ca 5 lane deu NO-SCREENSHOT (dev-rig chan:
chi :8080 LISTENING, API :5443 khong co, trang sau ProtectedRoute — gotcha #3). Moi ket
luan la DOC MA, khong phai da nhin.
Dot C3 (Field to brand cho MOI value) + dot D (O3/O4/O6/O7/O8) chua lam. O3 con cho owner
xac nhan cach doc "sum tong" — do la tien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:30:13 +07:00

780 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, Send, Trash2 } 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
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<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 canSubmit = isDraftLike && !!plan.approvalWorkflowId
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 ─────────────────────────────────────────── */}
{(canSubmit || isWaiting || canDeletePlan) && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{canSubmit && (
<button
type="button"
onClick={() => { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Submit) }}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
>
<Send className="h-3 w-3" /> Gửi duyệt
</button>
)}
{isDraftLike && !plan.approvalWorkflowId && (
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
Chưa pin quy trình duyệt không gửi duyệt đưc.
</span>
)}
{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ử. */}
</>
)}
{canDeletePlan && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
title="Xóa phiếu này (phiếu chuyển sang mục &ldquo;Đã xóa&rdquo;)"
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
>
<Trash2 className="h-3 w-3" /> Xóa phiếu
</button>
)}
</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>
)
}