Files
solution-erp/fe-admin/src/components/pe/PeDetailTabs.tsx
pqhuy1987 69997da74f
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m23s
[CLAUDE] PurchaseEvaluation: bo o "Ten ngan sach" o manual budget (UAT vong 4 - anh chot "cho ten ngan sach bo di nhe")
- PeDetailTabs Section 5 Dieu chinh ngan sach: bo input "Ten (khong bat buoc)"
  (user khong hieu "y nghia du phong la gi") - manual budget chi con So tien (VND).
  State manualName drop, payload budgetManualName: null. Ten cu phieu truoc van
  hien read-only, ve null khi Luu dieu chinh lan toi.
- PeHeaderForm: payload budgetManualName null + hasManual detect theo CA amount
  (phieu moi name=null sau khi bo o Ten -> van nhan dung manual mode).
- PeWorkspaceCreateView: khong doi (chua tung co o Ten, payload '' || null = null san).
- SHA256 mirror x2 app IDENTICAL, build tsc+vite x2 PASS.
2026-06-11 18:22:26 +07:00

2645 lines
114 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, Wallet } 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 { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import { 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 { BudgetPhase, type BudgetListItem } from '@/types/budget'
import { SupplierType, SupplierTypeLabel } from '@/types/master'
import type { Paged, 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
// 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)
const canSubmitForApproval = mode === 'workspace'
&& canEditPhase
&& !readOnly
&& forwardPhase != null
// 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).
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.`
: 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. Chọn NCC / TP thắng thầu">
<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>
{/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" cho phép Drafter
(Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC Admin
sửa Budget link / Manual amount. BE PATCH /budget-adjust riêng. */}
<Section title="5. Điều chỉnh ngân sách">
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
</Section>
</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,
budgetId: ev.budgetId,
budgetManualName: ev.budgetManualName,
budgetManualAmount: ev.budgetManualAmount,
})
},
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>
<div className="md:col-span-2">
<Label className="text-[11px]">Điều khoản thanh toán</Label>
{/* S59 UAT "nhập tay chỉ được 1 dòng?" → Textarea đa dòng (render đã pre-wrap). */}
<Textarea
rows={3}
value={paymentTerms}
onChange={e => setPaymentTerms(e.target.value)}
placeholder={'Nhập điều khoản — Enter để xuống dòng'}
/>
</div>
</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. Ngân sách inline editor (Mig 17) =====
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách /
// Duyệt). Edit chỉ enable khi !readOnly + phase editable (DangSoanThao /
// TraLai). Read-only khi pendingMe=1 hoặc phase đã gửi duyệt / đã duyệt /
// từ chối. Empty values hiển thị empty (per user 2026-05-07).
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link.
// Session 20 turn 6: user yêu cầu manual mode chỉ nhập số tiền — bỏ Tên field
// khỏi UI. State manualName drop, BE save luôn null cho field này. Data cũ với
// tên vẫn hiển thị OK ở read-only display (ev.budgetManualName).
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
const [manualMode, setManualMode] = useState(initialManual)
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
// Eligible budgets — chỉ fetch khi user có khả năng edit
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', ev.projectId],
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: canEdit,
})
// Dirty detect — compare current state vs ev original
const dirty = manualMode !== initialManual
|| (manualMode && manualAmount !== (ev.budgetManualAmount ?? 0))
|| (!manualMode && budgetId !== (ev.budgetId ?? ''))
const save = useMutation({
mutationFn: async () => {
const payload = manualMode
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau: ev.tenGoiThau,
diaDiem: ev.diaDiem,
moTa: ev.moTa,
paymentTerms: ev.paymentTerms,
...payload,
})
},
onSuccess: () => {
toast.success('Đã cập nhật ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
// Read-only mode: chỉ display (không toggle, không edit)
if (!canEdit) {
return (
<FormRow
label="b. Ngân sách"
value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '}{ev.budget.tenNganSach}
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
<span className="text-slate-700">
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
{ev.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</span>
) : <span className="text-slate-400"></span>}
/>
)
}
// Editable mode (canEdit=true)
return (
<div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span>
<div className="min-w-0 flex-1 space-y-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={manualMode}
onChange={e => setManualMode(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link)
</label>
{!manualMode ? (
<Select
value={budgetId}
onChange={e => setBudgetId(e.target.value)}
className="text-sm"
>
<option value=""></option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
) : (
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(manualAmount)}
onChange={e => setManualAmount(parseVnd(e.target.value))}
placeholder="0"
className="pr-10 font-mono text-right text-sm"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
)}
{dirty && (
<div className="flex items-center gap-2">
<Button
onClick={() => save.mutate()}
disabled={save.isPending}
className="h-7 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu ngân sách'}
</Button>
<button
onClick={() => {
setManualMode(initialManual)
setBudgetId(ev.budgetId ?? '')
setManualAmount(ev.budgetManualAmount ?? 0)
}}
className="text-[11px] text-slate-500 hover:text-slate-700"
>
Hủy thay đi
</button>
</div>
)}
</div>
</div>
)
}
// ===== Section "Điều chỉnh ngân sách" (S22+4 — Feature 2) =====
// Cho phép Drafter (DangSoanThao/TraLai) HOẶC Approver currentLevel (ChoDuyet)
// HOẶC Admin sửa BudgetId + BudgetManualName + BudgetManualAmount qua endpoint
// PATCH /budget-adjust riêng. Audit changelog tự động.
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const { user: currentUser } = useAuth()
const qc = useQueryClient()
const [editing, setEditing] = useState(false)
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|| ev.phase === PurchaseEvaluationPhase.TraLai
// F4 Approver scope (Mig 30): phase ChoDuyet + actor in currentApproval.approvers
// + currentLevel có flag AllowApproverEditBudget=true (admin Designer tick per slot).
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorInCurrentLevel
&& approverEditBudgetAllowed
// S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly
// pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match +
// Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true)
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace).
const canAdjust = isAdmin
|| (!readOnly && isDrafter && isDrafterPhase)
|| isApproverChoDuyet
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
const [manualMode, setManualMode] = useState(initialManual)
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
// S59 UAT vòng 4 (anh chốt "chỗ tên ngân sách bỏ đi"): bỏ ô "Tên (không bắt buộc)"
// — user không hiểu ý nghĩa; manual budget chỉ còn Số tiền. Tên cũ (phiếu trước)
// vẫn hiển thị read-only, sẽ về null khi Lưu điều chỉnh lần tới.
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets-adjust', ev.projectId],
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: editing && canAdjust,
})
const adjustMut = useMutation({
mutationFn: async () => {
const payload = manualMode
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, payload)
},
onSuccess: () => {
toast.success('Đã điều chỉnh ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setEditing(false)
},
onError: e => toast.error(getErrorMessage(e)),
})
// History defer S22+5 — changelog fetch separate endpoint, KHÔNG có trong
// PeDetailBundle. UAT user xem ở Panel "Lịch sử thay đổi" thông qua tab History.
// Display read mode
const displayLink = ev.budget ? (
<span>
<span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span>
{' · '}{ev.budget.tenNganSach}
{' · '}<span className="font-semibold text-slate-900">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</span>
) : (ev.budgetManualAmount != null || ev.budgetManualName) ? (
<span>
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
{ev.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</span>
) : <span className="italic text-slate-400">Chưa ngân sách</span>
return (
<div className="space-y-3">
{/* Read mode + Edit toggle */}
{!editing && (
<div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2">
<div className="flex items-start gap-2">
<Wallet className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<div className="text-sm text-slate-700">{displayLink}</div>
</div>
{canAdjust && (
<Button
onClick={() => {
setManualMode(initialManual)
setBudgetId(ev.budgetId ?? '')
setManualAmount(ev.budgetManualAmount ?? 0)
setEditing(true)
}}
variant="ghost"
className="h-7 shrink-0 px-2 text-xs"
>
<Pencil className="h-3 w-3" /> Điều chỉnh
</Button>
)}
</div>
)}
{/* Edit mode */}
{editing && canAdjust && (
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
{isApproverChoDuyet && (
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
Bạn đang điều chỉnh ngân sách lúc phiếu đang duyệt thay đi sẽ đưc ghi vào lịch sử.
</div>
)}
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={manualMode}
onChange={e => setManualMode(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link Budget)
</label>
{!manualMode ? (
<div>
<Label className="text-[11px]">Chọn Budget từ danh sách</Label>
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)} className="text-sm">
<option value=""> (huỷ link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
</div>
) : (
<div className="max-w-xs">
<Label className="text-[11px]">Số tiền (VND)</Label>
<div className="relative">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(manualAmount)}
onChange={e => setManualAmount(parseVnd(e.target.value))}
placeholder="0"
className="pr-10 font-mono text-right text-sm"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
onClick={() => setEditing(false)}
className="h-7 px-3 text-xs"
>
Hủy
</Button>
<Button
onClick={() => adjustMut.mutate()}
disabled={adjustMut.isPending}
className="h-7 px-3 text-xs"
>
{adjustMut.isPending ? 'Đang lưu…' : 'Lưu điều chỉnh'}
</Button>
</div>
</div>
)}
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */}
</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)
const winnerSupplierRowId = ev.selectedSupplierId
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
const giaChaoThau = winnerSupplierRowId
? ev.details
.flatMap(d => d.quotes)
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
.reduce((sum, q) => sum + q.thanhTien, 0)
: null
// 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} />
<BudgetFieldRow 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>
{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>
)
}
// 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)
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
const budgetBundle = useQuery({
queryKey: ['budget-detail-for-pe', ev.budgetId],
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map<string, number>()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
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>
{!readOnly && (
<Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm hạng mục
</Button>
)}
</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}
budgetRowMap={budgetRowMap}
showBudgetCol={showBudgetCol}
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, budgetRowMap, showBudgetCol, onEditDetail,
}: {
detail: PeDetailRow
ev: PeDetailBundle
readOnly: boolean
budgetRowMap: Map<string, number>
showBudgetCol: 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 NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const bgValue = budgetRowMap.get(`${detail.groupCode}|${detail.itemCode ?? ''}`)
const delta = bgValue != null ? detail.thanhTienNganSach - bgValue : null
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>
{showBudgetCol && bgValue != null && (
<div className="border-l border-slate-200 pl-3">
<div className="text-[10px] uppercase text-slate-400">NS link</div>
<div className="font-mono text-[11px]">{fmtMoney(bgValue)}</div>
<div className={cn(
'font-mono text-[10px]',
delta! > 0 && 'text-red-600',
delta! < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
</div>
</div>
)}
</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 ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'}
>
<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>
)
}