// Detail content cho 1 phiếu Duyệt NCC. Flat render (no tabs): Thông tin + // NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình. // Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel // → PeApprovalsSection + PeHistorySection). import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react' import { Button } from '@/components/ui/Button' import { Dialog } from '@/components/ui/Dialog' import { Input } from '@/components/ui/Input' import { Label } from '@/components/ui/Label' import { SearchableSelect } from '@/components/ui/SearchableSelect' import { Select } from '@/components/ui/Select' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { useAuth } from '@/contexts/AuthContext' import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog' import { PeAttachmentPurpose, PeAttachmentPurposeLabel, PeDepartmentKind, PeDepartmentKindLabel, PeDisplayStatusColor, PeDisplayStatusLabel, PurchaseEvaluationPhase, PurchaseEvaluationPhaseLabel, PurchaseEvaluationTypeLabel, getPeDisplayStatus, isEditablePhase, type PeApproval, type PeAttachment, type PeChangelog, type PeDepartmentOpinion, type PeDetailBundle, type PeDetailRow, type PeLevelOpinion, type PeQuote, type PeSupplier, } from '@/types/purchaseEvaluation' import { SupplierType, SupplierTypeLabel } from '@/types/master' import type { Supplier } from '@/types/master' const fmtMoney = (v: number) => v.toLocaleString('vi-VN') // Session 20 turn 4 — input helpers cho NCC/Quote inline form. // VND format dùng convention VN dấu chấm ngàn (1.000.000). Strip non-digit // khi parse user input → number. Empty/0 → empty string để placeholder hiện. const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0 const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '') // Validation cơ bản FE — empty OK (optional fields). BE FluentValidation // chưa enforce, FE check để user nhập sai biết ngay. const PHONE_RE = /^0\d{9,10}$/ // VN: bắt đầu 0, 10-11 digits sau khi strip space/dash/dot const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, '')) const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s) // Session 20 turn 8: trang trí 5 NCC khác màu (cycle theo index). Winner override // thành emerald nổi bật. Literal Tailwind class để JIT scan compile được. const NCC_PALETTES = [ 'border-l-blue-400 bg-blue-50/40', 'border-l-purple-400 bg-purple-50/40', 'border-l-sky-400 bg-sky-50/40', 'border-l-teal-400 bg-teal-50/40', 'border-l-pink-400 bg-pink-50/40', ] as const // Giá chào thầu của NCC/TP được chọn (winner) = sum quotes.thanhTien của winner // supplier-row. Single source of truth — Section 3 (ChonNccSection) + pre-check // nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn // NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá. function computeGiaChaoThau(ev: PeDetailBundle): number | null { const winnerSupplierRowId = ev.selectedSupplierId ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null : null if (winnerSupplierRowId === null) return null return ev.details .flatMap(d => d.quotes) .filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId) .reduce((sum, q) => sum + q.thanhTien, 0) } // Main detail content — flat render 3 section không tabs. // Tên giữ PeDetailTabs để không break callsite (rename gây churn). // // `mode` (2026-05-07): // - 'detail' (default): full UX — Section 5 Ý kiến 4PB editable theo readOnly. // Dùng ở leaf "Danh sách" + "Duyệt" (3-panel pages). // - 'workspace': dùng ở leaf "Thao tác" (2-panel workspace). Section 5 LUÔN // disabled (Q5 user — ý kiến nhập khi duyệt, không phải workspace nhập liệu). // Workflow Panel + Approvals + History KHÔNG render trong PeDetailTabs (luôn // ở caller PeWorkflowPanel — workspace caller skip render Panel 3 hoàn toàn). export function PeDetailTabs({ evaluation, onBack, onDelete, readOnly = false, mode = 'detail', autoEditHeader = false, }: { evaluation: PeDetailBundle onBack: () => void onDelete: () => void /** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */ readOnly?: boolean /** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */ mode?: 'detail' | 'workspace' /** Auto open Section 1 InfoTab in edit mode khi mount — triggered từ pencil icon Panel 1 */ autoEditHeader?: boolean }) { const qc = useQueryClient() // canEditPhase: bao gồm cả TraLai (user 2026-05-07). Header bar action // buttons "Sửa header" + "Xóa" + "Đóng" workspace mode đã chuyển xuống bottom // action bar (B11+ user 2026-05-07). const canEditPhase = isEditablePhase(evaluation.phase) const opinionsReadOnly = readOnly || mode === 'workspace' // Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá). const { user: currentUser } = useAuth() const isAdmin = currentUser?.roles?.includes('Admin') ?? false const v2Approvers = evaluation.currentApproval?.approvers ?? [] const actorMatchesLevel = isAdmin || (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id)) const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet // Mig 29 (S21 t5) — read F3 từ currentLevelOptions (per-NV slot) && (evaluation.currentLevelOptions?.allowApproverEditDetails ?? false) && actorMatchesLevel const itemsReadOnly = readOnly && !approverEditMode // "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition // sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing // (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace. // Mig 31 (S23 t1) — F2 Drafter-from-Nháp semantic deprecated. skipToFinal moved // sang Approver scope ChoDuyet (per-Level slot — xem PeWorkflowPanel). const submitForApproval = useMutation({ mutationFn: async () => { const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai) if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt') return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, { targetPhase: next, decision: 1, comment: null, }) }, onSuccess: () => { toast.success('Đã gửi duyệt phiếu — chuyển sang quy trình duyệt.') qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] }) qc.invalidateQueries({ queryKey: ['pe-list'] }) onBack() }, onError: e => toast.error(getErrorMessage(e)), }) const forwardPhase = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai) // Pre-check data-completeness cho action "Lưu & Gửi Duyệt" (S60 — anh Kiệt chốt). // CHỈ áp cho action gửi duyệt — liệt kê TẤT CẢ mục thiếu của Section 3 "Đơn vị // NCC/TP được chọn". Predicate khớp BE guard TransitionAsync (em main song song). // Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch. const missingForApproval = useMemo(() => { const missing: string[] = [] // 1. Chưa chọn Đơn vị NCC/TP if (evaluation.selectedSupplierId == null) { missing.push("Chưa chọn Đơn vị NCC/TP") } else { // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). // Chỉ check khi đã chọn (không spam khi chưa chọn — đã có mục 1). const gia = computeGiaChaoThau(evaluation) if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu") } // 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập). // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0. if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) { missing.push("Chưa nhập Ngân sách kỳ này") } // 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3) if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) { missing.push("Chưa đính kèm Bảng so sánh") } return missing }, [evaluation]) const canSubmitForApproval = mode === 'workspace' && canEditPhase && !readOnly && forwardPhase != null && missingForApproval.length === 0 // Tooltip reason cho button disabled (giúp diagnose tại sao "Lưu & Gửi Duyệt" // không bấm được — user feedback 2026-05-07). Reason cũ (workspace/canEditPhase/ // readOnly/forwardPhase) giữ nguyên; append data-completeness check S60 sau cùng. const submitDisabledReason = !canEditPhase ? `Phiếu đã ở phase ${PurchaseEvaluationPhaseLabel[evaluation.phase]} — chỉ Bản nháp / Trả lại mới sửa + gửi được.` : readOnly ? 'Chế độ chỉ đọc.' : !forwardPhase ? `Workflow không có phase tiếp theo từ ${PurchaseEvaluationPhaseLabel[evaluation.phase]}. Liên hệ admin kiểm tra cấu hình quy trình.` : missingForApproval.length > 0 ? `Chưa đủ thông tin mục 3 'Đơn vị NCC/TP được chọn':\n${missingForApproval.map(m => `• ${m}`).join('\n')}` : null return (

{evaluation.tenGoiThau}

{/* Display status meta (Bản nháp / Đã gửi duyệt / Đã duyệt / Từ chối) — phase chi tiết hiện ở Workflow timeline Panel 3. */} {PeDisplayStatusLabel[getPeDisplayStatus(evaluation.phase)]} ({PurchaseEvaluationPhaseLabel[evaluation.phase]}) {readOnly && ( chế độ duyệt )}
{evaluation.maPhieu ?? '—'} · {PurchaseEvaluationTypeLabel[evaluation.type]} · {evaluation.projectName} {/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (lời sếp) */} {evaluation.workItemName && <>{evaluation.workItemName}} {evaluation.drafterName && <>·Soạn: {evaluation.drafterName}}
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" + "Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn giữ Đóng cho non-workspace view (Danh sách + Duyệt — readOnly). */} {(readOnly || mode !== 'workspace') && (
)}
{/* Section layout (Session 20 Chunk B): Hạng mục nested expand chứa NCC (tầng 1 = hạng mục, tầng 2 = NCC tham gia + báo giá inline). NCC tham gia section riêng bỏ — gộp vào Section 2 expand panel. Tên hạng mục + giá trị auto từ gói thầu (Chunk A BE seed). */}
{/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2 */} {/* Plan Q S23 t7 — Drop mx-5 banner, full-width Section padding to align với ItemsTab header (button "+ Thêm hạng mục" right-aligned KHÔNG còn lệch khỏi banner inset gap). */} {approverEditMode && readOnly && (
ⓘ Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit). Mọi thay đổi sẽ được ghi vào Lịch sử chỉnh sửa.
)}
{mode === 'workspace' && (
Ý kiến + chữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
)} {/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */} {evaluation.approvalWorkflowId ? : }
{/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA: module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3 thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút: - Xóa phiếu (CHỈ Bản nháp, soft-delete BE) — bên trái red - Lưu (toast confirm, KHÔNG đóng workspace) — chính giữa ghost - Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand User 2026-05-07. */} {mode === 'workspace' && canEditPhase && !readOnly && (
{/* Xóa phiếu — CHỈ DangSoanThao (bản nháp). TraLai không cho xóa (đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint (AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */} {evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && ( )} ✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
)}
) } function Section({ title, children }: { title: string; children: React.ReactNode }) { // Session 20 turn 11: padding responsive cho laptop màn nhỏ — px-3 trên xs // (tiết kiệm ~16px width), bump px-5 từ sm+ trở lên. return (

{title}

{children}
) } // ===== Section 5 — Ý kiến 4 phòng ban ===== // Render 2x2 grid 4 box (Phê duyệt / CCM / MuaHàng / SM-PM). Mỗi box hiển // thị Opinion text + chữ ký (UserName + SignedAt) nếu đã ký, hoặc form nhập // + 2 button "Lưu" + "Lưu & Ký" khi chưa ký / readOnly=false. function DepartmentOpinionsSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { const KINDS: { kind: number; label: string }[] = [ { kind: PeDepartmentKind.PheDuyet, label: PeDepartmentKindLabel[PeDepartmentKind.PheDuyet] }, { kind: PeDepartmentKind.Ccm, label: PeDepartmentKindLabel[PeDepartmentKind.Ccm] }, { kind: PeDepartmentKind.MuaHang, label: PeDepartmentKindLabel[PeDepartmentKind.MuaHang] }, { kind: PeDepartmentKind.SmPm, label: PeDepartmentKindLabel[PeDepartmentKind.SmPm] }, ] return (
{KINDS.map(k => { const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null return ( ) })}
) } function OpinionBox({ evaluationId, kind, kindLabel, existing, readOnly, }: { evaluationId: string kind: number kindLabel: string existing: PeDepartmentOpinion | null readOnly: boolean }) { const qc = useQueryClient() const [text, setText] = useState(existing?.opinion ?? '') const isSigned = !!existing?.signedAt const save = useMutation({ mutationFn: async (sign: boolean) => api.post(`/purchase-evaluations/${evaluationId}/opinions`, { kind, opinion: text || null, sign, }), onSuccess: () => { toast.success('Đã lưu ý kiến.') qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }) }, onError: e => toast.error(getErrorMessage(e)), }) return (

{kindLabel}

{isSigned && ( Đã ký )}
{readOnly ? ( <>
{existing?.opinion ?? — chưa có ý kiến}
{isSigned && (
Ký bởi {existing?.userName ?? '—'} · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
)} ) : ( <>