All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m22s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2885 lines
125 KiB
TypeScript
2885 lines
125 KiB
TypeScript
// Detail content cho 1 phiếu Duyệt NCC. Flat render (no tabs): Thông tin +
|
||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||
// → PeApprovalsSection + PeHistorySection).
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { toast } from 'sonner'
|
||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||
import { Button } from '@/components/ui/Button'
|
||
import { Dialog } from '@/components/ui/Dialog'
|
||
import { Input } from '@/components/ui/Input'
|
||
import { Label } from '@/components/ui/Label'
|
||
import { SearchableSelect } from '@/components/ui/SearchableSelect'
|
||
import { Select } from '@/components/ui/Select'
|
||
import { api } from '@/lib/api'
|
||
import { getErrorMessage } from '@/lib/apiError'
|
||
import { cn } from '@/lib/cn'
|
||
import { useAuth } from '@/contexts/AuthContext'
|
||
import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
|
||
import {
|
||
PeAttachmentPurpose,
|
||
PeAttachmentPurposeLabel,
|
||
PeDepartmentKind,
|
||
PeDepartmentKindLabel,
|
||
PeDisplayStatusColor,
|
||
PeDisplayStatusLabel,
|
||
PurchaseEvaluationPhase,
|
||
PurchaseEvaluationPhaseLabel,
|
||
PurchaseEvaluationTypeLabel,
|
||
getPeDisplayStatus,
|
||
isEditablePhase,
|
||
type PeApproval,
|
||
type PeAttachment,
|
||
type PeChangelog,
|
||
type PeDepartmentOpinion,
|
||
type PeDetailBundle,
|
||
type PeDetailRow,
|
||
type PeLevelOpinion,
|
||
type PeQuote,
|
||
type PeSupplier,
|
||
} from '@/types/purchaseEvaluation'
|
||
import { SupplierType, SupplierTypeLabel } from '@/types/master'
|
||
import type { Supplier } from '@/types/master'
|
||
|
||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||
|
||
// Session 20 turn 4 — input helpers cho NCC/Quote inline form.
|
||
// VND format dùng convention VN dấu chấm ngàn (1.000.000). Strip non-digit
|
||
// khi parse user input → number. Empty/0 → empty string để placeholder hiện.
|
||
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
|
||
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
|
||
|
||
// Validation cơ bản FE — empty OK (optional fields). BE FluentValidation
|
||
// chưa enforce, FE check để user nhập sai biết ngay.
|
||
const PHONE_RE = /^0\d{9,10}$/ // VN: bắt đầu 0, 10-11 digits sau khi strip space/dash/dot
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, ''))
|
||
const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s)
|
||
|
||
// Session 20 turn 8: trang trí 5 NCC khác màu (cycle theo index). Winner override
|
||
// thành emerald nổi bật. Literal Tailwind class để JIT scan compile được.
|
||
const NCC_PALETTES = [
|
||
'border-l-blue-400 bg-blue-50/40',
|
||
'border-l-purple-400 bg-purple-50/40',
|
||
'border-l-sky-400 bg-sky-50/40',
|
||
'border-l-teal-400 bg-teal-50/40',
|
||
'border-l-pink-400 bg-pink-50/40',
|
||
] as const
|
||
|
||
// Giá chào thầu của NCC/TP được chọn (winner) = sum quotes.thanhTien của winner
|
||
// supplier-row. Single source of truth — Section 3 (ChonNccSection) + pre-check
|
||
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
|
||
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
|
||
function computeGiaChaoThau(ev: PeDetailBundle): number | null {
|
||
const winnerSupplierRowId = ev.selectedSupplierId
|
||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
||
: null
|
||
if (winnerSupplierRowId === null) return null
|
||
return ev.details
|
||
.flatMap(d => d.quotes)
|
||
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
|
||
.reduce((sum, q) => sum + q.thanhTien, 0)
|
||
}
|
||
|
||
// Main detail content — flat render 3 section không tabs.
|
||
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
|
||
//
|
||
// `mode` (2026-05-07):
|
||
// - 'detail' (default): full UX — Section 5 Ý kiến 4PB editable theo readOnly.
|
||
// Dùng ở leaf "Danh sách" + "Duyệt" (3-panel pages).
|
||
// - 'workspace': dùng ở leaf "Thao tác" (2-panel workspace). Section 5 LUÔN
|
||
// disabled (Q5 user — ý kiến nhập khi duyệt, không phải workspace nhập liệu).
|
||
// Workflow Panel + Approvals + History KHÔNG render trong PeDetailTabs (luôn
|
||
// ở caller PeWorkflowPanel — workspace caller skip render Panel 3 hoàn toàn).
|
||
export function PeDetailTabs({
|
||
evaluation,
|
||
onBack,
|
||
onDelete,
|
||
readOnly = false,
|
||
mode = 'detail',
|
||
autoEditHeader = false,
|
||
}: {
|
||
evaluation: PeDetailBundle
|
||
onBack: () => void
|
||
onDelete: () => void
|
||
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
|
||
readOnly?: boolean
|
||
/** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */
|
||
mode?: 'detail' | 'workspace'
|
||
/** Auto open Section 1 InfoTab in edit mode khi mount — triggered từ pencil icon Panel 1 */
|
||
autoEditHeader?: boolean
|
||
}) {
|
||
const qc = useQueryClient()
|
||
// canEditPhase: bao gồm cả TraLai (user 2026-05-07). Header bar action
|
||
// buttons "Sửa header" + "Xóa" + "Đóng" workspace mode đã chuyển xuống bottom
|
||
// action bar (B11+ user 2026-05-07).
|
||
const canEditPhase = isEditablePhase(evaluation.phase)
|
||
const opinionsReadOnly = readOnly || mode === 'workspace'
|
||
|
||
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
||
const { user: currentUser } = useAuth()
|
||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||
const actorMatchesLevel = isAdmin
|
||
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
||
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
|
||
// Mig 29 (S21 t5) — read F3 từ currentLevelOptions (per-NV slot)
|
||
&& (evaluation.currentLevelOptions?.allowApproverEditDetails ?? false)
|
||
&& actorMatchesLevel
|
||
const itemsReadOnly = readOnly && !approverEditMode
|
||
|
||
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
|
||
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
|
||
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
|
||
// Mig 31 (S23 t1) — F2 Drafter-from-Nháp semantic deprecated. skipToFinal moved
|
||
// sang Approver scope ChoDuyet (per-Level slot — xem PeWorkflowPanel).
|
||
|
||
const submitForApproval = useMutation({
|
||
mutationFn: async () => {
|
||
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
|
||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||
targetPhase: next,
|
||
decision: 1,
|
||
comment: null,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('Đã gửi duyệt phiếu — chuyển sang quy trình duyệt.')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
onBack()
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
|
||
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||
|
||
// Pre-check data-completeness cho action "Lưu & Gửi Duyệt" (S60 — anh Kiệt chốt).
|
||
// CHỈ áp cho action gửi duyệt — liệt kê TẤT CẢ mục thiếu của Section 3 "Đơn vị
|
||
// NCC/TP được chọn". Predicate khớp BE guard TransitionAsync (em main song song).
|
||
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
|
||
const missingForApproval = useMemo(() => {
|
||
const missing: string[] = []
|
||
// 1. Chưa chọn Đơn vị NCC/TP
|
||
if (evaluation.selectedSupplierId == null) {
|
||
missing.push("Chưa chọn Đơn vị NCC/TP")
|
||
} else {
|
||
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
|
||
// Chỉ check khi đã chọn (không spam khi chưa chọn — đã có mục 1).
|
||
const gia = computeGiaChaoThau(evaluation)
|
||
if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu")
|
||
}
|
||
// 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
|
||
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
||
if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
|
||
missing.push("Chưa nhập Ngân sách kỳ này")
|
||
}
|
||
// 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3)
|
||
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
|
||
missing.push("Chưa đính kèm Bảng so sánh")
|
||
}
|
||
return missing
|
||
}, [evaluation])
|
||
|
||
const canSubmitForApproval = mode === 'workspace'
|
||
&& canEditPhase
|
||
&& !readOnly
|
||
&& forwardPhase != null
|
||
&& missingForApproval.length === 0
|
||
|
||
// Tooltip reason cho button disabled (giúp diagnose tại sao "Lưu & Gửi Duyệt"
|
||
// không bấm được — user feedback 2026-05-07). Reason cũ (workspace/canEditPhase/
|
||
// readOnly/forwardPhase) giữ nguyên; append data-completeness check S60 sau cùng.
|
||
const submitDisabledReason = !canEditPhase
|
||
? `Phiếu đã ở phase ${PurchaseEvaluationPhaseLabel[evaluation.phase]} — chỉ Bản nháp / Trả lại mới sửa + gửi được.`
|
||
: readOnly
|
||
? 'Chế độ chỉ đọc.'
|
||
: !forwardPhase
|
||
? `Workflow không có phase tiếp theo từ ${PurchaseEvaluationPhaseLabel[evaluation.phase]}. Liên hệ admin kiểm tra cấu hình quy trình.`
|
||
: missingForApproval.length > 0
|
||
? `Chưa đủ thông tin mục 3 'Đơn vị NCC/TP được chọn':\n${missingForApproval.map(m => `• ${m}`).join('\n')}`
|
||
: null
|
||
|
||
return (
|
||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-base font-semibold text-slate-900">{evaluation.tenGoiThau}</h2>
|
||
{/* Display status meta (Bản nháp / Đã gửi duyệt / Đã duyệt / Từ chối)
|
||
— phase chi tiết hiện ở Workflow timeline Panel 3. */}
|
||
<span
|
||
className={cn(
|
||
'rounded px-1.5 py-0.5 text-[11px] font-medium',
|
||
PeDisplayStatusColor[getPeDisplayStatus(evaluation.phase)],
|
||
)}
|
||
>
|
||
{PeDisplayStatusLabel[getPeDisplayStatus(evaluation.phase)]}
|
||
</span>
|
||
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
|
||
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
|
||
</span>
|
||
{readOnly && (
|
||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
|
||
chế độ duyệt
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
|
||
<span className="font-mono">{evaluation.maPhieu ?? '—'}</span>
|
||
<span>·</span>
|
||
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
|
||
<span>·</span>
|
||
<span>{evaluation.projectName}</span>
|
||
{/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (lời sếp) */}
|
||
{evaluation.workItemName && <><span>–</span><span>{evaluation.workItemName}</span></>}
|
||
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
|
||
</div>
|
||
</div>
|
||
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
|
||
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
|
||
giữ Đóng cho non-workspace view (Danh sách + Duyệt — readOnly). */}
|
||
{(readOnly || mode !== 'workspace') && (
|
||
<div className="flex gap-2">
|
||
<Button variant="ghost" onClick={onBack} className="text-xs">← Đóng</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="divide-y divide-slate-200">
|
||
{/* Section layout (Session 20 Chunk B): Hạng mục nested expand chứa NCC
|
||
(tầng 1 = hạng mục, tầng 2 = NCC tham gia + báo giá inline). NCC
|
||
tham gia section riêng bỏ — gộp vào Section 2 expand panel. Tên
|
||
hạng mục + giá trị auto từ gói thầu (Chunk A BE seed). */}
|
||
<Section title="1. Thông tin gói thầu">
|
||
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
|
||
</Section>
|
||
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
|
||
{/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2 */}
|
||
{/* Plan Q S23 t7 — Drop mx-5 banner, full-width Section padding to
|
||
align với ItemsTab header (button "+ Thêm hạng mục" right-aligned
|
||
KHÔNG còn lệch khỏi banner inset gap). */}
|
||
{approverEditMode && readOnly && (
|
||
<div className="mb-3 rounded border border-violet-200 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
|
||
ⓘ Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit).
|
||
Mọi thay đổi sẽ được ghi vào Lịch sử chỉnh sửa.
|
||
</div>
|
||
)}
|
||
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
|
||
</Section>
|
||
<Section title="3. Đơn vị NCC/TP được chọn">
|
||
<ChonNccSection ev={evaluation} readOnly={readOnly} />
|
||
</Section>
|
||
<Section title="4. Ý kiến cấp duyệt (sign-off theo workflow)">
|
||
{mode === 'workspace' && (
|
||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
||
Ý kiến + chữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
|
||
</div>
|
||
)}
|
||
{/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ
|
||
fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */}
|
||
{evaluation.approvalWorkflowId
|
||
? <LevelOpinionsSectionV2 ev={evaluation} />
|
||
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
|
||
</Section>
|
||
{/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA:
|
||
module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3
|
||
thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
|
||
</div>
|
||
|
||
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
|
||
- Xóa phiếu (CHỈ Bản nháp, soft-delete BE) — bên trái red
|
||
- Lưu (toast confirm, KHÔNG đóng workspace) — chính giữa ghost
|
||
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
|
||
User 2026-05-07. */}
|
||
{mode === 'workspace' && canEditPhase && !readOnly && (
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
||
<div className="flex items-center gap-3">
|
||
{/* Xóa phiếu — CHỈ DangSoanThao (bản nháp). TraLai không cho xóa
|
||
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
|
||
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
|
||
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => {
|
||
if (confirm(`Xóa phiếu "${evaluation.tenGoiThau}"? Phiếu sẽ ẩn khỏi danh sách (soft-delete, không xóa hoàn toàn trong DB).`)) {
|
||
onDelete()
|
||
}
|
||
}}
|
||
className="gap-1.5 text-xs"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> Xóa phiếu
|
||
</Button>
|
||
)}
|
||
<span className="text-[11px] text-slate-500">
|
||
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => {
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
toast.success('Đã lưu — sync server.')
|
||
}}
|
||
className="text-xs"
|
||
>
|
||
Lưu
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
if (!forwardPhase) return
|
||
const confirmMsg = `Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`
|
||
if (confirm(confirmMsg)) {
|
||
submitForApproval.mutate()
|
||
}
|
||
}}
|
||
disabled={!canSubmitForApproval || submitForApproval.isPending}
|
||
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
|
||
className="text-xs"
|
||
>
|
||
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
// Session 20 turn 11: padding responsive cho laptop màn nhỏ — px-3 trên xs
|
||
// (tiết kiệm ~16px width), bump px-5 từ sm+ trở lên.
|
||
return (
|
||
<section className="px-3 py-3 sm:px-5 sm:py-4">
|
||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ===== Section 5 — Ý kiến 4 phòng ban =====
|
||
// Render 2x2 grid 4 box (Phê duyệt / CCM / MuaHàng / SM-PM). Mỗi box hiển
|
||
// thị Opinion text + chữ ký (UserName + SignedAt) nếu đã ký, hoặc form nhập
|
||
// + 2 button "Lưu" + "Lưu & Ký" khi chưa ký / readOnly=false.
|
||
function DepartmentOpinionsSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||
const KINDS: { kind: number; label: string }[] = [
|
||
{ kind: PeDepartmentKind.PheDuyet, label: PeDepartmentKindLabel[PeDepartmentKind.PheDuyet] },
|
||
{ kind: PeDepartmentKind.Ccm, label: PeDepartmentKindLabel[PeDepartmentKind.Ccm] },
|
||
{ kind: PeDepartmentKind.MuaHang, label: PeDepartmentKindLabel[PeDepartmentKind.MuaHang] },
|
||
{ kind: PeDepartmentKind.SmPm, label: PeDepartmentKindLabel[PeDepartmentKind.SmPm] },
|
||
]
|
||
return (
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||
{KINDS.map(k => {
|
||
const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null
|
||
return (
|
||
<OpinionBox
|
||
key={k.kind}
|
||
evaluationId={ev.id}
|
||
kind={k.kind}
|
||
kindLabel={k.label}
|
||
existing={existing}
|
||
readOnly={readOnly}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function OpinionBox({
|
||
evaluationId,
|
||
kind,
|
||
kindLabel,
|
||
existing,
|
||
readOnly,
|
||
}: {
|
||
evaluationId: string
|
||
kind: number
|
||
kindLabel: string
|
||
existing: PeDepartmentOpinion | null
|
||
readOnly: boolean
|
||
}) {
|
||
const qc = useQueryClient()
|
||
const [text, setText] = useState(existing?.opinion ?? '')
|
||
const isSigned = !!existing?.signedAt
|
||
|
||
const save = useMutation({
|
||
mutationFn: async (sign: boolean) =>
|
||
api.post(`/purchase-evaluations/${evaluationId}/opinions`, {
|
||
kind,
|
||
opinion: text || null,
|
||
sign,
|
||
}),
|
||
onSuccess: () => {
|
||
toast.success('Đã lưu ý kiến.')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
return (
|
||
<div className={cn(
|
||
'rounded-lg border bg-white p-3',
|
||
isSigned ? 'border-emerald-200' : 'border-slate-200',
|
||
)}>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h4 className="text-[13px] font-semibold uppercase tracking-wide text-slate-700">{kindLabel}</h4>
|
||
{isSigned && (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||
<Check className="h-3 w-3" /> Đã ký
|
||
</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 có ý kiến</span>}
|
||
</div>
|
||
{isSigned && (
|
||
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
|
||
Ký 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
|
||
? <>Ký 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ó 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 có ý kiến duyệt.</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||
{opinions.map(o => <StepOpinionEntry key={o.id} opinion={o} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
|
||
const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId
|
||
return (
|
||
<div className="rounded-lg border border-emerald-200 bg-white p-3">
|
||
<div className="mb-2 flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<h4 className="text-[13px] font-semibold text-slate-700">
|
||
Cấp {opinion.levelOrder} — <span className="text-slate-900">{opinion.approverFullName}</span>
|
||
</h4>
|
||
{isAdminOverride && (
|
||
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
||
⚠ Admin <strong>{opinion.signedByFullName}</strong> duyệt thay
|
||
</div>
|
||
)}
|
||
</div>
|
||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||
<Check className="h-3 w-3" /> Đã duyệt
|
||
</span>
|
||
</div>
|
||
<div className="whitespace-pre-wrap text-sm text-slate-800">
|
||
{opinion.comment}
|
||
</div>
|
||
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
|
||
{new Date(opinion.signedAt).toLocaleString('vi-VN')}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||
|
||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
||
return (
|
||
<div>
|
||
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử duyệt ({ev.approvals.length})</h3>
|
||
<ApprovalsTab ev={ev} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
|
||
return (
|
||
<div>
|
||
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử thay đổi</h3>
|
||
<HistoryTab ev={ev} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
|
||
// Inline editable khi canEdit (=!readOnly && phase editable). Edit pencil button
|
||
// "Sửa" flip display ↔ form mode. Save dùng existing PUT /pe/:id endpoint với
|
||
// current entity values + new header fields. Dự án + Type LOCKED sau create —
|
||
// chỉ Tên/Địa điểm/Mô tả/Payment editable inline. autoEdit prop cho phép trigger
|
||
// edit mode từ pencil icon trong PeListPanel (URL flag ?editHeader=1).
|
||
// Phase editable = DangSoanThao + TraLai (user 2026-05-07).
|
||
function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boolean; autoEdit: boolean }) {
|
||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||
const qc = useQueryClient()
|
||
const [editing, setEditing] = useState(autoEdit && canEdit)
|
||
const [tenGoiThau, setTenGoiThau] = useState(ev.tenGoiThau)
|
||
const [diaDiem, setDiaDiem] = useState(ev.diaDiem ?? '')
|
||
const [moTa, setMoTa] = useState(ev.moTa ?? '')
|
||
const [paymentTerms, setPaymentTerms] = useState(ev.paymentTerms ?? '')
|
||
|
||
// User 2026-05-07: re-trigger editing mode khi click pencil ở Panel 1 cho
|
||
// PHIẾU KHÁC (ev.id thay đổi) hoặc autoEdit prop change. useState init chỉ
|
||
// chạy mount-time → cần useEffect sync khi parent re-render với props mới.
|
||
useEffect(() => {
|
||
if (autoEdit && canEdit) {
|
||
setEditing(true)
|
||
// Sync values từ ev mới (tránh stale state khi switch giữa 2 phiếu)
|
||
setTenGoiThau(ev.tenGoiThau)
|
||
setDiaDiem(ev.diaDiem ?? '')
|
||
setMoTa(ev.moTa ?? '')
|
||
setPaymentTerms(ev.paymentTerms ?? '')
|
||
}
|
||
}, [autoEdit, canEdit, ev.id, ev.tenGoiThau, ev.diaDiem, ev.moTa, ev.paymentTerms])
|
||
|
||
const dirty = tenGoiThau !== ev.tenGoiThau
|
||
|| diaDiem !== (ev.diaDiem ?? '')
|
||
|| moTa !== (ev.moTa ?? '')
|
||
|| paymentTerms !== (ev.paymentTerms ?? '')
|
||
|
||
const save = useMutation({
|
||
mutationFn: async () => {
|
||
await api.put(`/purchase-evaluations/${ev.id}`, {
|
||
id: ev.id,
|
||
tenGoiThau,
|
||
diaDiem: diaDiem || null,
|
||
moTa: moTa || null,
|
||
paymentTerms: paymentTerms || null,
|
||
// S61 — module Budget cũ XÓA HẲN; PE giữ 2 ô ngân sách mới (echo lại
|
||
// giá trị hiện tại để PUT update không xóa nhầm — drafter sửa qua bảng
|
||
// TỔNG HỢP NGÂN SÁCH / PATCH budget-adjust).
|
||
budgetPeriodAmount: ev.budgetPeriodAmount,
|
||
expectedRemainingAmount: ev.expectedRemainingAmount,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('Đã cập nhật thông tin')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
setEditing(false)
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
function reset() {
|
||
setTenGoiThau(ev.tenGoiThau)
|
||
setDiaDiem(ev.diaDiem ?? '')
|
||
setMoTa(ev.moTa ?? '')
|
||
setPaymentTerms(ev.paymentTerms ?? '')
|
||
}
|
||
|
||
if (!editing) {
|
||
return (
|
||
<dl className="space-y-2 text-sm">
|
||
<div className="flex items-start justify-between">
|
||
<FormRow label="a. Tên gói thầu" value={ev.tenGoiThau} />
|
||
{canEdit && (
|
||
<button
|
||
onClick={() => setEditing(true)}
|
||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 hover:text-brand-600"
|
||
title="Sửa thông tin gói thầu"
|
||
>
|
||
<Pencil className="h-3 w-3" /> Sửa
|
||
</button>
|
||
)}
|
||
</div>
|
||
<FormRow label="b. Dự án" value={ev.projectName} />
|
||
{/* S57bis — Hạng mục công việc (WorkItem master). Phiếu cũ null → "—". */}
|
||
<FormRow label="c. Hạng mục công việc" value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode} — ` : ''}${ev.workItemName}` : '—'} />
|
||
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
|
||
<div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600">
|
||
{ev.diaDiem && <div><span className="text-slate-400">Địa điểm:</span> {ev.diaDiem}</div>}
|
||
{ev.moTa && <div><span className="text-slate-400">Mô 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]">Mô tả ngắn</Label>
|
||
<Input
|
||
value={moTa}
|
||
onChange={e => setMoTa(e.target.value)}
|
||
placeholder="Phương án A: ..."
|
||
/>
|
||
</div>
|
||
{/* S59 vòng 5: field "Điều khoản thanh toán" GỠ khỏi inline-edit (anh chốt
|
||
"bỏ nốt ra luôn tất cả các form"). State paymentTerms giữ — save giữ nguyên
|
||
data cũ; phiếu đã nhập vẫn hiển thị read-only ở header info. */}
|
||
</div>
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => { reset(); setEditing(false) }}
|
||
className="h-7 px-3 text-xs"
|
||
>
|
||
Hủy
|
||
</Button>
|
||
<Button
|
||
onClick={() => save.mutate()}
|
||
disabled={!dirty || !tenGoiThau || save.isPending}
|
||
className="h-7 px-3 text-xs"
|
||
>
|
||
{save.isPending ? 'Đang lưu…' : 'Lưu'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) =====
|
||
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3
|
||
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)".
|
||
// Save dùng POST /pe/:id/select-winner endpoint hiện có.
|
||
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||
const qc = useQueryClient()
|
||
const setWinner = useMutation({
|
||
mutationFn: async (supplierId: string) =>
|
||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||
onSuccess: () => {
|
||
toast.success('Đã chọn NCC.')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
if (!canEdit) {
|
||
return (
|
||
<FormRow
|
||
label="a. NCC / TP được chọn"
|
||
value={ev.selectedSupplierName
|
||
? <span className="font-medium text-emerald-700">✓ {ev.selectedSupplierName}</span>
|
||
: <span className="text-slate-400">— (chưa chọn)</span>}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
||
<div className="relative min-w-0 flex-1">
|
||
<Select
|
||
value={ev.selectedSupplierId ?? ''}
|
||
onChange={e => setWinner.mutate(e.target.value)}
|
||
disabled={ev.suppliers.length === 0 || setWinner.isPending}
|
||
className="text-sm"
|
||
>
|
||
<option value="">— Chọn NCC từ danh sách Section 3 —</option>
|
||
{ev.suppliers.map(s => (
|
||
<option key={s.id} value={s.supplierId}>
|
||
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
|
||
{setWinner.isPending && (
|
||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||
<span>Đang chọn NCC + sync cột giá Section 4…</span>
|
||
</div>
|
||
)}
|
||
{ev.suppliers.length === 0 && (
|
||
<p className="mt-1 text-[11px] text-amber-600">
|
||
Thêm NCC ở Section 3 trước rồi mới chọn winner.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
|
||
// Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
|
||
// BE trả `ev.budgetSummary`. 2 block:
|
||
// A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
|
||
// dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
|
||
// B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
|
||
// (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
|
||
// budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
|
||
|
||
// fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
|
||
const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
|
||
const fmtVndSigned = (v: number) =>
|
||
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
|
||
const fmtPct = (num: number, denom: number): string | null =>
|
||
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
|
||
|
||
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
|
||
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
|
||
function VndInlineEdit({
|
||
initial, allowNegative = false, onSave, saving, label,
|
||
}: {
|
||
initial: number | null
|
||
allowNegative?: boolean
|
||
onSave: (v: number | null) => void
|
||
saving: boolean
|
||
label?: string
|
||
}) {
|
||
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
|
||
const [neg, setNeg] = useState((initial ?? 0) < 0)
|
||
const parse = (): number | null => {
|
||
const n = parseVnd(text)
|
||
if (n === 0 && text.trim() === '') return null
|
||
return allowNegative && neg ? -n : n
|
||
}
|
||
const dirty = parse() !== initial
|
||
return (
|
||
<div className="flex items-center justify-end gap-1.5">
|
||
{allowNegative && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setNeg(v => !v)}
|
||
className={cn(
|
||
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
|
||
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||
)}
|
||
title="Đảo dấu âm/dương"
|
||
>
|
||
{neg ? '−' : '+'}
|
||
</button>
|
||
)}
|
||
<div className="relative w-40">
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={text}
|
||
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
|
||
placeholder="0"
|
||
aria-label={label}
|
||
className="h-7 pr-6 font-mono text-right text-[13px]"
|
||
/>
|
||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-[11px] font-medium text-slate-500">đ</span>
|
||
</div>
|
||
<Button
|
||
onClick={() => onSave(parse())}
|
||
disabled={!dirty || saving}
|
||
className="h-7 px-2 text-[11px]"
|
||
>
|
||
{saving ? '…' : 'Lưu'}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú).
|
||
// tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50.
|
||
function BudgetRow({
|
||
label, sub, value, third, tone, danger, mono = true,
|
||
}: {
|
||
label: React.ReactNode
|
||
sub?: React.ReactNode
|
||
value: React.ReactNode
|
||
third?: React.ReactNode
|
||
tone?: 'brand' | 'brand-soft' | 'blue-soft'
|
||
danger?: boolean
|
||
mono?: boolean
|
||
}) {
|
||
const toneCls =
|
||
tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
|
||
: tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
|
||
: tone === 'blue-soft' ? 'bg-blue-50'
|
||
: ''
|
||
return (
|
||
<div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
|
||
<div className="min-w-0 flex-1">
|
||
<div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>{label}</div>
|
||
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
|
||
</div>
|
||
<div className={cn(
|
||
'w-48 shrink-0 text-right tabular-nums',
|
||
mono && 'font-mono',
|
||
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
|
||
)}>
|
||
{value}
|
||
</div>
|
||
<div className={cn(
|
||
'w-24 shrink-0 text-right text-[11px]',
|
||
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
|
||
)}>
|
||
{third}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Block tiêu đề (A / B)
|
||
function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="border-b border-slate-200 bg-slate-100 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||
const qc = useQueryClient()
|
||
const bs = ev.budgetSummary
|
||
|
||
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
|
||
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
|
||
const drafterEditable = !readOnly && isEditablePhase(ev.phase)
|
||
|
||
const invalidate = () => {
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
}
|
||
|
||
// PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
|
||
const proMut = useMutation({
|
||
mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
|
||
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
|
||
onSuccess: () => { toast.success('Đã lưu ngân sách PRO'); invalidate() },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
|
||
const ccmMut = useMutation({
|
||
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) =>
|
||
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
|
||
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
|
||
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
|
||
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
|
||
const adjustMut = useMutation({
|
||
mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
|
||
api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
|
||
onSuccess: () => { toast.success('Đã lưu'); invalidate() },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
// proNote inline-edit state (Textarea — không dùng VndInlineEdit)
|
||
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
|
||
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
|
||
|
||
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
|
||
if (!bs) {
|
||
return (
|
||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
|
||
Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục để dùng ngân sách gói thầu.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== Số liệu Excel =====
|
||
const full = bs.fullAmount
|
||
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
|
||
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
|
||
const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
|
||
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
|
||
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
|
||
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
|
||
const row7 = full - row5 // Ngân sách còn lại
|
||
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
|
||
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
|
||
|
||
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 − row4)
|
||
const cmp56 = row5 - row6 // So với NS (row5 − row6)
|
||
const cmpFull = full - row9 // So sánh với Ngân sách full (full − row9)
|
||
|
||
// Cờ tô màu cảnh báo
|
||
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
|
||
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
|
||
|
||
return (
|
||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||
Tổng hợp ngân sách trình ký
|
||
</div>
|
||
|
||
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
|
||
Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full
|
||
(cmpFull<0). Số dư còn lại âm vẫn lưu + gửi duyệt được. */}
|
||
{(cmpPeriod < 0 || cmpFull < 0) && (
|
||
<div className="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
||
<span className="shrink-0 text-sm leading-none">⚠️</span>
|
||
<span>
|
||
<strong>Vượt ngân sách</strong> — giá trị đề xuất NCC đang cao hơn ngân sách của gói thầu.
|
||
Phiếu <strong>vẫn lưu & gửi duyệt được</strong>, vui lòng kiểm tra lại số liệu trước khi trình.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
|
||
<BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
|
||
|
||
{/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
|
||
<BudgetRow
|
||
tone="brand"
|
||
label={
|
||
<span className="inline-flex items-center gap-2">
|
||
Ngân sách (full gói thầu)
|
||
{bs.fullIsEstimate && (
|
||
<span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">ngân sách PRO</span>
|
||
)}
|
||
</span>
|
||
}
|
||
value={fmtVnd(full)}
|
||
/>
|
||
|
||
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
|
||
<BudgetRow
|
||
label="Ngân sách Ban hành lần đầu"
|
||
value={
|
||
bs.canEditCcm ? (
|
||
<VndInlineEdit
|
||
initial={bs.initialAmount}
|
||
saving={ccmMut.isPending}
|
||
label="Ngân sách ban hành lần đầu"
|
||
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })}
|
||
/>
|
||
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400">—</span>
|
||
}
|
||
/>
|
||
|
||
{/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
|
||
<BudgetRow
|
||
label="Ngân sách V0 / hiệu chỉnh tăng giảm"
|
||
value={
|
||
bs.canEditCcm ? (
|
||
<VndInlineEdit
|
||
initial={bs.adjustmentAmount}
|
||
allowNegative
|
||
saving={ccmMut.isPending}
|
||
label="Ngân sách hiệu chỉnh tăng giảm"
|
||
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })}
|
||
/>
|
||
) : bs.adjustmentAmount != null ? (
|
||
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
|
||
) : <span className="text-slate-400">—</span>
|
||
}
|
||
/>
|
||
|
||
{/* Dòng 4 — Dự trù PRO (PRO editable) */}
|
||
<BudgetRow
|
||
label="Ngân sách PRO"
|
||
value={
|
||
bs.canEditPro ? (
|
||
<VndInlineEdit
|
||
initial={bs.proEstimateAmount}
|
||
saving={proMut.isPending}
|
||
label="Ngân sách PRO"
|
||
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
|
||
/>
|
||
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400">—</span>
|
||
}
|
||
/>
|
||
|
||
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
|
||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
|
||
<div className="w-72 shrink-0">
|
||
{bs.canEditPro ? (
|
||
<div className="space-y-1">
|
||
<textarea
|
||
value={proNoteText}
|
||
onChange={e => setProNoteText(e.target.value)}
|
||
placeholder="Ghi chú dự trù…"
|
||
rows={2}
|
||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button
|
||
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
|
||
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
|
||
className="h-6 px-2 text-[11px]"
|
||
>
|
||
{proMut.isPending ? '…' : 'Lưu ghi chú'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||
{bs.proNote || <span className="text-slate-400">—</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== Block B — THỰC HIỆN ===== */}
|
||
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
|
||
|
||
{/* 1 — Ngân sách trình duyệt trước */}
|
||
<BudgetRow
|
||
label="1. Ngân sách trình duyệt trước"
|
||
value={bs.previousSubmittedCount === 0
|
||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
||
: fmtVnd(row1)}
|
||
/>
|
||
|
||
{/* 2 — Kỳ trước đã chọn thầu */}
|
||
<BudgetRow
|
||
label="2. Kỳ trước đã chọn thầu"
|
||
value={bs.previousSelectedCount === 0
|
||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
||
: fmtVnd(row2)}
|
||
/>
|
||
|
||
{/* 3 — Ngân sách - kỳ này (drafter editable) + % /full */}
|
||
<BudgetRow
|
||
label="3. Ngân sách - kỳ này"
|
||
value={
|
||
drafterEditable ? (
|
||
<VndInlineEdit
|
||
initial={ev.budgetPeriodAmount}
|
||
saving={adjustMut.isPending}
|
||
label="Ngân sách kỳ này"
|
||
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
|
||
/>
|
||
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400">—</span>
|
||
}
|
||
third={fmtPct(row3, full) ?? undefined}
|
||
/>
|
||
|
||
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */}
|
||
<BudgetRow
|
||
tone="blue-soft"
|
||
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
|
||
value={
|
||
<span className="font-sans text-slate-700">
|
||
{ev.selectedSupplierName ?? <span className="text-slate-400">— (chưa chọn)</span>}
|
||
</span>
|
||
}
|
||
/>
|
||
<BudgetRow
|
||
tone="blue-soft"
|
||
label="Giá trị kỳ này"
|
||
value={
|
||
proposalOver ? (
|
||
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
|
||
) : fmtVnd(row4)
|
||
}
|
||
/>
|
||
<BudgetRow
|
||
tone="blue-soft"
|
||
label="So sánh với ngân sách kỳ này"
|
||
sub="= 3 − 4"
|
||
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
|
||
third={fmtPct(cmpPeriod, row3) ?? undefined}
|
||
danger={cmpPeriod < 0}
|
||
/>
|
||
|
||
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */}
|
||
<BudgetRow
|
||
label="5. Lũy kế ngân sách đã sử dụng"
|
||
sub="= 1 + 3"
|
||
value={fmtVnd(row5)}
|
||
/>
|
||
|
||
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 − 6) */}
|
||
<BudgetRow
|
||
label="6. Lũy kế thực hiện"
|
||
sub="= 2 + 4"
|
||
value={fmtVnd(row6)}
|
||
/>
|
||
<BudgetRow
|
||
label="So với NS"
|
||
sub="= 5 − 6"
|
||
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
|
||
third={fmtPct(cmp56, row5) ?? undefined}
|
||
danger={cmp56 < 0}
|
||
/>
|
||
|
||
{/* 7 — Ngân sách còn lại (= full − 5) + % /full */}
|
||
<BudgetRow
|
||
label="7. Ngân sách còn lại"
|
||
sub="= Ngân sách full − 5"
|
||
value={fmtVnd(row7)}
|
||
third={fmtPct(row7, full) ?? undefined}
|
||
/>
|
||
|
||
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */}
|
||
<div className={cn(
|
||
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]',
|
||
remainingOver && 'bg-red-50',
|
||
)}>
|
||
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}>
|
||
8. Giá trị thực hiện dự kiến còn lại
|
||
<div className="text-[10px] text-slate-400">mặc định = 7 nếu chưa nhập</div>
|
||
</div>
|
||
<div className="w-48 shrink-0 text-right">
|
||
{drafterEditable ? (
|
||
<VndInlineEdit
|
||
initial={ev.expectedRemainingAmount}
|
||
allowNegative
|
||
saving={adjustMut.isPending}
|
||
label="Giá trị thực hiện dự kiến còn lại"
|
||
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
|
||
/>
|
||
) : (
|
||
<span className={cn('font-mono tabular-nums', remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
|
||
{fmtVnd(row8)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="w-24 shrink-0" />
|
||
</div>
|
||
|
||
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
|
||
<BudgetRow
|
||
tone="brand"
|
||
label="9. Giá trị tổng thực hiện dự kiến"
|
||
sub="= 4 + 8"
|
||
value={fmtVnd(row9)}
|
||
/>
|
||
<BudgetRow
|
||
tone="brand-soft"
|
||
label="So sánh với Ngân sách full"
|
||
sub="= Ngân sách full − 9"
|
||
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
|
||
third={fmtPct(cmpFull, full) ?? undefined}
|
||
danger={cmpFull < 0}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
||
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
|
||
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
|
||
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
|
||
const winnerSupplierRowId = ev.selectedSupplierId
|
||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
||
: null
|
||
const giaChaoThau = computeGiaChaoThau(ev)
|
||
|
||
// d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null
|
||
const banSoSanhAttachments = ev.attachments.filter(
|
||
a => a.purchaseEvaluationSupplierId === null,
|
||
)
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<NccSelectorRow ev={ev} readOnly={readOnly} />
|
||
{/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
|
||
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
|
||
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
|
||
<FormRow
|
||
label="c. Giá chào thầu"
|
||
value={
|
||
winnerSupplierRowId === null ? (
|
||
<span className="text-slate-400">— (chọn NCC/TP ở (a) trước)</span>
|
||
) : giaChaoThau === 0 ? (
|
||
<span className="text-slate-400">— (chưa nhập báo giá ở Section 4)</span>
|
||
) : (
|
||
<span className="font-semibold text-slate-900">{giaChaoThau!.toLocaleString('vi-VN')} đ</span>
|
||
)
|
||
}
|
||
/>
|
||
<div>
|
||
<div className="flex gap-3">
|
||
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span>
|
||
<div className="min-w-0 flex-1">
|
||
<GeneralAttachmentsSection
|
||
evaluationId={ev.id}
|
||
attachments={banSoSanhAttachments}
|
||
readOnly={readOnly}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* e. Link hồ sơ (anh Kiệt FDC) — 1 hyperlink tới thư mục hồ sơ trên NAS công ty.
|
||
Read-only: render thẻ <a> bấm-mở (target=_blank). Editable: Input dán URL +
|
||
nút Lưu (PUT /purchase-evaluations/:id echo field bắt buộc + hoSoLink). */}
|
||
<HoSoLinkRow ev={ev} readOnly={readOnly} />
|
||
|
||
{ev.paymentTerms && (
|
||
<FormRow label="Điều khoản thanh toán" value={<span className="whitespace-pre-wrap">{ev.paymentTerms}</span>} />
|
||
)}
|
||
{ev.contractId && (
|
||
<FormRow
|
||
label="HĐ kế thừa"
|
||
value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline">✓ Xem HĐ</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 HĐ 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 HĐ từ phiếu
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{createOpen && <CreateContractDialog evaluation={ev} onClose={() => setCreateOpen(false)} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// e. Link hồ sơ — 1 cột HoSoLink (string? nullable) trỏ thư mục hồ sơ NAS.
|
||
// Read-only: thẻ <a> bấm-mở. Editable (phiếu DangSoanThao/TraLai + !readOnly):
|
||
// Input dán URL + nút Lưu. Save = PUT /purchase-evaluations/:id echo field bắt
|
||
// buộc (tenGoiThau + 2 ô ngân sách) như InfoTab.save để không xóa nhầm data.
|
||
function HoSoLinkRow({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||
const qc = useQueryClient()
|
||
const [hoSoLink, setHoSoLink] = useState(ev.hoSoLink ?? '')
|
||
useEffect(() => { setHoSoLink(ev.hoSoLink ?? '') }, [ev.id, ev.hoSoLink])
|
||
|
||
const dirty = hoSoLink !== (ev.hoSoLink ?? '')
|
||
const save = useMutation({
|
||
mutationFn: async () => {
|
||
await api.put(`/purchase-evaluations/${ev.id}`, {
|
||
id: ev.id,
|
||
tenGoiThau: ev.tenGoiThau,
|
||
diaDiem: ev.diaDiem,
|
||
moTa: ev.moTa,
|
||
paymentTerms: ev.paymentTerms,
|
||
budgetPeriodAmount: ev.budgetPeriodAmount,
|
||
expectedRemainingAmount: ev.expectedRemainingAmount,
|
||
hoSoLink: hoSoLink || null,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('Đã lưu link hồ sơ')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
return (
|
||
<div className="flex gap-3">
|
||
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">e. Link hồ sơ</span>
|
||
<div className="min-w-0 flex-1">
|
||
{canEdit ? (
|
||
<div className="flex max-w-2xl items-center gap-2">
|
||
<Input
|
||
type="url"
|
||
value={hoSoLink}
|
||
onChange={e => setHoSoLink(e.target.value)}
|
||
placeholder="Dán link thư mục hồ sơ trên NAS..."
|
||
className="text-sm"
|
||
/>
|
||
<Button
|
||
onClick={() => save.mutate()}
|
||
disabled={!dirty || save.isPending}
|
||
className="h-9 shrink-0 px-3 text-xs"
|
||
>
|
||
{save.isPending ? 'Đang lưu…' : 'Lưu'}
|
||
</Button>
|
||
</div>
|
||
) : ev.hoSoLink ? (
|
||
/^https?:\/\//i.test(ev.hoSoLink.trim()) ? (
|
||
// Link web (http/https — vd SharePoint) → bấm mở thẳng tab mới.
|
||
<a
|
||
href={ev.hoSoLink}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="break-all text-sm text-brand-600 hover:underline"
|
||
>
|
||
{ev.hoSoLink}
|
||
</a>
|
||
) : (
|
||
// Đường dẫn ổ cứng/ổ mạng (O:\…, \\server) → trình duyệt CHẶN mở file://
|
||
// từ https nên bấm sẽ hụt → hiện chữ + nút Copy để dán vào File Explorer.
|
||
<PathWithCopy path={ev.hoSoLink} />
|
||
)
|
||
) : (
|
||
<span className="text-sm text-slate-400">—</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// e.bis — Đường dẫn ổ cứng/ổ mạng (không phải http) → chữ + nút Copy. Trình duyệt
|
||
// CHẶN mở file:// từ trang https nên KHÔNG render <a> bấm-mở (bấm sẽ hụt); thay
|
||
// bằng Copy → người dùng dán vào File Explorer (máy có map ổ mạng là mở ngay).
|
||
function PathWithCopy({ path }: { path: string }) {
|
||
const [copied, setCopied] = useState(false)
|
||
const copy = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(path)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 1500)
|
||
} catch {
|
||
toast.error('Không copy được — vui lòng bôi đen đường dẫn rồi Ctrl+C.')
|
||
}
|
||
}
|
||
return (
|
||
<div className="flex max-w-2xl items-start gap-2">
|
||
<code className="min-w-0 flex-1 break-all rounded bg-slate-50 px-2 py-1 text-[13px] text-brand-800 ring-1 ring-slate-200">
|
||
{path}
|
||
</code>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={copy}
|
||
className="h-8 shrink-0 px-2.5 text-xs"
|
||
title="Copy đường dẫn rồi dán vào File Explorer (This PC)"
|
||
>
|
||
{copied ? '✓ Đã copy' : 'Copy'}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Form row: label cố định 176px (w-44) bên trái + value bên phải (giống spec).
|
||
function FormRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||
<dt className="w-44 shrink-0 text-[12px] text-slate-500">{label}</dt>
|
||
<dd className="min-w-0 flex-1 text-slate-800">{value}</dd>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
|
||
const navigate = useNavigate()
|
||
const [form, setForm] = useState({
|
||
contractType: 1,
|
||
tenHopDong: evaluation.tenGoiThau,
|
||
bypassProcurementAndCCM: false,
|
||
})
|
||
const mut = useMutation({
|
||
mutationFn: async () =>
|
||
api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
|
||
onSuccess: res => {
|
||
toast.success('Đã tạo HĐ từ phiếu.')
|
||
navigate(`/contracts/${res.data.contractId}`)
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
const typeOptions = [
|
||
[1, 'HĐ Thầu phụ'],
|
||
[2, 'HĐ Giao khoán'],
|
||
[3, 'HĐ Nhà cung cấp'],
|
||
[4, 'HĐ Dịch vụ'],
|
||
[5, 'HĐ Mua bán'],
|
||
[6, 'HĐ Nguyên tắc NCC'],
|
||
[7, 'HĐ Nguyên tắc DV'],
|
||
] as const
|
||
return (
|
||
<Dialog
|
||
open
|
||
onClose={onClose}
|
||
title="Tạo HĐ từ phiếu Duyệt NCC"
|
||
footer={<>
|
||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Tạo</Button>
|
||
</>}
|
||
>
|
||
<div className="space-y-3">
|
||
<p className="text-sm text-slate-500">
|
||
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
|
||
</p>
|
||
<div>
|
||
<Label>Loại HĐ</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 HĐ</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 HĐ với Chủ đầu tư)
|
||
</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 có 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]">Mã *</Label>
|
||
<Input value={newSup.code} onChange={e => setNewSup({ ...newSup, code: e.target.value.toUpperCase() })} placeholder="VD: NTP-THANGLONG" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px]">Loại *</Label>
|
||
<Select value={newSup.type} onChange={e => setNewSup({ ...newSup, type: Number(e.target.value) as SupplierType })}>
|
||
{Object.values(SupplierType).map(t => (
|
||
<option key={t} value={t}>{SupplierTypeLabel[t]}</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<Label className="text-[11px]">Tên *</Label>
|
||
<Input value={newSup.name} onChange={e => setNewSup({ ...newSup, name: e.target.value })} placeholder="CÔNG TY ..." />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px]">SĐT</Label>
|
||
<Input value={newSup.phone} onChange={e => setNewSup({ ...newSup, phone: e.target.value })} placeholder="0987654321" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px]">Email</Label>
|
||
<Input value={newSup.email} onChange={e => setNewSup({ ...newSup, email: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end">
|
||
<Button
|
||
onClick={() => createSup.mutate()}
|
||
disabled={!newSup.code.trim() || !newSup.name.trim() || createSup.isPending}
|
||
className="h-7 px-3 text-xs"
|
||
>
|
||
{createSup.isPending ? 'Đang tạo…' : 'Tạo & chọn'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
|
||
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" /></div>
|
||
<div><Label>Người liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
|
||
<div>
|
||
<Label>Điện thoại</Label>
|
||
<Input
|
||
type="tel"
|
||
inputMode="tel"
|
||
value={form.contactPhone}
|
||
onChange={e => setForm({ ...form, contactPhone: e.target.value })}
|
||
placeholder="0987654321"
|
||
className={phoneError ? 'border-red-300' : undefined}
|
||
/>
|
||
{phoneError && <p className="mt-0.5 text-[10px] text-red-600">{phoneError}</p>}
|
||
</div>
|
||
<div className="col-span-2">
|
||
<Label>Email</Label>
|
||
<Input
|
||
type="email"
|
||
value={form.contactEmail}
|
||
onChange={e => setForm({ ...form, contactEmail: e.target.value })}
|
||
placeholder="name@example.com"
|
||
className={emailError ? 'border-red-300' : undefined}
|
||
/>
|
||
{emailError && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>}
|
||
</div>
|
||
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div>
|
||
{showQuote && (
|
||
<div className="col-span-2 rounded-lg border border-brand-200 bg-brand-50/40 p-3">
|
||
<Label className="text-brand-700">Số tiền báo giá cho hạng mục</Label>
|
||
<div className="relative mt-1 max-w-xs">
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={formatVndInput(form.thanhTien)}
|
||
onChange={e => setForm({ ...form, thanhTien: parseVnd(e.target.value) })}
|
||
placeholder="0"
|
||
className="pr-10 font-mono text-right"
|
||
/>
|
||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||
</div>
|
||
<p className="mt-1 text-[11px] text-slate-500">
|
||
Để trống / 0 → chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeSupplier; onClose: () => void }) {
|
||
const qc = useQueryClient()
|
||
const [form, setForm] = useState({
|
||
supplierId: row.supplierId,
|
||
displayName: row.displayName ?? '',
|
||
contactName: row.contactName ?? '',
|
||
contactEmail: row.contactEmail ?? '',
|
||
contactPhone: row.contactPhone ?? '',
|
||
paymentTermText: row.paymentTermText ?? '',
|
||
note: row.note ?? '',
|
||
})
|
||
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
|
||
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
|
||
const hasError = !!(phoneError || emailError)
|
||
|
||
const mut = useMutation({
|
||
mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form),
|
||
onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
return (
|
||
<Dialog
|
||
open
|
||
onClose={onClose}
|
||
title={`Sửa NCC — ${row.supplierName}`}
|
||
footer={<>
|
||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||
<Button onClick={() => mut.mutate()} disabled={hasError || mut.isPending}>Lưu</Button>
|
||
</>}
|
||
>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} /></div>
|
||
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} /></div>
|
||
<div><Label>Liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
|
||
<div>
|
||
<Label>Điện thoại</Label>
|
||
<Input
|
||
type="tel"
|
||
inputMode="tel"
|
||
value={form.contactPhone}
|
||
onChange={e => setForm({ ...form, contactPhone: e.target.value })}
|
||
placeholder="0987654321"
|
||
className={phoneError ? 'border-red-300' : undefined}
|
||
/>
|
||
{phoneError && <p className="mt-0.5 text-[10px] text-red-600">{phoneError}</p>}
|
||
</div>
|
||
<div className="col-span-2">
|
||
<Label>Email</Label>
|
||
<Input
|
||
type="email"
|
||
value={form.contactEmail}
|
||
onChange={e => setForm({ ...form, contactEmail: e.target.value })}
|
||
placeholder="name@example.com"
|
||
className={emailError ? 'border-red-300' : undefined}
|
||
/>
|
||
{emailError && <p className="mt-0.5 text-[10px] text-red-600">{emailError}</p>}
|
||
</div>
|
||
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
|
||
</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
// ===== Tab: Hạng mục + Báo giá (Session 20 — nested cards layout) =====
|
||
// Mỗi hạng mục = 1 card với expand panel chứa NCC tham gia inline grid.
|
||
// Replace bảng matrix grid (hạng mục × NCC) cũ — user demo 1 hạng mục.
|
||
function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||
const [addOpen, setAddOpen] = useState(false)
|
||
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
|
||
|
||
// S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
|
||
// không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
|
||
// TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<p className="text-xs text-slate-500">
|
||
{ev.details.length} hạng mục · {ev.suppliers.length} NCC tham gia
|
||
{!readOnly && ' — mở hạng mục để thêm NCC + nhập báo giá.'}
|
||
</p>
|
||
{/* S59 vòng 6 (anh chốt "bỏ luôn cái nút thêm hạng mục"): 1 phiếu = 1 hạng mục
|
||
chọn từ header (S57bis/S58) — hạng mục đầu auto-seed khi tạo phiếu, nút thêm
|
||
hạng mục thứ 2+ sai mô hình. AddItemDialog giữ (dead) để flip lại dễ nếu cần. */}
|
||
</div>
|
||
|
||
{ev.details.length === 0 ? (
|
||
<p className="text-sm text-slate-500">Chưa có hạng mục.</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{ev.details.map(d => (
|
||
<HangMucCard
|
||
key={d.id}
|
||
detail={d}
|
||
ev={ev}
|
||
readOnly={readOnly}
|
||
onEditDetail={() => setEditDetail(d)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
|
||
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
|
||
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
|
||
function HangMucCard({
|
||
detail, ev, readOnly, onEditDetail,
|
||
}: {
|
||
detail: PeDetailRow
|
||
ev: PeDetailBundle
|
||
readOnly: boolean
|
||
onEditDetail: () => void
|
||
}) {
|
||
const qc = useQueryClient()
|
||
const [expanded, setExpanded] = useState(true)
|
||
const [addNccOpen, setAddNccOpen] = useState(false)
|
||
const [editNccRow, setEditNccRow] = useState<PeSupplier | null>(null)
|
||
const [quoteEdit, setQuoteEdit] = useState<{ supplier: PeSupplier; existing: PeQuote | null } | null>(null)
|
||
|
||
const removeDetail = useMutation({
|
||
mutationFn: async () => api.delete(`/purchase-evaluations/${ev.id}/details/${detail.id}`),
|
||
onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
const removeNcc = useMutation({
|
||
mutationFn: async (rowId: string) => api.delete(`/purchase-evaluations/${ev.id}/suppliers/${rowId}`),
|
||
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
const setWinner = useMutation({
|
||
mutationFn: async (supplierId: string) =>
|
||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
return (
|
||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||
{/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap +
|
||
padding responsive cho laptop nhỏ. Stat (Số tiền NS) wrap xuống dòng
|
||
riêng khi container hẹp. */}
|
||
<div className="flex flex-wrap items-start gap-2 border-b border-slate-100 p-2 sm:gap-3 sm:p-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded(!expanded)}
|
||
className="mt-0.5 text-slate-400 hover:text-slate-700"
|
||
title={expanded ? 'Đóng' : 'Mở'}
|
||
>
|
||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||
</button>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium text-slate-900">
|
||
<span className="font-mono text-[12px] text-slate-500 mr-2">{detail.groupCode}</span>
|
||
{detail.noiDung}
|
||
</div>
|
||
<div className="mt-0.5 text-[11px] text-slate-500">
|
||
{detail.groupName}{detail.donViTinh ? ` · ĐVT: ${detail.donViTinh}` : ''}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-shrink-0 items-start gap-4 text-right text-xs">
|
||
<div>
|
||
<div className="text-[10px] uppercase text-slate-400">Số tiền ngân sách</div>
|
||
<div className="font-mono text-base font-semibold text-slate-900">
|
||
{fmtMoney(detail.thanhTienNganSach)}
|
||
<span className="ml-1 text-xs font-normal text-slate-500">đ</span>
|
||
</div>
|
||
</div>
|
||
{/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
|
||
Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
|
||
trình ký" cấp phiếu (PeBudgetSummaryTable). */}
|
||
</div>
|
||
{!readOnly && (
|
||
<div className="flex flex-shrink-0 gap-1">
|
||
<button
|
||
onClick={onEditDetail}
|
||
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
|
||
title="Sửa hạng mục"
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => { if (confirm('Xóa hạng mục? Báo giá NCC đã nhập cũng sẽ mất.')) removeDetail.mutate() }}
|
||
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
||
title="Xóa hạng mục"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Expand panel — NCC tham gia + báo giá inline */}
|
||
{expanded && (
|
||
<div className="p-2 sm:p-3">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<div className="text-[11px] uppercase tracking-wide text-slate-500">
|
||
NCC tham gia ({ev.suppliers.length})
|
||
</div>
|
||
{!readOnly && (
|
||
<Button variant="ghost" onClick={() => setAddNccOpen(true)} className="gap-1.5 text-xs">
|
||
<Plus className="h-3 w-3" /> Thêm NCC
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{ev.suppliers.length === 0 ? (
|
||
<p className="text-xs text-slate-500">
|
||
{readOnly ? 'Chưa có NCC tham gia.' : 'Chưa có NCC. Thêm NCC để nhập báo giá.'}
|
||
</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
{/* S59 UAT vòng 3: "thêm file giao diện bị thay đổi không cân xứng" — auto-layout
|
||
để cell File (chip tên dài) phình + bóp dọc cột NCC. Fix: table-fixed + width
|
||
từng cột (chip file/email có truncate sẵn — kích hoạt khi cell khóa width);
|
||
min-w để panel hẹp thì scroll ngang (wrapper overflow-x-auto) thay vì bóp nát. */}
|
||
<table className="w-full min-w-[860px] table-fixed border border-slate-200 text-xs">
|
||
<thead className="bg-slate-50 text-slate-600">
|
||
<tr>
|
||
<th className="w-[24%] border-r border-slate-200 px-2 py-1.5 text-left">NCC</th>
|
||
<th className="w-[9%] border-r border-slate-200 px-2 py-1.5 text-left">SĐT</th>
|
||
<th className="w-[14%] border-r border-slate-200 px-2 py-1.5 text-left">Email</th>
|
||
<th className="w-[14%] border-r border-slate-200 px-2 py-1.5 text-left">Điều khoản TT</th>
|
||
<th className="w-[22%] border-r border-slate-200 px-2 py-1.5 text-left">File báo giá</th>
|
||
<th className="w-[12%] border-r border-slate-200 px-2 py-1.5 text-right">Số tiền</th>
|
||
{!readOnly && <th className="w-10 px-2 py-1.5"></th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{ev.suppliers.map((s, idx) => {
|
||
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
|
||
const isWinner = ev.selectedSupplierId === s.supplierId
|
||
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
|
||
const canDelete = !isWinner && !hasQuotes
|
||
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
|
||
const palette = NCC_PALETTES[idx % NCC_PALETTES.length]
|
||
return (
|
||
<tr
|
||
key={s.id}
|
||
className={cn(
|
||
'align-top border-l-4 transition',
|
||
isWinner
|
||
? 'border-l-emerald-500 bg-emerald-100/70 font-semibold shadow-sm ring-1 ring-inset ring-emerald-300 hover:bg-emerald-200/70'
|
||
: cn(palette, 'hover:bg-white/80 hover:shadow-sm'),
|
||
)}
|
||
>
|
||
<td className="border-r border-slate-200 px-2 py-1.5">
|
||
<div className="font-medium text-slate-900">
|
||
{isWinner && <span className="mr-1 text-base font-bold text-emerald-700">✓</span>}
|
||
<span className={cn(isWinner && 'text-emerald-900')}>{s.supplierName}</span>
|
||
</div>
|
||
{s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>}
|
||
{s.note && <div className="text-[10px] text-amber-700">{s.note}</div>}
|
||
</td>
|
||
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600 font-mono">
|
||
{s.contactPhone || <span className="text-slate-300">—</span>}
|
||
</td>
|
||
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600">
|
||
{s.contactEmail
|
||
? <span className="block truncate" title={s.contactEmail}>{s.contactEmail}</span>
|
||
: <span className="text-slate-300">—</span>}
|
||
</td>
|
||
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px]">
|
||
{s.paymentTermText ?? <span className="text-slate-300">—</span>}
|
||
</td>
|
||
<td className="border-r border-slate-200 px-2 py-1.5">
|
||
<SupplierAttachmentsCell
|
||
evaluationId={ev.id}
|
||
supplierRowId={s.id}
|
||
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
|
||
readOnly={readOnly}
|
||
/>
|
||
</td>
|
||
<td className="border-r border-slate-200 px-2 py-1.5">
|
||
{!readOnly ? (
|
||
<button
|
||
type="button"
|
||
onClick={openQuote}
|
||
className={cn(
|
||
'w-full rounded border px-2 py-1 text-right font-mono text-[11px] transition',
|
||
q
|
||
? isWinner
|
||
? 'border-emerald-300 bg-emerald-50 font-semibold text-emerald-700 hover:bg-emerald-100'
|
||
: 'border-slate-300 bg-white font-semibold hover:border-brand-300 hover:bg-brand-50'
|
||
: 'border-dashed border-slate-300 bg-slate-50 text-[10px] text-slate-400 hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600',
|
||
)}
|
||
title="Click để nhập / sửa số tiền"
|
||
>
|
||
{q ? `${fmtMoney(q.thanhTien)} đ` : '+ Nhập số tiền'}
|
||
</button>
|
||
) : (
|
||
<div className={cn('text-right font-mono font-semibold', isWinner && 'text-emerald-700')}>
|
||
{q ? `${fmtMoney(q.thanhTien)} đ` : <span className="text-slate-300">—</span>}
|
||
</div>
|
||
)}
|
||
</td>
|
||
{!readOnly && (
|
||
<td className="px-2 py-1.5">
|
||
<div className="flex justify-end gap-0.5">
|
||
<button
|
||
onClick={() => setWinner.mutate(s.supplierId)}
|
||
className={cn(
|
||
'rounded px-1 py-0.5',
|
||
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
|
||
)}
|
||
title={isWinner ? 'Đơn vị NCC/TP đã được chọn' : 'Chọn đơn vị NCC/TP'}
|
||
>
|
||
<Check className="h-3 w-3" />
|
||
</button>
|
||
{!isWinner && (
|
||
<button
|
||
onClick={() => setEditNccRow(s)}
|
||
className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100"
|
||
title="Sửa thông tin NCC"
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
{canDelete ? (
|
||
<button
|
||
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) removeNcc.mutate(s.id) }}
|
||
className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50"
|
||
title="Xóa NCC"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
) : !isWinner && hasQuotes && (
|
||
<span
|
||
className="rounded px-1 py-0.5 text-slate-300 cursor-not-allowed"
|
||
title="NCC đã có báo giá — xóa báo giá trước rồi mới xóa NCC"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{addNccOpen && <AddSupplierDialog evaluationId={ev.id} detailId={detail.id} onClose={() => setAddNccOpen(false)} />}
|
||
{editNccRow && <EditSupplierDialog evaluationId={ev.id} row={editNccRow} onClose={() => setEditNccRow(null)} />}
|
||
{quoteEdit && (
|
||
<QuoteDialog
|
||
evaluationId={ev.id}
|
||
detailId={detail.id}
|
||
supplierRowId={quoteEdit.supplier.id}
|
||
supplierName={quoteEdit.supplier.supplierName}
|
||
itemName={detail.noiDung}
|
||
existing={quoteEdit.existing}
|
||
onClose={() => setQuoteEdit(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
|
||
const qc = useQueryClient()
|
||
// Session 20 turn 5: user yêu cầu rút gọn — chỉ Tên hạng mục + Số tiền
|
||
// ngân sách (VND format) + Ghi chú. Các field schema khác (groupCode/
|
||
// groupName/itemCode/donViTinh/khoiLuongs/donGia) giữ default cho BE
|
||
// schema backward compat — KHÔNG expose UI cho user.
|
||
const [form, setForm] = useState({
|
||
groupCode: row?.groupCode ?? '01',
|
||
groupName: row?.groupName ?? 'Hạng mục chính',
|
||
itemCode: row?.itemCode ?? '',
|
||
noiDung: row?.noiDung ?? '',
|
||
donViTinh: row?.donViTinh ?? 'gói',
|
||
khoiLuongNganSach: row?.khoiLuongNganSach ?? 1,
|
||
khoiLuongThiCong: row?.khoiLuongThiCong ?? 1,
|
||
donGiaNganSach: row?.donGiaNganSach ?? 0,
|
||
thanhTienNganSach: row?.thanhTienNganSach ?? 0,
|
||
ghiChu: row?.ghiChu ?? '',
|
||
})
|
||
const mut = useMutation({
|
||
mutationFn: async () =>
|
||
row
|
||
? api.put(`/purchase-evaluations/${evaluationId}/details/${row.id}`, form)
|
||
: api.post(`/purchase-evaluations/${evaluationId}/details`, form),
|
||
onSuccess: () => { toast.success(row ? 'Đã sửa.' : 'Đã thêm.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
// Sync ngân sách: user nhập "Số tiền ngân sách" → set cả donGia + thanhTien
|
||
// (KL = 1 ngầm). BE giữ schema 3 field.
|
||
const setBudgetAmount = (n: number) => {
|
||
setForm({ ...form, donGiaNganSach: n, thanhTienNganSach: n })
|
||
}
|
||
|
||
return (
|
||
<Dialog
|
||
open
|
||
onClose={onClose}
|
||
title={(row ? 'Sửa' : 'Thêm') + ' hạng mục'}
|
||
footer={<>
|
||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>{row ? 'Lưu' : 'Thêm'}</Button>
|
||
</>}
|
||
>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>Tên hạng mục</Label>
|
||
<Input
|
||
value={form.noiDung}
|
||
onChange={e => setForm({ ...form, noiDung: e.target.value })}
|
||
placeholder="vd Cung cấp bê tông M250"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Số tiền ngân sách</Label>
|
||
<div className="relative">
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={formatVndInput(form.thanhTienNganSach)}
|
||
onChange={e => setBudgetAmount(parseVnd(e.target.value))}
|
||
placeholder="0"
|
||
className="pr-12 font-mono text-right"
|
||
/>
|
||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||
</div>
|
||
<p className="mt-1 text-[11px] text-slate-500">VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
|
||
</div>
|
||
<div>
|
||
<Label>Ghi chú</Label>
|
||
<Input value={form.ghiChu} onChange={e => setForm({ ...form, ghiChu: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function QuoteDialog({
|
||
evaluationId, detailId, supplierRowId, supplierName, itemName, existing, onClose,
|
||
}: {
|
||
evaluationId: string
|
||
detailId: string
|
||
supplierRowId: string
|
||
supplierName: string
|
||
itemName: string
|
||
existing: PeQuote | null
|
||
onClose: () => void
|
||
}) {
|
||
const qc = useQueryClient()
|
||
// Session 20 turn 3: user yêu cầu "tạm thời chỉ cần nhập số tiền, không
|
||
// cần 3 cột có VAT / không VAT / tổng". UI chỉ 1 input thanhTien; bgVat /
|
||
// chuaVat / note vẫn gửi BE giữ schema (default 0 / empty cho row mới,
|
||
// giữ giá trị cũ nếu existing).
|
||
const [form, setForm] = useState({
|
||
thanhTien: existing?.thanhTien ?? 0,
|
||
})
|
||
|
||
const mut = useMutation({
|
||
mutationFn: async () =>
|
||
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
|
||
purchaseEvaluationDetailId: detailId,
|
||
purchaseEvaluationSupplierId: supplierRowId,
|
||
bgVat: existing?.bgVat ?? 0,
|
||
chuaVat: existing?.chuaVat ?? 0,
|
||
thanhTien: form.thanhTien,
|
||
note: existing?.note ?? '',
|
||
isSelected: existing?.isSelected ?? false,
|
||
}),
|
||
onSuccess: () => { toast.success('Đã lưu số tiền.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
const del = useMutation({
|
||
mutationFn: async () =>
|
||
existing ? api.delete(`/purchase-evaluations/${evaluationId}/quotes/${existing.id}`) : Promise.resolve(),
|
||
onSuccess: () => { toast.success('Đã xóa.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
const isSaving = mut.isPending || del.isPending
|
||
|
||
return (
|
||
<Dialog
|
||
open
|
||
onClose={onClose}
|
||
title={`Báo giá — ${supplierName}`}
|
||
footer={<>
|
||
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={isSaving}>{del.isPending ? 'Đang xóa…' : 'Xóa'}</Button>}
|
||
<Button variant="ghost" onClick={onClose} disabled={isSaving}>Hủy</Button>
|
||
<Button onClick={() => mut.mutate()} disabled={isSaving}>{mut.isPending ? 'Đang lưu…' : 'Lưu'}</Button>
|
||
</>}
|
||
>
|
||
<div className="relative space-y-3">
|
||
{isSaving && (
|
||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-white/70 backdrop-blur-sm">
|
||
<div className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 shadow-md">
|
||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||
<span className="text-sm font-medium text-slate-700">
|
||
{mut.isPending ? 'Đang lưu…' : 'Đang xóa…'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong></p>
|
||
<div>
|
||
<Label>Số tiền</Label>
|
||
<div className="relative">
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={formatVndInput(form.thanhTien)}
|
||
onChange={e => setForm({ thanhTien: parseVnd(e.target.value) })}
|
||
placeholder="0"
|
||
autoFocus
|
||
className="pr-12 font-mono text-right"
|
||
/>
|
||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||
</div>
|
||
<p className="mt-1 text-[11px] text-slate-500">VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
// ===== Tab: Duyệt =====
|
||
// Plan AC S25 Bug 3 — Decision badge phân biệt Approve / Trả lại / Từ chối.
|
||
// Plan AD S25 — Drop fromPhase→toPhase badges (gây nhầm khi cùng ChoDuyet);
|
||
// thay bằng next-target hint parse từ comment để rõ "gửi duyệt cho ai / trả về đâu".
|
||
const PE_DECISION_REJECT = 2
|
||
function decisionBadge(decision: number, toPhase: number): { label: string; cls: string } {
|
||
if (decision === PE_DECISION_REJECT) {
|
||
// Reject phân biệt: TuChoi(99) = "Từ chối" / TraLai(98) hoặc ChoDuyet(10) = "Trả lại"
|
||
if (toPhase === 99) return { label: 'Từ chối', cls: 'bg-rose-100 text-rose-700 border border-rose-200' }
|
||
return { label: 'Trả lại', cls: 'bg-amber-100 text-amber-700 border border-amber-200' }
|
||
}
|
||
return { label: 'Duyệt', cls: 'bg-emerald-100 text-emerald-700 border border-emerald-200' }
|
||
}
|
||
|
||
// Plan AD S25 — Parse comment để show next-target hint rõ ràng. BE comment
|
||
// format chuẩn từ Service:
|
||
// Approve advance Cấp: "Hoàn tất Cấp X, sang Cấp Y cùng Bước Z"
|
||
// Approve advance Bước: "Hoàn tất Bước X/Y, sang Bước Z (Cấp 1)"
|
||
// Approve skipToFinal: "[Duyệt vượt cấp tới Cấp cuối] ..." (Plan AC)
|
||
// Approve terminal: toPhase=DaDuyet(20)
|
||
// Reject OneLevel: "Trả về Cấp X (cùng Bước Y)" hoặc "không lùi được"
|
||
// Reject OneStep: "Trả về Bước X Cấp Y" hoặc "không lùi được"
|
||
// Reject Assignee: "Trả về Người chỉ định — Bước X (...) Cấp Y"
|
||
// Reject Drafter: "Trả về Người soạn thảo"
|
||
// Reject TuChoi: toPhase=TuChoi(99)
|
||
function extractNextTargetHint(decision: number, toPhase: number, comment: string | null): string {
|
||
if (decision === PE_DECISION_REJECT) {
|
||
if (toPhase === 99) return '→ Từ chối hoàn toàn'
|
||
const c = comment ?? ''
|
||
if (c.includes('không lùi được')) return '→ Không lùi được'
|
||
if (c.includes('Người chỉ định')) {
|
||
const m = c.match(/Bước\s*(\d+).*?Cấp\s*(\d+)/)
|
||
return m ? `→ Trả về Người chỉ định (Bước ${m[1]} Cấp ${m[2]})` : '→ Trả về Người chỉ định'
|
||
}
|
||
if (c.includes('Người soạn thảo') || c.includes('Drafter')) return '→ Trả về Người soạn thảo'
|
||
if (c.includes('Trả về 1 Cấp') || c.includes('Trả về Cấp')) {
|
||
const m = c.match(/Cấp\s*(\d+)/)
|
||
return m ? `→ Lùi về Cấp ${m[1]}` : '→ Lùi 1 Cấp'
|
||
}
|
||
if (c.includes('Trả về 1 Bước') || c.includes('Trả về Bước')) {
|
||
const m = c.match(/Bước\s*(\d+)/)
|
||
return m ? `→ Lùi về Bước ${m[1]}` : '→ Lùi 1 Bước'
|
||
}
|
||
return ''
|
||
}
|
||
// Approve
|
||
if (toPhase === 20) return '→ Đã duyệt hoàn tất'
|
||
const c = comment ?? ''
|
||
if (c.includes('Duyệt vượt cấp') || c.includes('Approver skip thẳng tới')) {
|
||
return '→ Vượt cấp tới Cấp cuối'
|
||
}
|
||
const levelMatch = c.match(/sang Cấp\s*(\d+)/)
|
||
if (levelMatch) return `→ Cấp ${levelMatch[1]}`
|
||
const stepMatch = c.match(/sang Bước\s*(\d+)/)
|
||
if (stepMatch) return `→ Bước ${stepMatch[1]} (Cấp 1)`
|
||
return ''
|
||
}
|
||
|
||
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
|
||
// Plan AC2 S25 — FE merge view: fetch changelogs + reconstruct synthetic
|
||
// Reject rows từ pre-Plan AC historical data (PE cũ deploy trước 2026-05-19
|
||
// KHÔNG có Approval row cho Reject vì BE cũ chỉ log Changelog). Merge approvals
|
||
// + synthetic + dedupe timestamp 5s bucket cùng approverUserId.
|
||
const changelogs = useQuery({
|
||
queryKey: ['pe-changelog', ev.id],
|
||
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
|
||
})
|
||
|
||
// Plan AF S25 — userMap fallback cho historical entries pre-Plan AE
|
||
// (userName="" empty/null). Cover real approvals + synthetic reject rows.
|
||
const userMap = useMemo(() => {
|
||
const m = new Map<string, string>()
|
||
if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName)
|
||
ev.approvals.forEach(a => {
|
||
if (a.approverUserId && a.approverName) m.set(a.approverUserId, a.approverName)
|
||
})
|
||
ev.approvalFlow?.steps?.forEach(s =>
|
||
s.levels?.forEach(l =>
|
||
l.approvers?.forEach(ap => {
|
||
if (ap.userId && ap.fullName) m.set(ap.userId, ap.fullName)
|
||
}),
|
||
),
|
||
)
|
||
ev.levelOpinions?.forEach(o => {
|
||
if (o.signedByUserId && o.signedByFullName) m.set(o.signedByUserId, o.signedByFullName)
|
||
})
|
||
ev.departmentOpinions?.forEach(o => {
|
||
if (o.userId && o.userName) m.set(o.userId, o.userName)
|
||
})
|
||
return m
|
||
}, [ev])
|
||
|
||
const resolveActorName = (a: PeApproval): string => {
|
||
if (a.approverName && a.approverName.trim() !== '') return a.approverName
|
||
if (a.approverUserId) {
|
||
const name = userMap.get(a.approverUserId)
|
||
if (name) return name
|
||
}
|
||
return 'Hệ thống'
|
||
}
|
||
|
||
const merged = useMemo(() => {
|
||
const phaseEnumMap: Record<string, number> = {
|
||
DangSoanThao: 1, ChoDuyet: 10, DaDuyet: 20, TraLai: 98, TuChoi: 99,
|
||
}
|
||
const PE_ENTITY_WORKFLOW = 5
|
||
const syntheticRejects: PeApproval[] = (changelogs.data ?? [])
|
||
.filter(c => {
|
||
if (c.entityType !== PE_ENTITY_WORKFLOW) return false
|
||
if (c.summary?.includes('→ TraLai') || c.summary?.includes('→ TuChoi')) return true
|
||
// 3 mode (OneLevel/OneStep/Assignee) giữ ChoDuyet → distinguish qua ContextNote keywords
|
||
const note = c.contextNote ?? ''
|
||
return note.includes('Trả về') || note.includes('không lùi được')
|
||
})
|
||
.map<PeApproval>(c => {
|
||
const m = c.summary?.match(/Chuyển phase (\w+) → (\w+)/)
|
||
const fromPhase = m ? (phaseEnumMap[m[1]] ?? 10) : 10
|
||
const toPhase = m ? (phaseEnumMap[m[2]] ?? 10) : 10
|
||
return {
|
||
id: `syn-${c.id}`,
|
||
fromPhase,
|
||
toPhase,
|
||
approverUserId: c.userId ?? null,
|
||
approverName: c.userName ?? null,
|
||
decision: 2,
|
||
comment: c.contextNote ?? c.summary ?? null,
|
||
approvedAt: c.createdAt,
|
||
}
|
||
})
|
||
|
||
const realRejectKeys = new Set(
|
||
ev.approvals
|
||
.filter(a => a.decision === 2)
|
||
.map(a => `${a.approverUserId ?? ''}-${Math.floor(new Date(a.approvedAt).getTime() / 5000)}`),
|
||
)
|
||
const dedupedSynthetic = syntheticRejects.filter(s =>
|
||
!realRejectKeys.has(`${s.approverUserId ?? ''}-${Math.floor(new Date(s.approvedAt).getTime() / 5000)}`),
|
||
)
|
||
|
||
return [...ev.approvals, ...dedupedSynthetic]
|
||
.sort((a, b) => new Date(a.approvedAt).getTime() - new Date(b.approvedAt).getTime())
|
||
}, [ev.approvals, changelogs.data])
|
||
|
||
if (merged.length === 0) return <p className="text-sm text-slate-500">Chưa có 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 có 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 có 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 có 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>
|
||
)
|
||
}
|