All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m11s
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>
760 lines
42 KiB
TypeScript
760 lines
42 KiB
TypeScript
// [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} là 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
|
||
“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. */}
|
||
{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>
|
||
)}
|
||
|
||
{/* ── 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 có 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 có 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 “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>
|
||
)}
|
||
{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 và hiện ở mục “File đính kèm khi duyệt”.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{action === KhkkTransitionAction.Approve && (
|
||
<p className="mt-1 text-[11px] text-slate-500">
|
||
Ghi chú 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>
|
||
)}
|
||
|
||
{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 và khỏi cây toàn trình, chỉ còn xem được ở mục “Đã xóa”.
|
||
</div>
|
||
</Dialog>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|