All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m10s
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>
432 lines
22 KiB
TypeScript
432 lines
22 KiB
TypeScript
// [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">
|
||
✓ ký {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
|
||
“Kế hoạch ký kết HĐ” 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 “Gửi duyệt”, 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 “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.
|
||
</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 “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.
|
||
</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 “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||
thì ghi đè ô cũ).
|
||
</p>
|
||
)}
|
||
</Dialog>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|