Files
solution-erp/fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx
pqhuy1987 15349e8ee0
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m10s
[CLAUDE] Contract: K3 SP-002 8 workflow nhom KHKK-N1-8 + port level-finalize + designer (0-mig) + 5 test
Seed 8 workflow type-10 theo ROSTER LIVE prod (QT-DN-V2-001, lead verify tay
@SQLEXPRESS: 3 tram PRO->CCM->CEO, 11 nguoi, resolve EMAIL, idempotent per-Code;
co Finalize CHI cap chuong.phan). Port nhanh level-finalize mirror PE :866-876
(EndedByLevelFinalize K2-de-K3-tieu + ApplyApprovedValuesOnFinalize truoc Phase;
opt-out applyLevelFinalize 3-tang interface/service/controller, default true).
Sweep ep-false va-3: GO CREATE ep-false Finalize type-10 (OG-9 supersede PHA-VO
S155); GIU PUT-Conflict (luat versioning CHUNG - spec liet nham, khai lech);
GIU ep-false SkipToFinal type-10 (K3-test O-1 chong config-lie mới) + FE disable
o SkipToFinal cho KHKK (gate-K3 F-2). Rao (v) pin-dung-nhom regex KHKK-N[1-8].
+8 MenuKeys AwV2_KhkkN* (All 56->64, policies 224->256 may-dem) + 8 menu-row +
resolvePath fe-admin phep-am Khkk_ 0-hit. 5 test K3 (T-a..T-e, anti-tautology).
Gate K3 PASS-WITH-FLAGS 8f/0-blocker (sub-reviewer-gate-k3.md): F-1 checkbox
opt-out man duyet = KHAI HANDOFF dau-viec-#1 lan sau (tram CEO tam thoi qua
Designer bo co); F-2 va; F-3 dong truoc do. Suite 614/0 (45D+569I). K3-BE 2 luot
engine-exit -> che nho + lead-solo (bai: task-hep-Write-som song).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:21:39 +07:00

432 lines
22 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.

// [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ợ".
// Panel VẪN không có control riêng cho đường kết-thúc-sớm — nhưng vì lý do KHÁC HẲN: `AllowApproverFinalize`
// là cấu-hình PER-CẤP do admin tick ở Designer, BE đọc lúc duyệt để quyết dừng hay trình tiếp CEO. Người
// duyệt chỉ bấm "Duyệt" như thường; không có gì để panel render thêm.
//
// 🔴 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<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: '○',
}
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<KhkkTransitionActionValue | null>(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<KhkkTransitionResult>(
`/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 (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500">
{plan.workflowCode}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
</span>
)}
</header>
<div className="space-y-4 p-5">
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
{steps.length > 0 ? (
<ol className="space-y-2">
{steps.map((step, idx) => {
const st = stepStatus(idx)
return (
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
<div className="flex items-center gap-2 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>
</div>
{step.levels.length > 0 && (
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{step.levels.map(lv => {
const ls = levelStatus(idx, lv.order)
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
return (
<li key={lv.id} className="text-[11px]">
<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">
{lv.name || `Cấp ${lv.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>}
</div>
<div className="text-slate-500">
{lv.approverFullName ?? '(chưa cấu hình)'}
{signed && (
<span className="ml-1.5 text-[10px] text-emerald-600">
{formatDateTime(signed.signedAt)}
</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. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
{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>
)}
{/* Hành động */}
{(canSubmit || isWaiting) && (
<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(''); 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(''); 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(
'inline-flex items-center gap-1 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',
)}
>
<Check className="h-3 w-3" /> Duyệt
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); 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(
'inline-flex items-center gap-1 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',
)}
>
<Undo2 className="h-3 w-3" /> Trả lại
</button>
<button
type="button"
disabled={blockedByLevel}
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
className={cn(
'inline-flex items-center gap-1 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-red-300 text-red-700 hover:bg-red-50',
)}
>
<X className="h-3 w-3" /> Từ chối
</button>
</>
)}
</div>
</div>
)}
{/* Ý 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 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
Ý kiến cấp duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{opinions.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{opinions.map(o => (
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className="font-medium">{o.signedByFullName}</span>
<span className="text-slate-400">·</span>
<span>
{o.stepName ? `Bước ${o.stepOrder}${o.stepName}` : 'Cấp duyệt'}
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
duyệt thay
</span>
)}
</div>
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
{o.comment?.trim() || '(duyệt — không ý kiến)'}
</div>
</li>
))}
</ul>
</div>
)}
</div>
{action !== null && (
<Dialog
open
onClose={() => setAction(null)}
title={dialogTitle}
footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</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>
)}
<Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ 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...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
/>
{action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500">
Ý kiến 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>
)}
</section>
)
}