Files
solution-erp/fe-user/src/components/pe/PeDetailTabs.tsx
pqhuy1987 6df1b2d7c1
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m22s
[CLAUDE] FE-PE: Link hồ sơ auto-detect — http(s) -> hyperlink bấm-mở / đường dẫn ổ mạng -> chữ + nút Copy (x2 app SHA256)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:28:51 +07:00

2885 lines
125 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// 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 (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-slate-900">{evaluation.tenGoiThau}</h2>
{/* 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. */}
<span
className={cn(
'rounded px-1.5 py-0.5 text-[11px] font-medium',
PeDisplayStatusColor[getPeDisplayStatus(evaluation.phase)],
)}
>
{PeDisplayStatusLabel[getPeDisplayStatus(evaluation.phase)]}
</span>
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
</span>
{readOnly && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
chế đ duyệt
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
<span className="font-mono">{evaluation.maPhieu ?? '—'}</span>
<span>·</span>
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
<span>·</span>
<span>{evaluation.projectName}</span>
{/* S57bis — phiếu dạng "Dự án Hạng mục công việc" (lời sếp) */}
{evaluation.workItemName && <><span></span><span>{evaluation.workItemName}</span></>}
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
</div>
{/* 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') && (
<div className="flex gap-2">
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
)}
</div>
<div className="divide-y divide-slate-200">
{/* 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). */}
<Section title="1. Thông tin gói thầu">
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section>
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
{/* 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 && (
<div className="mb-3 rounded border border-violet-200 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
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.
</div>
)}
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
</Section>
<Section title="3. Đơn vị NCC/TP được chọn">
<ChonNccSection ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="4. Ý kiến cấp duyệt (sign-off theo workflow)">
{mode === 'workspace' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
</div>
)}
{/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ
fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */}
{evaluation.approvalWorkflowId
? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section>
{/* 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). */}
</div>
{/* 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 && (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="flex items-center gap-3">
{/* 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 && (
<Button
variant="danger"
onClick={() => {
if (confirm(`Xóa phiếu "${evaluation.tenGoiThau}"? Phiếu sẽ ẩn khỏi danh sách (soft-delete, không xóa hoàn toàn trong DB).`)) {
onDelete()
}
}}
className="gap-1.5 text-xs"
>
<Trash2 className="h-3.5 w-3.5" /> Xóa phiếu
</Button>
)}
<span className="text-[11px] text-slate-500">
Các thay đi đã tự đng lưu khi chỉnh sửa từng phần.
</span>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={() => {
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
toast.success('Đã lưu — sync server.')
}}
className="text-xs"
>
Lưu
</Button>
<Button
onClick={() => {
if (!forwardPhase) return
const confirmMsg = `Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`
if (confirm(confirmMsg)) {
submitForApproval.mutate()
}
}}
disabled={!canSubmitForApproval || submitForApproval.isPending}
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
className="text-xs"
>
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'}
</Button>
</div>
</div>
)}
</div>
)
}
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 (
<section className="px-3 py-3 sm:px-5 sm:py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
{children}
</section>
)
}
// ===== 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 (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{KINDS.map(k => {
const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null
return (
<OpinionBox
key={k.kind}
evaluationId={ev.id}
kind={k.kind}
kindLabel={k.label}
existing={existing}
readOnly={readOnly}
/>
)
})}
</div>
)
}
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 (
<div className={cn(
'rounded-lg border bg-white p-3',
isSigned ? 'border-emerald-200' : 'border-slate-200',
)}>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[13px] font-semibold uppercase tracking-wide text-slate-700">{kindLabel}</h4>
{isSigned && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã
</span>
)}
</div>
{readOnly ? (
<>
<div className="min-h-[60px] whitespace-pre-wrap text-sm text-slate-800">
{existing?.opinion ?? <span className="italic text-slate-400"> chưa ý kiến</span>}
</div>
{isSigned && (
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
bởi <strong>{existing?.userName ?? '—'}</strong> · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
</div>
)}
</>
) : (
<>
<textarea
rows={3}
value={text}
onChange={e => setText(e.target.value)}
placeholder="Nhập ý kiến…"
className="w-full resize-none rounded border border-slate-200 px-2 py-1.5 text-sm focus:border-brand-300 focus:outline-none focus:ring-1 focus:ring-brand-200"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="text-[11px] text-slate-500">
{isSigned
? <> bởi <strong>{existing?.userName ?? '—'}</strong> · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}</>
: 'Chưa ký'}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
onClick={() => save.mutate(false)}
disabled={save.isPending}
className="text-xs"
>
Lưu text
</Button>
<Button
onClick={() => save.mutate(true)}
disabled={save.isPending}
className="text-xs"
>
{isSigned ? 'Cập nhật chữ ký' : 'Lưu & Ký'}
</Button>
</div>
</div>
</>
)}
</div>
)
}
// ===== Section 5 V2 — Ý kiến cấp duyệt dynamic (Mig 26 — Session 19) =====
//
// Render theo workflow đã pin: forEach Step → forEach Level (Cấp) → forEach
// approver (NV). Mỗi NV = 1 OpinionBox (read-only). Service ApproveV2Async
// auto sync comment khi duyệt (Q1=1B). Empty list → fallback message.
//
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers
// (wrap nếu N>2). Admin override badge khi SignedByUserId !== ApproverUserId.
// Session 20 Chunk C (revised): gộp opinions đồng cấp cùng Phòng → 1 wrapper box / Step,
// BÊN TRONG render từng NV đã duyệt thành các "ô vuông" card mirror visual S19
// (grid-cols-2 cards). User feedback turn 2: giữ visual ô vuông như trước.
//
// Counter fix turn 2: "Số bước duyệt" (= số Cấp / Step) KHÁC "số người duyệt trong
// 1 bước" (= tổng NV across Cấp, OR-of-N nên chỉ 1 NV/Cấp cần ký). Counter đúng
// hiển thị X/Y cấp đã duyệt + thông tin phụ tổng NV tham gia.
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow
const opinions = ev.levelOpinions
if (!flow || flow.steps.length === 0) {
return (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500">
Workflow chưa đưc cấu hình hoặc chưa cấp duyệt nào.
</div>
)
}
return (
<div className="space-y-3">
{flow.steps.map(step => {
const totalLevels = step.levels.length
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
const stepOpinions = opinions
.filter(o => o.stepOrder === step.order)
.slice()
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
const signedLevels = new Set(stepOpinions.map(o => o.levelOrder)).size
return (
<StepOpinionsBox
key={step.order}
stepOrder={step.order}
stepName={step.name}
departmentName={step.departmentName}
totalLevels={totalLevels}
totalApprovers={totalApprovers}
signedLevels={signedLevels}
opinions={stepOpinions}
/>
)
})}
</div>
)
}
function StepOpinionsBox({
stepOrder, stepName, departmentName, totalLevels, totalApprovers, signedLevels, opinions,
}: {
stepOrder: number
stepName: string
departmentName?: string | null
totalLevels: number // số Cấp (bước duyệt nhỏ trong Step)
totalApprovers: number // tổng NV tham gia (FYI — OR-of-N nên không cần ký hết)
signedLevels: number // số Cấp đã có ít nhất 1 NV ký
opinions: PeLevelOpinion[]
}) {
return (
<div className="rounded-lg border border-slate-200 bg-slate-50/40">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {stepOrder} {stepName}
</span>
{departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{departmentName}
</span>
)}
<span
className="ml-auto text-[10px] text-slate-500"
title={`Tổng ${totalApprovers} NV tham gia (mỗi cấp chỉ cần 1 NV ký — OR-of-N)`}
>
{signedLevels}/{totalLevels} cấp đã duyệt · {totalApprovers} NV tham gia
</span>
</div>
<div className="p-3">
{opinions.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Chưa ý kiến duyệt.</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{opinions.map(o => <StepOpinionEntry key={o.id} opinion={o} />)}
</div>
)}
</div>
</div>
)
}
function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId
return (
<div className="rounded-lg border border-emerald-200 bg-white p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
Cấp {opinion.levelOrder} <span className="text-slate-900">{opinion.approverFullName}</span>
</h4>
{isAdminOverride && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{opinion.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
</div>
<div className="whitespace-pre-wrap text-sm text-slate-800">
{opinion.comment}
</div>
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{new Date(opinion.signedAt).toLocaleString('vi-VN')}
</div>
</div>
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử duyệt ({ev.approvals.length})</h3>
<ApprovalsTab ev={ev} />
</div>
)
}
export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử thay đi</h3>
<HistoryTab ev={ev} />
</div>
)
}
// ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
// Inline editable khi canEdit (=!readOnly && phase editable). Edit pencil button
// "Sửa" flip display ↔ form mode. Save dùng existing PUT /pe/:id endpoint với
// current entity values + new header fields. Dự án + Type LOCKED sau create —
// chỉ Tên/Địa điểm/Mô tả/Payment editable inline. autoEdit prop cho phép trigger
// edit mode từ pencil icon trong PeListPanel (URL flag ?editHeader=1).
// Phase editable = DangSoanThao + TraLai (user 2026-05-07).
function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boolean; autoEdit: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const [editing, setEditing] = useState(autoEdit && canEdit)
const [tenGoiThau, setTenGoiThau] = useState(ev.tenGoiThau)
const [diaDiem, setDiaDiem] = useState(ev.diaDiem ?? '')
const [moTa, setMoTa] = useState(ev.moTa ?? '')
const [paymentTerms, setPaymentTerms] = useState(ev.paymentTerms ?? '')
// User 2026-05-07: re-trigger editing mode khi click pencil ở Panel 1 cho
// PHIẾU KHÁC (ev.id thay đổi) hoặc autoEdit prop change. useState init chỉ
// chạy mount-time → cần useEffect sync khi parent re-render với props mới.
useEffect(() => {
if (autoEdit && canEdit) {
setEditing(true)
// Sync values từ ev mới (tránh stale state khi switch giữa 2 phiếu)
setTenGoiThau(ev.tenGoiThau)
setDiaDiem(ev.diaDiem ?? '')
setMoTa(ev.moTa ?? '')
setPaymentTerms(ev.paymentTerms ?? '')
}
}, [autoEdit, canEdit, ev.id, ev.tenGoiThau, ev.diaDiem, ev.moTa, ev.paymentTerms])
const dirty = tenGoiThau !== ev.tenGoiThau
|| diaDiem !== (ev.diaDiem ?? '')
|| moTa !== (ev.moTa ?? '')
|| paymentTerms !== (ev.paymentTerms ?? '')
const save = useMutation({
mutationFn: async () => {
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau,
diaDiem: diaDiem || null,
moTa: moTa || null,
paymentTerms: paymentTerms || null,
// S61 — module Budget cũ XÓA HẲN; PE giữ 2 ô ngân sách mới (echo lại
// giá trị hiện tại để PUT update không xóa nhầm — drafter sửa qua bảng
// TỔNG HỢP NGÂN SÁCH / PATCH budget-adjust).
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
})
},
onSuccess: () => {
toast.success('Đã cập nhật thông tin')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setEditing(false)
},
onError: e => toast.error(getErrorMessage(e)),
})
function reset() {
setTenGoiThau(ev.tenGoiThau)
setDiaDiem(ev.diaDiem ?? '')
setMoTa(ev.moTa ?? '')
setPaymentTerms(ev.paymentTerms ?? '')
}
if (!editing) {
return (
<dl className="space-y-2 text-sm">
<div className="flex items-start justify-between">
<FormRow label="a. Tên gói thầu" value={ev.tenGoiThau} />
{canEdit && (
<button
onClick={() => setEditing(true)}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 hover:text-brand-600"
title="Sửa thông tin gói thầu"
>
<Pencil className="h-3 w-3" /> Sửa
</button>
)}
</div>
<FormRow label="b. Dự án" value={ev.projectName} />
{/* S57bis — Hạng mục công việc (WorkItem master). Phiếu cũ null → "—". */}
<FormRow label="c. Hạng mục công việc" value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode}` : ''}${ev.workItemName}` : '—'} />
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
<div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600">
{ev.diaDiem && <div><span className="text-slate-400">Đa điểm:</span> {ev.diaDiem}</div>}
{ev.moTa && <div><span className="text-slate-400"> tả:</span> {ev.moTa}</div>}
{ev.paymentTerms && <div><span className="text-slate-400">Điều khoản TT:</span> <span className="whitespace-pre-wrap">{ev.paymentTerms}</span></div>}
</div>
)}
</dl>
)
}
// Editing mode
return (
<div className="space-y-3 rounded border border-brand-200 bg-brand-50/30 p-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="md:col-span-2">
<Label className="text-[11px]">a. Tên gói thầu *</Label>
<Input
value={tenGoiThau}
onChange={e => setTenGoiThau(e.target.value)}
placeholder="vd Cung cấp bê tông"
/>
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">b. Dự án (khóa)</Label>
<Input value={ev.projectName} disabled className="bg-slate-100" />
</div>
<div className="md:col-span-2">
{/* S57bis — hạng mục khóa ở inline-edit; đổi qua "Sửa header phiếu" (PeHeaderForm). */}
<Label className="text-[11px]">c. Hạng mục công việc (khóa)</Label>
<Input value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode}` : ''}${ev.workItemName}` : '—'} disabled className="bg-slate-100" />
</div>
<div>
<Label className="text-[11px]">Đa điểm</Label>
<Input
value={diaDiem}
onChange={e => setDiaDiem(e.target.value)}
placeholder="Lô K, KCN Lộc An..."
/>
</div>
<div>
<Label className="text-[11px]"> tả ngắn</Label>
<Input
value={moTa}
onChange={e => setMoTa(e.target.value)}
placeholder="Phương án A: ..."
/>
</div>
{/* S59 vòng 5: field "Điều khoản thanh toán" GỠ khỏi inline-edit (anh chốt
"bỏ nốt ra luôn tất cả các form"). State paymentTerms giữ — save giữ nguyên
data cũ; phiếu đã nhập vẫn hiển thị read-only ở header info. */}
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
onClick={() => { reset(); setEditing(false) }}
className="h-7 px-3 text-xs"
>
Hủy
</Button>
<Button
onClick={() => save.mutate()}
disabled={!dirty || !tenGoiThau || save.isPending}
className="h-7 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</div>
</div>
)
}
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) =====
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)".
// Save dùng POST /pe/:id/select-winner endpoint hiện có.
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => {
toast.success('Đã chọn NCC.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
if (!canEdit) {
return (
<FormRow
label="a. NCC / TP được chọn"
value={ev.selectedSupplierName
? <span className="font-medium text-emerald-700"> {ev.selectedSupplierName}</span>
: <span className="text-slate-400"> (chưa chọn)</span>}
/>
)
}
return (
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span>
<div className="relative min-w-0 flex-1">
<Select
value={ev.selectedSupplierId ?? ''}
onChange={e => setWinner.mutate(e.target.value)}
disabled={ev.suppliers.length === 0 || setWinner.isPending}
className="text-sm"
>
<option value=""> Chọn NCC từ danh sách Section 3 </option>
{ev.suppliers.map(s => (
<option key={s.id} value={s.supplierId}>
{s.supplierName}{s.displayName ? `${s.displayName}` : ''}
</option>
))}
</Select>
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
{setWinner.isPending && (
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
<span>Đang chọn NCC + sync cột giá Section 4</span>
</div>
)}
{ev.suppliers.length === 0 && (
<p className="mt-1 text-[11px] text-amber-600">
Thêm NCC Section 3 trước rồi mới chọn winner.
</p>
)}
</div>
</div>
)
}
// ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
// Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
// BE trả `ev.budgetSummary`. 2 block:
// A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
// dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
// B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
// (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
// budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
// fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtVndSigned = (v: number) =>
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label,
}: {
initial: number | null
allowNegative?: boolean
onSave: (v: number | null) => void
saving: boolean
label?: string
}) {
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => {
const n = parseVnd(text)
if (n === 0 && text.trim() === '') return null
return allowNegative && neg ? -n : n
}
const dirty = parse() !== initial
return (
<div className="flex items-center justify-end gap-1.5">
{allowNegative && (
<button
type="button"
onClick={() => setNeg(v => !v)}
className={cn(
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
)}
title="Đảo dấu âm/dương"
>
{neg ? '' : '+'}
</button>
)}
<div className="relative w-40">
<Input
type="text"
inputMode="numeric"
value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
placeholder="0"
aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]"
/>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-[11px] font-medium text-slate-500">đ</span>
</div>
<Button
onClick={() => onSave(parse())}
disabled={!dirty || saving}
className="h-7 px-2 text-[11px]"
>
{saving ? '…' : 'Lưu'}
</Button>
</div>
)
}
// 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú).
// tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50.
function BudgetRow({
label, sub, value, third, tone, danger, mono = true,
}: {
label: React.ReactNode
sub?: React.ReactNode
value: React.ReactNode
third?: React.ReactNode
tone?: 'brand' | 'brand-soft' | 'blue-soft'
danger?: boolean
mono?: boolean
}) {
const toneCls =
tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
: tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
: tone === 'blue-soft' ? 'bg-blue-50'
: ''
return (
<div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
<div className="min-w-0 flex-1">
<div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>{label}</div>
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
</div>
<div className={cn(
'w-48 shrink-0 text-right tabular-nums',
mono && 'font-mono',
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
)}>
{value}
</div>
<div className={cn(
'w-24 shrink-0 text-right text-[11px]',
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
)}>
{third}
</div>
</div>
)
}
// Block tiêu đề (A / B)
function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
return (
<div className="border-b border-slate-200 bg-slate-100 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
{children}
</div>
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient()
const bs = ev.budgetSummary
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
const drafterEditable = !readOnly && isEditablePhase(ev.phase)
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
}
// PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
const proMut = useMutation({
mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách PRO'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
const ccmMut = useMutation({
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
const adjustMut = useMutation({
mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
onSuccess: () => { toast.success('Đã lưu'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) {
return (
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
Phiếu chưa gắn Hạng mục công việc gắn Hạng mục đ dùng ngân sách gói thầu.
</div>
)
}
// ===== Số liệu Excel =====
const full = bs.fullAmount
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4)
const cmp56 = row5 - row6 // So với NS (row5 row6)
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
return (
<div className="overflow-hidden rounded-lg border border-slate-300">
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
Tổng hợp ngân sách trình
</div>
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full
(cmpFull<0). Số dư còn lại âm vẫn lưu + gửi duyệt được. */}
{(cmpPeriod < 0 || cmpFull < 0) && (
<div className="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
<span className="shrink-0 text-sm leading-none"></span>
<span>
<strong>Vượt ngân sách</strong> giá trị đ xuất NCC đang cao hơn ngân sách của gói thầu.
Phiếu <strong>vẫn lưu &amp; gửi duyệt đưc</strong>, vui lòng kiểm tra lại số liệu trước khi trình.
</span>
</div>
)}
{/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
<BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
{/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
<BudgetRow
tone="brand"
label={
<span className="inline-flex items-center gap-2">
Ngân sách (full gói thầu)
{bs.fullIsEstimate && (
<span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">ngân sách PRO</span>
)}
</span>
}
value={fmtVnd(full)}
/>
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
<BudgetRow
label="Ngân sách Ban hành lần đầu"
value={
bs.canEditCcm ? (
<VndInlineEdit
initial={bs.initialAmount}
saving={ccmMut.isPending}
label="Ngân sách ban hành lần đầu"
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })}
/>
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
<BudgetRow
label="Ngân sách V0 / hiệu chỉnh tăng giảm"
value={
bs.canEditCcm ? (
<VndInlineEdit
initial={bs.adjustmentAmount}
allowNegative
saving={ccmMut.isPending}
label="Ngân sách hiệu chỉnh tăng giảm"
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })}
/>
) : bs.adjustmentAmount != null ? (
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 4 — Dự trù PRO (PRO editable) */}
<BudgetRow
label="Ngân sách PRO"
value={
bs.canEditPro ? (
<VndInlineEdit
initial={bs.proEstimateAmount}
saving={proMut.isPending}
label="Ngân sách PRO"
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
/>
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
<div className="w-72 shrink-0">
{bs.canEditPro ? (
<div className="space-y-1">
<textarea
value={proNoteText}
onChange={e => setProNoteText(e.target.value)}
placeholder="Ghi chú dự trù…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
className="h-6 px-2 text-[11px]"
>
{proMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{bs.proNote || <span className="text-slate-400"></span>}
</div>
)}
</div>
</div>
{/* ===== Block B — THỰC HIỆN ===== */}
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
{/* 1 — Ngân sách trình duyệt trước */}
<BudgetRow
label="1. Ngân sách trình duyệt trước"
value={bs.previousSubmittedCount === 0
? <span className="font-sans text-slate-400">Chưa chọn</span>
: fmtVnd(row1)}
/>
{/* 2 — Kỳ trước đã chọn thầu */}
<BudgetRow
label="2. Kỳ trước đã chọn thầu"
value={bs.previousSelectedCount === 0
? <span className="font-sans text-slate-400">Chưa chọn</span>
: fmtVnd(row2)}
/>
{/* 3 — Ngân sách - kỳ này (drafter editable) + % /full */}
<BudgetRow
label="3. Ngân sách - kỳ này"
value={
drafterEditable ? (
<VndInlineEdit
initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này"
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span>
}
third={fmtPct(row3, full) ?? undefined}
/>
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */}
<BudgetRow
tone="blue-soft"
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
value={
<span className="font-sans text-slate-700">
{ev.selectedSupplierName ?? <span className="text-slate-400"> (chưa chọn)</span>}
</span>
}
/>
<BudgetRow
tone="blue-soft"
label="Giá trị kỳ này"
value={
proposalOver ? (
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
) : fmtVnd(row4)
}
/>
<BudgetRow
tone="blue-soft"
label="So sánh với ngân sách kỳ này"
sub="= 3 4"
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0}
/>
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */}
<BudgetRow
label="5. Lũy kế ngân sách đã sử dụng"
sub="= 1 + 3"
value={fmtVnd(row5)}
/>
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 6) */}
<BudgetRow
label="6. Lũy kế thực hiện"
sub="= 2 + 4"
value={fmtVnd(row6)}
/>
<BudgetRow
label="So với NS"
sub="= 5 6"
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0}
/>
{/* 7 — Ngân sách còn lại (= full 5) + % /full */}
<BudgetRow
label="7. Ngân sách còn lại"
sub="= Ngân sách full 5"
value={fmtVnd(row7)}
third={fmtPct(row7, full) ?? undefined}
/>
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */}
<div className={cn(
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]',
remainingOver && 'bg-red-50',
)}>
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}>
8. Giá trị thực hiện dự kiến còn lại
<div className="text-[10px] text-slate-400">mặc đnh = 7 nếu chưa nhập</div>
</div>
<div className="w-48 shrink-0 text-right">
{drafterEditable ? (
<VndInlineEdit
initial={ev.expectedRemainingAmount}
allowNegative
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
<span className={cn('font-mono tabular-nums', remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
{fmtVnd(row8)}
</span>
)}
</div>
<div className="w-24 shrink-0" />
</div>
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
<BudgetRow
tone="brand"
label="9. Giá trị tổng thực hiện dự kiến"
sub="= 4 + 8"
value={fmtVnd(row9)}
/>
<BudgetRow
tone="brand-soft"
label="So sánh với Ngân sách full"
sub="= Ngân sách full 9"
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
</div>
)
}
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
const [createOpen, setCreateOpen] = useState(false)
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
const winnerSupplierRowId = ev.selectedSupplierId
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
const giaChaoThau = computeGiaChaoThau(ev)
// d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null
const banSoSanhAttachments = ev.attachments.filter(
a => a.purchaseEvaluationSupplierId === null,
)
return (
<div className="space-y-3">
<NccSelectorRow ev={ev} readOnly={readOnly} />
{/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
<FormRow
label="c. Giá chào thầu"
value={
winnerSupplierRowId === null ? (
<span className="text-slate-400"> (chọn NCC/TP (a) trước)</span>
) : giaChaoThau === 0 ? (
<span className="text-slate-400"> (chưa nhập báo giá Section 4)</span>
) : (
<span className="font-semibold text-slate-900">{giaChaoThau!.toLocaleString('vi-VN')} đ</span>
)
}
/>
<div>
<div className="flex gap-3">
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span>
<div className="min-w-0 flex-1">
<GeneralAttachmentsSection
evaluationId={ev.id}
attachments={banSoSanhAttachments}
readOnly={readOnly}
/>
</div>
</div>
</div>
{/* e. Link hồ sơ (anh Kiệt FDC) — 1 hyperlink tới thư mục hồ sơ trên NAS công ty.
Read-only: render thẻ <a> bấm-mở (target=_blank). Editable: Input dán URL +
nút Lưu (PUT /purchase-evaluations/:id echo field bắt buộc + hoSoLink). */}
<HoSoLinkRow ev={ev} readOnly={readOnly} />
{ev.paymentTerms && (
<FormRow label="Điều khoản thanh toán" value={<span className="whitespace-pre-wrap">{ev.paymentTerms}</span>} />
)}
{ev.contractId && (
<FormRow
label="HĐ kế thừa"
value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>}
/>
)}
{canCreateContract && (
<div className="rounded border border-emerald-200 bg-emerald-50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-emerald-800">
Phiếu đã duyệt. Bấm đ tạo mới kế thừa NCC + hạng mục.
</div>
<Button onClick={() => setCreateOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo từ phiếu
</Button>
</div>
</div>
)}
{createOpen && <CreateContractDialog evaluation={ev} onClose={() => setCreateOpen(false)} />}
</div>
)
}
// e. Link hồ sơ — 1 cột HoSoLink (string? nullable) trỏ thư mục hồ sơ NAS.
// Read-only: thẻ <a> bấm-mở. Editable (phiếu DangSoanThao/TraLai + !readOnly):
// Input dán URL + nút Lưu. Save = PUT /purchase-evaluations/:id echo field bắt
// buộc (tenGoiThau + 2 ô ngân sách) như InfoTab.save để không xóa nhầm data.
function HoSoLinkRow({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const [hoSoLink, setHoSoLink] = useState(ev.hoSoLink ?? '')
useEffect(() => { setHoSoLink(ev.hoSoLink ?? '') }, [ev.id, ev.hoSoLink])
const dirty = hoSoLink !== (ev.hoSoLink ?? '')
const save = useMutation({
mutationFn: async () => {
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau: ev.tenGoiThau,
diaDiem: ev.diaDiem,
moTa: ev.moTa,
paymentTerms: ev.paymentTerms,
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
hoSoLink: hoSoLink || null,
})
},
onSuccess: () => {
toast.success('Đã lưu link hồ sơ')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">e. Link hồ </span>
<div className="min-w-0 flex-1">
{canEdit ? (
<div className="flex max-w-2xl items-center gap-2">
<Input
type="url"
value={hoSoLink}
onChange={e => setHoSoLink(e.target.value)}
placeholder="Dán link thư mục hồ sơ trên NAS..."
className="text-sm"
/>
<Button
onClick={() => save.mutate()}
disabled={!dirty || save.isPending}
className="h-9 shrink-0 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</div>
) : ev.hoSoLink ? (
/^https?:\/\//i.test(ev.hoSoLink.trim()) ? (
// Link web (http/https — vd SharePoint) → bấm mở thẳng tab mới.
<a
href={ev.hoSoLink}
target="_blank"
rel="noopener noreferrer"
className="break-all text-sm text-brand-600 hover:underline"
>
{ev.hoSoLink}
</a>
) : (
// Đường dẫn ổ cứng/ổ mạng (O:\…, \\server) → trình duyệt CHẶN mở file://
// từ https nên bấm sẽ hụt → hiện chữ + nút Copy để dán vào File Explorer.
<PathWithCopy path={ev.hoSoLink} />
)
) : (
<span className="text-sm text-slate-400"></span>
)}
</div>
</div>
)
}
// e.bis — Đường dẫn ổ cứng/ổ mạng (không phải http) → chữ + nút Copy. Trình duyệt
// CHẶN mở file:// từ trang https nên KHÔNG render <a> bấm-mở (bấm sẽ hụt); thay
// bằng Copy → người dùng dán vào File Explorer (máy có map ổ mạng là mở ngay).
function PathWithCopy({ path }: { path: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(path)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error('Không copy được — vui lòng bôi đen đường dẫn rồi Ctrl+C.')
}
}
return (
<div className="flex max-w-2xl items-start gap-2">
<code className="min-w-0 flex-1 break-all rounded bg-slate-50 px-2 py-1 text-[13px] text-brand-800 ring-1 ring-slate-200">
{path}
</code>
<Button
type="button"
variant="outline"
onClick={copy}
className="h-8 shrink-0 px-2.5 text-xs"
title="Copy đường dẫn rồi dán vào File Explorer (This PC)"
>
{copied ? '✓ Đã copy' : 'Copy'}
</Button>
</div>
)
}
// Form row: label cố định 176px (w-44) bên trái + value bên phải (giống spec).
function FormRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
<dt className="w-44 shrink-0 text-[12px] text-slate-500">{label}</dt>
<dd className="min-w-0 flex-1 text-slate-800">{value}</dd>
</div>
)
}
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
const navigate = useNavigate()
const [form, setForm] = useState({
contractType: 1,
tenHopDong: evaluation.tenGoiThau,
bypassProcurementAndCCM: false,
})
const mut = useMutation({
mutationFn: async () =>
api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success('Đã tạo HĐ từ phiếu.')
navigate(`/contracts/${res.data.contractId}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
const typeOptions = [
[1, 'HĐ Thầu phụ'],
[2, 'HĐ Giao khoán'],
[3, 'HĐ Nhà cung cấp'],
[4, 'HĐ Dịch vụ'],
[5, 'HĐ Mua bán'],
[6, 'HĐ Nguyên tắc NCC'],
[7, 'HĐ Nguyên tắc DV'],
] as const
return (
<Dialog
open
onClose={onClose}
title="Tạo HĐ từ phiếu Duyệt NCC"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Tạo</Button>
</>}
>
<div className="space-y-3">
<p className="text-sm text-slate-500">
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
</p>
<div>
<Label>Loại </Label>
<Select value={form.contractType} onChange={e => setForm({ ...form, contractType: Number(e.target.value) })}>
{typeOptions.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
</Select>
</div>
<div>
<Label>Tên </Label>
<Input value={form.tenHopDong} onChange={e => setForm({ ...form, tenHopDong: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.bypassProcurementAndCCM}
onChange={e => setForm({ ...form, bypassProcurementAndCCM: e.target.checked })}
/>
Bypass CCM (áp dụng với Chủ đu )
</label>
</div>
</Dialog>
)
}
// Session 20 Chunk B: SuppliersTab function bỏ — NCC list giờ render nested
// trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier
// vẫn giữ vì HangMucCard call lại.
// Session 20 turn 8: Dialog thêm NCC mới — khi gọi từ HangMucCard (có detailId)
// thì input "Số tiền" hiển thị + sequential POST: tạo supplier → tạo quote
// cho hạng mục đó. detailId optional cho call site khác trong tương lai.
function AddSupplierDialog({ evaluationId, detailId, onClose }: {
evaluationId: string
detailId?: string
onClose: () => void
}) {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
})
const [form, setForm] = useState({
supplierId: '',
displayName: '',
contactName: '',
contactEmail: '',
contactPhone: '',
paymentTermText: '',
note: '',
thanhTien: 0,
})
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError)
const showQuote = !!detailId
// S59 UAT "Không tự thêm dc tên NTP mới" — anh chốt mở POST /suppliers cho mọi
// user đăng nhập (Sửa/Xóa vẫn Admin/CatalogManager). Tạo xong auto-select vào phiếu.
const [showNew, setShowNew] = useState(false)
const [newSup, setNewSup] = useState({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
const createSup = useMutation({
mutationFn: async () => (await api.post<{ id: string }>('/suppliers', {
code: newSup.code.trim(),
name: newSup.name.trim(),
type: newSup.type,
phone: newSup.phone.trim() || null,
email: newSup.email.trim() || null,
})).data,
onSuccess: async created => {
toast.success('Đã tạo NCC mới vào danh mục.')
await qc.invalidateQueries({ queryKey: ['all-suppliers'] })
setForm(prev => ({ ...prev, supplierId: created.id, contactPhone: newSup.phone.trim(), contactEmail: newSup.email.trim() }))
setShowNew(false)
setNewSup({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
},
onError: e => toast.error(getErrorMessage(e)),
})
const mut = useMutation({
mutationFn: async () => {
// Step 1: tạo NCC tham gia (PE.Suppliers row)
const res = await api.post<{ id: string }>(`/purchase-evaluations/${evaluationId}/suppliers`, {
supplierId: form.supplierId,
displayName: form.displayName,
contactName: form.contactName,
contactEmail: form.contactEmail,
contactPhone: form.contactPhone,
paymentTermText: form.paymentTermText,
note: form.note,
})
const newSupplierRowId = res.data.id
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0)
if (detailId && form.thanhTien > 0) {
await api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: newSupplierRowId,
bgVat: 0,
chuaVat: 0,
thanhTien: form.thanhTien,
note: '',
isSelected: false,
})
}
},
onSuccess: () => {
toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
onClose()
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title="Thêm NCC vào phiếu"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={!form.supplierId || hasError || mut.isPending}>Thêm</Button>
</>}
>
<div className="space-y-3">
<div>
<Label>NCC (master)</Label>
{/* S59 UAT (3 ý): gõ-tìm (SearchableSelect) + sort A-Z theo mã + "+ NCC mới"
tạo nhanh vào danh mục dùng ngay. Auto-fill liên hệ từ master giữ nguyên. */}
<div className="flex items-start gap-2">
<SearchableSelect
className="min-w-0 flex-1"
options={(suppliers.data ?? [])
.map(s => ({ value: s.id, label: `${s.code}${s.name}` }))
.sort((a, b) => a.label.localeCompare(b.label, 'vi', { numeric: true }))}
value={form.supplierId}
onChange={id => {
// Session 20 turn 10: auto-fill các field NCC từ master data sẵn có
// (contactPerson/phone/email/note). User vẫn override được sau đó.
const picked = suppliers.data?.find(s => s.id === id)
setForm(prev => ({
...prev,
supplierId: id,
contactName: picked?.contactPerson ?? '',
contactPhone: picked?.phone ?? '',
contactEmail: picked?.email ?? '',
note: picked?.note ?? '',
}))
}}
placeholder="-- Chọn NCC (gõ để lọc) --"
/>
<button
type="button"
onClick={() => setShowNew(v => !v)}
className="inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-lg border border-dashed border-brand-300 px-2.5 text-[11px] font-medium text-brand-700 hover:bg-brand-50"
>
<Plus className="h-3 w-3" /> NCC mới
</button>
</div>
{form.supplierId && <p className="mt-1 text-[10px] text-emerald-600"> Đã tự điền từ Master bạn thể sửa lại nếu cần.</p>}
{showNew && (
<div className="mt-2 space-y-2 rounded-lg border border-dashed border-brand-200 bg-brand-50/40 p-2.5">
<p className="text-[11px] font-medium text-brand-700">Tạo NCC mới vào danh mục (dùng ngay cho phiếu này)</p>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[11px]"> *</Label>
<Input value={newSup.code} onChange={e => setNewSup({ ...newSup, code: e.target.value.toUpperCase() })} placeholder="VD: NTP-THANGLONG" />
</div>
<div>
<Label className="text-[11px]">Loại *</Label>
<Select value={newSup.type} onChange={e => setNewSup({ ...newSup, type: Number(e.target.value) as SupplierType })}>
{Object.values(SupplierType).map(t => (
<option key={t} value={t}>{SupplierTypeLabel[t]}</option>
))}
</Select>
</div>
<div className="col-span-2">
<Label className="text-[11px]">Tên *</Label>
<Input value={newSup.name} onChange={e => setNewSup({ ...newSup, name: e.target.value })} placeholder="CÔNG TY ..." />
</div>
<div>
<Label className="text-[11px]">SĐT</Label>
<Input value={newSup.phone} onChange={e => setNewSup({ ...newSup, phone: e.target.value })} placeholder="0987654321" />
</div>
<div>
<Label className="text-[11px]">Email</Label>
<Input value={newSup.email} onChange={e => setNewSup({ ...newSup, email: e.target.value })} />
</div>
</div>
<div className="flex justify-end">
<Button
onClick={() => createSup.mutate()}
disabled={!newSup.code.trim() || !newSup.name.trim() || createSup.isPending}
className="h-7 px-3 text-xs"
>
{createSup.isPending ? 'Đang tạo…' : 'Tạo & chọn'}
</Button>
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" /></div>
<div><Label>Người liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
<div>
<Label>Điện thoại</Label>
<Input
type="tel"
inputMode="tel"
value={form.contactPhone}
onChange={e => setForm({ ...form, contactPhone: e.target.value })}
placeholder="0987654321"
className={phoneError ? 'border-red-300' : undefined}
/>
{phoneError && <p className="mt-0.5 text-[10px] text-red-600">{phoneError}</p>}
</div>
<div className="col-span-2">
<Label>Email</Label>
<Input
type="email"
value={form.contactEmail}
onChange={e => setForm({ ...form, contactEmail: e.target.value })}
placeholder="name@example.com"
className={emailError ? 'border-red-300' : undefined}
/>
{emailError && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>}
</div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div>
{showQuote && (
<div className="col-span-2 rounded-lg border border-brand-200 bg-brand-50/40 p-3">
<Label className="text-brand-700">Số tiền báo giá cho hạng mục</Label>
<div className="relative mt-1 max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.thanhTien)}
onChange={e => setForm({ ...form, thanhTien: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="mt-1 text-[11px] text-slate-500">
Đ trống / 0 chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng.
</p>
</div>
)}
</div>
</div>
</Dialog>
)
}
function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeSupplier; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
supplierId: row.supplierId,
displayName: row.displayName ?? '',
contactName: row.contactName ?? '',
contactEmail: row.contactEmail ?? '',
contactPhone: row.contactPhone ?? '',
paymentTermText: row.paymentTermText ?? '',
note: row.note ?? '',
})
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError)
const mut = useMutation({
mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form),
onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={`Sửa NCC — ${row.supplierName}`}
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={hasError || mut.isPending}>Lưu</Button>
</>}
>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} /></div>
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} /></div>
<div><Label>Liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
<div>
<Label>Điện thoại</Label>
<Input
type="tel"
inputMode="tel"
value={form.contactPhone}
onChange={e => setForm({ ...form, contactPhone: e.target.value })}
placeholder="0987654321"
className={phoneError ? 'border-red-300' : undefined}
/>
{phoneError && <p className="mt-0.5 text-[10px] text-red-600">{phoneError}</p>}
</div>
<div className="col-span-2">
<Label>Email</Label>
<Input
type="email"
value={form.contactEmail}
onChange={e => setForm({ ...form, contactEmail: e.target.value })}
placeholder="name@example.com"
className={emailError ? 'border-red-300' : undefined}
/>
{emailError && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>}
</div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
</div>
</Dialog>
)
}
// ===== Tab: Hạng mục + Báo giá (Session 20 — nested cards layout) =====
// Mỗi hạng mục = 1 card với expand panel chứa NCC tham gia inline grid.
// Replace bảng matrix grid (hạng mục × NCC) cũ — user demo 1 hạng mục.
function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
// S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
// không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
// TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
return (
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-slate-500">
{ev.details.length} hạng mục · {ev.suppliers.length} NCC tham gia
{!readOnly && ' — mở hạng mục để thêm NCC + nhập báo giá.'}
</p>
{/* S59 vòng 6 (anh chốt "bỏ luôn cái nút thêm hạng mục"): 1 phiếu = 1 hạng mục
chọn từ header (S57bis/S58) — hạng mục đầu auto-seed khi tạo phiếu, nút thêm
hạng mục thứ 2+ sai mô hình. AddItemDialog giữ (dead) để flip lại dễ nếu cần. */}
</div>
{ev.details.length === 0 ? (
<p className="text-sm text-slate-500">Chưa hạng mục.</p>
) : (
<div className="space-y-3">
{ev.details.map(d => (
<HangMucCard
key={d.id}
detail={d}
ev={ev}
readOnly={readOnly}
onEditDetail={() => setEditDetail(d)}
/>
))}
</div>
)}
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
</div>
)
}
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
function HangMucCard({
detail, ev, readOnly, onEditDetail,
}: {
detail: PeDetailRow
ev: PeDetailBundle
readOnly: boolean
onEditDetail: () => void
}) {
const qc = useQueryClient()
const [expanded, setExpanded] = useState(true)
const [addNccOpen, setAddNccOpen] = useState(false)
const [editNccRow, setEditNccRow] = useState<PeSupplier | null>(null)
const [quoteEdit, setQuoteEdit] = useState<{ supplier: PeSupplier; existing: PeQuote | null } | null>(null)
const removeDetail = useMutation({
mutationFn: async () => api.delete(`/purchase-evaluations/${ev.id}/details/${detail.id}`),
onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const removeNcc = useMutation({
mutationFn: async (rowId: string) => api.delete(`/purchase-evaluations/${ev.id}/suppliers/${rowId}`),
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap +
padding responsive cho laptop nhỏ. Stat (Số tiền NS) wrap xuống dòng
riêng khi container hẹp. */}
<div className="flex flex-wrap items-start gap-2 border-b border-slate-100 p-2 sm:gap-3 sm:p-3">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="mt-0.5 text-slate-400 hover:text-slate-700"
title={expanded ? 'Đóng' : 'Mở'}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900">
<span className="font-mono text-[12px] text-slate-500 mr-2">{detail.groupCode}</span>
{detail.noiDung}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{detail.groupName}{detail.donViTinh ? ` · ĐVT: ${detail.donViTinh}` : ''}
</div>
</div>
<div className="flex flex-shrink-0 items-start gap-4 text-right text-xs">
<div>
<div className="text-[10px] uppercase text-slate-400">Số tiền ngân sách</div>
<div className="font-mono text-base font-semibold text-slate-900">
{fmtMoney(detail.thanhTienNganSach)}
<span className="ml-1 text-xs font-normal text-slate-500">đ</span>
</div>
</div>
{/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
trình ký" cấp phiếu (PeBudgetSummaryTable). */}
</div>
{!readOnly && (
<div className="flex flex-shrink-0 gap-1">
<button
onClick={onEditDetail}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa hạng mục"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { if (confirm('Xóa hạng mục? Báo giá NCC đã nhập cũng sẽ mất.')) removeDetail.mutate() }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa hạng mục"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Expand panel — NCC tham gia + báo giá inline */}
{expanded && (
<div className="p-2 sm:p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[11px] uppercase tracking-wide text-slate-500">
NCC tham gia ({ev.suppliers.length})
</div>
{!readOnly && (
<Button variant="ghost" onClick={() => setAddNccOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3 w-3" /> Thêm NCC
</Button>
)}
</div>
{ev.suppliers.length === 0 ? (
<p className="text-xs text-slate-500">
{readOnly ? 'Chưa có NCC tham gia.' : 'Chưa có NCC. Thêm NCC để nhập báo giá.'}
</p>
) : (
<div className="overflow-x-auto">
{/* S59 UAT vòng 3: "thêm file giao diện bị thay đổi không cân xứng" — auto-layout
để cell File (chip tên dài) phình + bóp dọc cột NCC. Fix: table-fixed + width
từng cột (chip file/email có truncate sẵn — kích hoạt khi cell khóa width);
min-w để panel hẹp thì scroll ngang (wrapper overflow-x-auto) thay vì bóp nát. */}
<table className="w-full min-w-[860px] table-fixed border border-slate-200 text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="w-[24%] border-r border-slate-200 px-2 py-1.5 text-left">NCC</th>
<th className="w-[9%] border-r border-slate-200 px-2 py-1.5 text-left">SĐT</th>
<th className="w-[14%] border-r border-slate-200 px-2 py-1.5 text-left">Email</th>
<th className="w-[14%] border-r border-slate-200 px-2 py-1.5 text-left">Điều khoản TT</th>
<th className="w-[22%] border-r border-slate-200 px-2 py-1.5 text-left">File báo giá</th>
<th className="w-[12%] border-r border-slate-200 px-2 py-1.5 text-right">Số tiền</th>
{!readOnly && <th className="w-10 px-2 py-1.5"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.suppliers.map((s, idx) => {
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
const isWinner = ev.selectedSupplierId === s.supplierId
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
const palette = NCC_PALETTES[idx % NCC_PALETTES.length]
return (
<tr
key={s.id}
className={cn(
'align-top border-l-4 transition',
isWinner
? 'border-l-emerald-500 bg-emerald-100/70 font-semibold shadow-sm ring-1 ring-inset ring-emerald-300 hover:bg-emerald-200/70'
: cn(palette, 'hover:bg-white/80 hover:shadow-sm'),
)}
>
<td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900">
{isWinner && <span className="mr-1 text-base font-bold text-emerald-700"></span>}
<span className={cn(isWinner && 'text-emerald-900')}>{s.supplierName}</span>
</div>
{s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="text-[10px] text-amber-700">{s.note}</div>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600 font-mono">
{s.contactPhone || <span className="text-slate-300"></span>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600">
{s.contactEmail
? <span className="block truncate" title={s.contactEmail}>{s.contactEmail}</span>
: <span className="text-slate-300"></span>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px]">
{s.paymentTermText ?? <span className="text-slate-300"></span>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5">
<SupplierAttachmentsCell
evaluationId={ev.id}
supplierRowId={s.id}
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
readOnly={readOnly}
/>
</td>
<td className="border-r border-slate-200 px-2 py-1.5">
{!readOnly ? (
<button
type="button"
onClick={openQuote}
className={cn(
'w-full rounded border px-2 py-1 text-right font-mono text-[11px] transition',
q
? isWinner
? 'border-emerald-300 bg-emerald-50 font-semibold text-emerald-700 hover:bg-emerald-100'
: 'border-slate-300 bg-white font-semibold hover:border-brand-300 hover:bg-brand-50'
: 'border-dashed border-slate-300 bg-slate-50 text-[10px] text-slate-400 hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600',
)}
title="Click để nhập / sửa số tiền"
>
{q ? `${fmtMoney(q.thanhTien)} đ` : '+ Nhập số tiền'}
</button>
) : (
<div className={cn('text-right font-mono font-semibold', isWinner && 'text-emerald-700')}>
{q ? `${fmtMoney(q.thanhTien)} đ` : <span className="text-slate-300"></span>}
</div>
)}
</td>
{!readOnly && (
<td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5">
<button
onClick={() => setWinner.mutate(s.supplierId)}
className={cn(
'rounded px-1 py-0.5',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={isWinner ? 'Đơn vị NCC/TP đã được chọn' : 'Chọn đơn vị NCC/TP'}
>
<Check className="h-3 w-3" />
</button>
{!isWinner && (
<button
onClick={() => setEditNccRow(s)}
className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa thông tin NCC"
>
<Pencil className="h-3 w-3" />
</button>
)}
{canDelete ? (
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) removeNcc.mutate(s.id) }}
className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa NCC"
>
<Trash2 className="h-3 w-3" />
</button>
) : !isWinner && hasQuotes && (
<span
className="rounded px-1 py-0.5 text-slate-300 cursor-not-allowed"
title="NCC đã có báo giá — xóa báo giá trước rồi mới xóa NCC"
>
<Trash2 className="h-3 w-3" />
</span>
)}
</div>
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
{addNccOpen && <AddSupplierDialog evaluationId={ev.id} detailId={detail.id} onClose={() => setAddNccOpen(false)} />}
{editNccRow && <EditSupplierDialog evaluationId={ev.id} row={editNccRow} onClose={() => setEditNccRow(null)} />}
{quoteEdit && (
<QuoteDialog
evaluationId={ev.id}
detailId={detail.id}
supplierRowId={quoteEdit.supplier.id}
supplierName={quoteEdit.supplier.supplierName}
itemName={detail.noiDung}
existing={quoteEdit.existing}
onClose={() => setQuoteEdit(null)}
/>
)}
</div>
)
}
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
const qc = useQueryClient()
// Session 20 turn 5: user yêu cầu rút gọn — chỉ Tên hạng mục + Số tiền
// ngân sách (VND format) + Ghi chú. Các field schema khác (groupCode/
// groupName/itemCode/donViTinh/khoiLuongs/donGia) giữ default cho BE
// schema backward compat — KHÔNG expose UI cho user.
const [form, setForm] = useState({
groupCode: row?.groupCode ?? '01',
groupName: row?.groupName ?? 'Hạng mục chính',
itemCode: row?.itemCode ?? '',
noiDung: row?.noiDung ?? '',
donViTinh: row?.donViTinh ?? 'gói',
khoiLuongNganSach: row?.khoiLuongNganSach ?? 1,
khoiLuongThiCong: row?.khoiLuongThiCong ?? 1,
donGiaNganSach: row?.donGiaNganSach ?? 0,
thanhTienNganSach: row?.thanhTienNganSach ?? 0,
ghiChu: row?.ghiChu ?? '',
})
const mut = useMutation({
mutationFn: async () =>
row
? api.put(`/purchase-evaluations/${evaluationId}/details/${row.id}`, form)
: api.post(`/purchase-evaluations/${evaluationId}/details`, form),
onSuccess: () => { toast.success(row ? 'Đã sửa.' : 'Đã thêm.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
// Sync ngân sách: user nhập "Số tiền ngân sách" → set cả donGia + thanhTien
// (KL = 1 ngầm). BE giữ schema 3 field.
const setBudgetAmount = (n: number) => {
setForm({ ...form, donGiaNganSach: n, thanhTienNganSach: n })
}
return (
<Dialog
open
onClose={onClose}
title={(row ? 'Sửa' : 'Thêm') + ' hạng mục'}
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>{row ? 'Lưu' : 'Thêm'}</Button>
</>}
>
<div className="space-y-3">
<div>
<Label>Tên hạng mục</Label>
<Input
value={form.noiDung}
onChange={e => setForm({ ...form, noiDung: e.target.value })}
placeholder="vd Cung cấp bê tông M250"
/>
</div>
<div>
<Label>Số tiền ngân sách</Label>
<div className="relative">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.thanhTienNganSach)}
onChange={e => setBudgetAmount(parseVnd(e.target.value))}
placeholder="0"
className="pr-12 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="mt-1 text-[11px] text-slate-500">VND nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
</div>
<div>
<Label>Ghi chú</Label>
<Input value={form.ghiChu} onChange={e => setForm({ ...form, ghiChu: e.target.value })} />
</div>
</div>
</Dialog>
)
}
function QuoteDialog({
evaluationId, detailId, supplierRowId, supplierName, itemName, existing, onClose,
}: {
evaluationId: string
detailId: string
supplierRowId: string
supplierName: string
itemName: string
existing: PeQuote | null
onClose: () => void
}) {
const qc = useQueryClient()
// Session 20 turn 3: user yêu cầu "tạm thời chỉ cần nhập số tiền, không
// cần 3 cột có VAT / không VAT / tổng". UI chỉ 1 input thanhTien; bgVat /
// chuaVat / note vẫn gửi BE giữ schema (default 0 / empty cho row mới,
// giữ giá trị cũ nếu existing).
const [form, setForm] = useState({
thanhTien: existing?.thanhTien ?? 0,
})
const mut = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: supplierRowId,
bgVat: existing?.bgVat ?? 0,
chuaVat: existing?.chuaVat ?? 0,
thanhTien: form.thanhTien,
note: existing?.note ?? '',
isSelected: existing?.isSelected ?? false,
}),
onSuccess: () => { toast.success('Đã lưu số tiền.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async () =>
existing ? api.delete(`/purchase-evaluations/${evaluationId}/quotes/${existing.id}`) : Promise.resolve(),
onSuccess: () => { toast.success('Đã xóa.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const isSaving = mut.isPending || del.isPending
return (
<Dialog
open
onClose={onClose}
title={`Báo giá — ${supplierName}`}
footer={<>
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={isSaving}>{del.isPending ? 'Đang xóa…' : 'Xóa'}</Button>}
<Button variant="ghost" onClick={onClose} disabled={isSaving}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={isSaving}>{mut.isPending ? 'Đang lưu…' : 'Lưu'}</Button>
</>}
>
<div className="relative space-y-3">
{isSaving && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-white/70 backdrop-blur-sm">
<div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 shadow-md">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
<span className="text-sm font-medium text-slate-700">
{mut.isPending ? 'Đang lưu…' : 'Đang xóa…'}
</span>
</div>
</div>
)}
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong></p>
<div>
<Label>Số tiền</Label>
<div className="relative">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.thanhTien)}
onChange={e => setForm({ thanhTien: parseVnd(e.target.value) })}
placeholder="0"
autoFocus
className="pr-12 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="mt-1 text-[11px] text-slate-500">VND nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
</div>
</div>
</Dialog>
)
}
// ===== Tab: Duyệt =====
// Plan AC S25 Bug 3 — Decision badge phân biệt Approve / Trả lại / Từ chối.
// Plan AD S25 — Drop fromPhase→toPhase badges (gây nhầm khi cùng ChoDuyet);
// thay bằng next-target hint parse từ comment để rõ "gửi duyệt cho ai / trả về đâu".
const PE_DECISION_REJECT = 2
function decisionBadge(decision: number, toPhase: number): { label: string; cls: string } {
if (decision === PE_DECISION_REJECT) {
// Reject phân biệt: TuChoi(99) = "Từ chối" / TraLai(98) hoặc ChoDuyet(10) = "Trả lại"
if (toPhase === 99) return { label: 'Từ chối', cls: 'bg-rose-100 text-rose-700 border border-rose-200' }
return { label: 'Trả lại', cls: 'bg-amber-100 text-amber-700 border border-amber-200' }
}
return { label: 'Duyệt', cls: 'bg-emerald-100 text-emerald-700 border border-emerald-200' }
}
// Plan AD S25 — Parse comment để show next-target hint rõ ràng. BE comment
// format chuẩn từ Service:
// Approve advance Cấp: "Hoàn tất Cấp X, sang Cấp Y cùng Bước Z"
// Approve advance Bước: "Hoàn tất Bước X/Y, sang Bước Z (Cấp 1)"
// Approve skipToFinal: "[Duyệt vượt cấp tới Cấp cuối] ..." (Plan AC)
// Approve terminal: toPhase=DaDuyet(20)
// Reject OneLevel: "Trả về Cấp X (cùng Bước Y)" hoặc "không lùi được"
// Reject OneStep: "Trả về Bước X Cấp Y" hoặc "không lùi được"
// Reject Assignee: "Trả về Người chỉ định — Bước X (...) Cấp Y"
// Reject Drafter: "Trả về Người soạn thảo"
// Reject TuChoi: toPhase=TuChoi(99)
function extractNextTargetHint(decision: number, toPhase: number, comment: string | null): string {
if (decision === PE_DECISION_REJECT) {
if (toPhase === 99) return '→ Từ chối hoàn toàn'
const c = comment ?? ''
if (c.includes('không lùi được')) return '→ Không lùi được'
if (c.includes('Người chỉ định')) {
const m = c.match(/Bước\s*(\d+).*?Cấp\s*(\d+)/)
return m ? `→ Trả về Người chỉ định (Bước ${m[1]} Cấp ${m[2]})` : '→ Trả về Người chỉ định'
}
if (c.includes('Người soạn thảo') || c.includes('Drafter')) return '→ Trả về Người soạn thảo'
if (c.includes('Trả về 1 Cấp') || c.includes('Trả về Cấp')) {
const m = c.match(/Cấp\s*(\d+)/)
return m ? `→ Lùi về Cấp ${m[1]}` : '→ Lùi 1 Cấp'
}
if (c.includes('Trả về 1 Bước') || c.includes('Trả về Bước')) {
const m = c.match(/Bước\s*(\d+)/)
return m ? `→ Lùi về Bước ${m[1]}` : '→ Lùi 1 Bước'
}
return ''
}
// Approve
if (toPhase === 20) return '→ Đã duyệt hoàn tất'
const c = comment ?? ''
if (c.includes('Duyệt vượt cấp') || c.includes('Approver skip thẳng tới')) {
return '→ Vượt cấp tới Cấp cuối'
}
const levelMatch = c.match(/sang Cấp\s*(\d+)/)
if (levelMatch) return `→ Cấp ${levelMatch[1]}`
const stepMatch = c.match(/sang Bước\s*(\d+)/)
if (stepMatch) return `→ Bước ${stepMatch[1]} (Cấp 1)`
return ''
}
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
// Plan AC2 S25 — FE merge view: fetch changelogs + reconstruct synthetic
// Reject rows từ pre-Plan AC historical data (PE cũ deploy trước 2026-05-19
// KHÔNG có Approval row cho Reject vì BE cũ chỉ log Changelog). Merge approvals
// + synthetic + dedupe timestamp 5s bucket cùng approverUserId.
const changelogs = useQuery({
queryKey: ['pe-changelog', ev.id],
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
})
// Plan AF S25 — userMap fallback cho historical entries pre-Plan AE
// (userName="" empty/null). Cover real approvals + synthetic reject rows.
const userMap = useMemo(() => {
const m = new Map<string, string>()
if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName)
ev.approvals.forEach(a => {
if (a.approverUserId && a.approverName) m.set(a.approverUserId, a.approverName)
})
ev.approvalFlow?.steps?.forEach(s =>
s.levels?.forEach(l =>
l.approvers?.forEach(ap => {
if (ap.userId && ap.fullName) m.set(ap.userId, ap.fullName)
}),
),
)
ev.levelOpinions?.forEach(o => {
if (o.signedByUserId && o.signedByFullName) m.set(o.signedByUserId, o.signedByFullName)
})
ev.departmentOpinions?.forEach(o => {
if (o.userId && o.userName) m.set(o.userId, o.userName)
})
return m
}, [ev])
const resolveActorName = (a: PeApproval): string => {
if (a.approverName && a.approverName.trim() !== '') return a.approverName
if (a.approverUserId) {
const name = userMap.get(a.approverUserId)
if (name) return name
}
return 'Hệ thống'
}
const merged = useMemo(() => {
const phaseEnumMap: Record<string, number> = {
DangSoanThao: 1, ChoDuyet: 10, DaDuyet: 20, TraLai: 98, TuChoi: 99,
}
const PE_ENTITY_WORKFLOW = 5
const syntheticRejects: PeApproval[] = (changelogs.data ?? [])
.filter(c => {
if (c.entityType !== PE_ENTITY_WORKFLOW) return false
if (c.summary?.includes('→ TraLai') || c.summary?.includes('→ TuChoi')) return true
// 3 mode (OneLevel/OneStep/Assignee) giữ ChoDuyet → distinguish qua ContextNote keywords
const note = c.contextNote ?? ''
return note.includes('Trả về') || note.includes('không lùi được')
})
.map<PeApproval>(c => {
const m = c.summary?.match(/Chuyển phase (\w+) → (\w+)/)
const fromPhase = m ? (phaseEnumMap[m[1]] ?? 10) : 10
const toPhase = m ? (phaseEnumMap[m[2]] ?? 10) : 10
return {
id: `syn-${c.id}`,
fromPhase,
toPhase,
approverUserId: c.userId ?? null,
approverName: c.userName ?? null,
decision: 2,
comment: c.contextNote ?? c.summary ?? null,
approvedAt: c.createdAt,
}
})
const realRejectKeys = new Set(
ev.approvals
.filter(a => a.decision === 2)
.map(a => `${a.approverUserId ?? ''}-${Math.floor(new Date(a.approvedAt).getTime() / 5000)}`),
)
const dedupedSynthetic = syntheticRejects.filter(s =>
!realRejectKeys.has(`${s.approverUserId ?? ''}-${Math.floor(new Date(s.approvedAt).getTime() / 5000)}`),
)
return [...ev.approvals, ...dedupedSynthetic]
.sort((a, b) => new Date(a.approvedAt).getTime() - new Date(b.approvedAt).getTime())
}, [ev.approvals, changelogs.data])
if (merged.length === 0) return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{merged.map(a => {
const dec = decisionBadge(a.decision, a.toPhase)
const hint = extractNextTargetHint(a.decision, a.toPhase, a.comment)
return (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<span className={cn('rounded px-1.5 py-0.5 text-[11px] font-medium shrink-0', dec.cls)}>
{dec.label}
</span>
{hint && <span className="text-[12px] font-medium text-slate-700">{hint}</span>}
</div>
<span className="text-xs text-slate-500 shrink-0">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<div className="mt-1 text-xs text-slate-500">
{resolveActorName(a)}{a.comment && ` · ${a.comment}`}
</div>
</li>
)
})}
</ol>
)
}
// ===== Tab: Lịch sử =====
function HistoryTab({ ev }: { ev: PeDetailBundle }) {
const logs = useQuery({
queryKey: ['pe-changelog', ev.id],
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
})
// Plan AF S25 — userMap fallback cho historical entries pre-Plan AE
const userMap = useMemo(() => {
const m = new Map<string, string>()
if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName)
ev.approvals.forEach(a => {
if (a.approverUserId && a.approverName) m.set(a.approverUserId, a.approverName)
})
ev.approvalFlow?.steps?.forEach(s =>
s.levels?.forEach(l =>
l.approvers?.forEach(ap => {
if (ap.userId && ap.fullName) m.set(ap.userId, ap.fullName)
}),
),
)
ev.levelOpinions?.forEach(o => {
if (o.signedByUserId && o.signedByFullName) m.set(o.signedByUserId, o.signedByFullName)
})
ev.departmentOpinions?.forEach(o => {
if (o.userId && o.userName) m.set(o.userId, o.userName)
})
return m
}, [ev])
const resolveUserName = (l: PeChangelog): string => {
if (l.userName && l.userName.trim() !== '') return l.userName
if (l.userId) {
const name = userMap.get(l.userId)
if (name) return name
}
return 'Hệ thống'
}
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
// User UAT 2026-05-08: chỉ track events Trả lại + Gửi duyệt lại.
// User UAT 2026-05-19: + track Budget Adjust (Bug 1) + 4 mode Trả lại (Bug 2).
// Filter giữ:
// - Workflow transition về TraLai (phaseAtChange = TraLai = 98)
// - Workflow transition từ TraLai → khác (Drafter gửi lại — summary "TraLai →")
// - Workflow Trả lại 4 mode (summary chứa "Trả lại" — Plan AB S25 fix Bug 2)
// - Header Budget Adjust (summary chứa "ngân sách" — Plan AB S25 fix Bug 1)
// - Mọi thay đổi nội dung khi phaseAtChange = TraLai (Drafter sửa trước gửi lại)
// BE giữ data đầy đủ (audit trail) — chỉ filter ở UI, reversible.
const PE_PHASE_TRALAI = 98
const PE_ENTITY_WORKFLOW = 5
const PE_ENTITY_HEADER = 1
const filtered = (logs.data ?? []).filter(l => {
if (l.entityType === PE_ENTITY_WORKFLOW) {
if (l.phaseAtChange === PE_PHASE_TRALAI) return true
if (l.summary?.includes('TraLai →')) return true
if (l.summary?.includes('Trả lại')) return true
return false
}
if (l.entityType === PE_ENTITY_HEADER && l.summary?.toLowerCase().includes('ngân sách')) {
return true
}
return l.phaseAtChange === PE_PHASE_TRALAI
})
if (filtered.length === 0) return <p className="text-sm text-slate-500">Chưa lịch sử trả lại / điều chỉnh ngân sách / gửi duyệt lại.</p>
return (
<ol className="space-y-1.5 text-sm">
{filtered.map(l => (
<li key={l.id} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{resolveUserName(l)}</span>
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
</div>
<div className="text-slate-800">{l.summary}</div>
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
</li>
))}
</ol>
)
}
// ===== Cell upload file đính kèm per-NCC =====
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
function SupplierAttachmentsCell({
evaluationId,
supplierRowId,
attachments,
readOnly = false,
}: {
evaluationId: string
supplierRowId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
fd.append('supplierRowId', supplierRowId)
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
)
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
}
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
<div className="space-y-1">
{attachments.length === 0 && (
<div className="text-[11px] italic text-slate-400">Chưa file</div>
)}
{attachments.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">{fmtSize(a.fileSize)}</span>
<span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
</span>
{isPreviewable(a.fileName) && (
<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
onClick={() => download(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>
{!readOnly && (
<button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
))}
{previewAtt && (
<AttachmentPreviewDialog
open
evaluationId={evaluationId}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
{!readOnly && (
<div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={onPick}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
>
<Upload className="h-3 w-3" />
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
</button>
</div>
)}
</div>
)
}
// ===== Section Bảng so sánh — general attachments (không gắn NCC cụ thể) =====
// Purpose mặc định = ComparisonTable (4). Upload file Excel/PDF tổng hợp so
// sánh giá N NCC × M hạng mục. Storage path giống SupplierAttachmentsCell
// nhưng supplierRowId KHÔNG truyền → BE lưu NULL.
function GeneralAttachmentsSection({
evaluationId,
attachments,
readOnly = false,
}: {
evaluationId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
// KHÔNG append supplierRowId → BE set NULL → general attachment
fd.append('purpose', String(PeAttachmentPurpose.ComparisonTable))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên bảng so sánh.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
)
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
}
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
<div>
{!readOnly && (
<p className="mb-2 text-[12px] text-slate-500">
File Excel/PDF tổng hợp so sánh giá của tất cả NCC (không gắn với 1 NCC cụ thể).
</p>
)}
{attachments.length === 0 && readOnly && (
<p className="text-sm italic text-slate-400">Chưa bảng so sánh.</p>
)}
{attachments.length > 0 && (
<div className="mb-2 space-y-1.5">
{attachments.map(a => (
<div
key={a.id}
className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm"
>
<Paperclip className="h-4 w-4 shrink-0 text-brand-500" />
<span className="min-w-0 flex-1 truncate font-medium text-slate-800" title={a.fileName}>
{a.fileName}
</span>
<span className="shrink-0 text-[11px] text-slate-500">{fmtSize(a.fileSize)}</span>
<span className="shrink-0 rounded bg-brand-50 px-1.5 py-0.5 text-[10px] text-brand-700">
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
</span>
<span className="shrink-0 text-[10px] text-slate-400">
{new Date(a.createdAt).toLocaleDateString('vi-VN')}
</span>
{isPreviewable(a.fileName) && (
<button
onClick={() => setPreviewAtt(a)}
className="shrink-0 rounded p-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
<Eye className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => download(a)}
className="shrink-0 rounded p-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
>
<Download className="h-3.5 w-3.5" />
</button>
{!readOnly && (
<button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded p-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
{previewAtt && (
<AttachmentPreviewDialog
open
evaluationId={evaluationId}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
{!readOnly && (
<div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={onPick}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1.5 rounded border border-dashed border-brand-300 bg-brand-50/50 px-3 py-2 text-xs font-medium text-brand-700 hover:border-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<Upload className="h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải…' : '+ Tải lên bảng so sánh'}
</button>
</div>
)}
</div>
)
}