All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m17s
User báo button "Lưu & Gửi Duyệt" KHÔNG hoạt động + suy đoán "trùng ID".
Phân tích: button disabled khi `evaluation.workflow.nextPhases` không có
forward phase (chỉ TuChoi/TraLai). Hiện FE silent — không cách nào biết.
Improvement (cả 2 app, mirror):
- Compute `forwardPhase` once thay vì 2 lần (.find / .some).
- Add `submitDisabledReason` string giải thích reason:
* canEditPhase=false → "Phiếu đã ở phase X — 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ừ X. Liên hệ
admin kiểm tra cấu hình quy trình"
- Button title attribute show reason (hover tooltip) hoặc forward phase
label khi enabled: "Gửi phiếu sang 'Chờ Purchasing'"
- Confirm dialog show forward phase explicit: 'Gửi phiếu vào quy trình
duyệt? Sẽ chuyển sang "Chờ Purchasing". Sau khi gửi sẽ KHÔNG sửa
được nữa (trừ khi approver Trả lại).'
Note "trùng ID" KHÔNG phải bug FE: PurchaseEvaluationWorkspacePage
URL state đúng (`+ Thêm mới` clear `id`, save set new). Mỗi PE row
unique GUID + MaPhieu. User feedback có thể due to button silent
disabled — tooltip giờ rõ reason.
Verify: npm build fe-admin + fe-user pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1800 lines
77 KiB
TypeScript
1800 lines
77 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, useRef, useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { toast } from 'sonner'
|
||
import { Check, 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 { Select } from '@/components/ui/Select'
|
||
import { api } from '@/lib/api'
|
||
import { getErrorMessage } from '@/lib/apiError'
|
||
import { cn } from '@/lib/cn'
|
||
import {
|
||
PeAttachmentPurpose,
|
||
PeAttachmentPurposeLabel,
|
||
PeDepartmentKind,
|
||
PeDepartmentKindLabel,
|
||
PeDisplayStatusColor,
|
||
PeDisplayStatusLabel,
|
||
PurchaseEvaluationPhase,
|
||
PurchaseEvaluationPhaseColor,
|
||
PurchaseEvaluationPhaseLabel,
|
||
PurchaseEvaluationTypeLabel,
|
||
getPeDisplayStatus,
|
||
isEditablePhase,
|
||
type PeAttachment,
|
||
type PeChangelog,
|
||
type PeDepartmentOpinion,
|
||
type PeDetailBundle,
|
||
type PeDetailRow,
|
||
type PeQuote,
|
||
type PeSupplier,
|
||
} from '@/types/purchaseEvaluation'
|
||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||
import type { Paged, Supplier } from '@/types/master'
|
||
|
||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||
|
||
// 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'
|
||
|
||
// "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.
|
||
const submitForApproval = useMutation({
|
||
mutationFn: async () => {
|
||
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
|
||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||
targetPhase: next,
|
||
decision: 1,
|
||
comment: null,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('Đã gửi duyệt phiếu — chuyển sang quy trình duyệt.')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
onBack()
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
|
||
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||
const canSubmitForApproval = mode === 'workspace'
|
||
&& canEditPhase
|
||
&& !readOnly
|
||
&& forwardPhase != null
|
||
|
||
// Tooltip reason cho button disabled (giúp diagnose tại sao "Lưu & Gửi Duyệt"
|
||
// không bấm được — user feedback 2026-05-07).
|
||
const submitDisabledReason = !canEditPhase
|
||
? `Phiếu đã ở phase ${PurchaseEvaluationPhaseLabel[evaluation.phase]} — chỉ Bản nháp / Trả lại mới sửa + gửi được.`
|
||
: readOnly
|
||
? 'Chế độ chỉ đọc.'
|
||
: !forwardPhase
|
||
? `Workflow không có phase tiếp theo từ ${PurchaseEvaluationPhaseLabel[evaluation.phase]}. Liên hệ admin kiểm tra cấu hình quy trình.`
|
||
: null
|
||
|
||
return (
|
||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-base font-semibold text-slate-900">{evaluation.tenGoiThau}</h2>
|
||
{/* Display status meta (Bản nháp / Đã gửi duyệt / Đã duyệt / Từ chối)
|
||
— phase chi tiết hiện ở Workflow timeline Panel 3. */}
|
||
<span
|
||
className={cn(
|
||
'rounded px-1.5 py-0.5 text-[11px] font-medium',
|
||
PeDisplayStatusColor[getPeDisplayStatus(evaluation.phase)],
|
||
)}
|
||
>
|
||
{PeDisplayStatusLabel[getPeDisplayStatus(evaluation.phase)]}
|
||
</span>
|
||
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
|
||
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
|
||
</span>
|
||
{readOnly && (
|
||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
|
||
chế độ duyệt
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
|
||
<span className="font-mono">{evaluation.maPhieu ?? '—'}</span>
|
||
<span>·</span>
|
||
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
|
||
<span>·</span>
|
||
<span>{evaluation.projectName}</span>
|
||
{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 1 — đúng spec form FO-PHIẾU TRÌNH KÝ CHỌN TP/NCC */}
|
||
<Section title="1. Thông tin gói thầu">
|
||
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
|
||
</Section>
|
||
<Section title="2. Chọn NCC / TP">
|
||
<ChonNccSection ev={evaluation} readOnly={readOnly} />
|
||
</Section>
|
||
<Section title={`3. NCC / TP tham gia (${evaluation.suppliers.length})`}>
|
||
<SuppliersTab ev={evaluation} readOnly={readOnly} />
|
||
</Section>
|
||
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||
</Section>
|
||
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||
{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ý nhập khi duyệt phiếu — vào menu “Duyệt” để ký.
|
||
</div>
|
||
)}
|
||
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
|
||
</Section>
|
||
</div>
|
||
|
||
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
|
||
- Xóa phiếu (CHỈ Bản nháp, soft-delete BE) — bên trái red
|
||
- Lưu (toast confirm, KHÔNG đóng workspace) — chính giữa ghost
|
||
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
|
||
User 2026-05-07. */}
|
||
{mode === 'workspace' && canEditPhase && !readOnly && (
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
||
<div className="flex items-center gap-3">
|
||
{/* Xóa phiếu — CHỈ DangSoanThao (bản nháp). TraLai không cho xóa
|
||
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
|
||
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
|
||
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => {
|
||
if (confirm(`Xóa phiếu "${evaluation.tenGoiThau}"? Phiếu sẽ ẩn khỏi danh sách (soft-delete, không xóa hoàn toàn trong DB).`)) {
|
||
onDelete()
|
||
}
|
||
}}
|
||
className="gap-1.5 text-xs"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> Xóa phiếu
|
||
</Button>
|
||
)}
|
||
<span className="text-[11px] text-slate-500">
|
||
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => {
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
toast.success('Đã lưu — sync server.')
|
||
}}
|
||
className="text-xs"
|
||
>
|
||
Lưu
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
if (!forwardPhase) return
|
||
if (confirm(`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).`)) {
|
||
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 }) {
|
||
return (
|
||
<section className="px-5 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>
|
||
)
|
||
}
|
||
|
||
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||
|
||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
||
return (
|
||
<div>
|
||
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử duyệt ({ev.approvals.length})</h3>
|
||
<ApprovalsTab ev={ev} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
|
||
return (
|
||
<div>
|
||
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử thay đổi</h3>
|
||
<HistoryTab ev={ev} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
|
||
// Inline editable khi canEdit (=!readOnly && phase editable). Edit pencil button
|
||
// "Sửa" flip display ↔ form mode. Save dùng existing PUT /pe/:id endpoint với
|
||
// current entity values + new header fields. Dự án + Type LOCKED sau create —
|
||
// chỉ Tên/Địa điểm/Mô tả/Payment editable inline. autoEdit prop cho phép trigger
|
||
// edit mode từ pencil icon trong PeListPanel (URL flag ?editHeader=1).
|
||
// Phase editable = DangSoanThao + TraLai (user 2026-05-07).
|
||
function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boolean; autoEdit: boolean }) {
|
||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||
const qc = useQueryClient()
|
||
const [editing, setEditing] = useState(autoEdit && canEdit)
|
||
const [tenGoiThau, setTenGoiThau] = useState(ev.tenGoiThau)
|
||
const [diaDiem, setDiaDiem] = useState(ev.diaDiem ?? '')
|
||
const [moTa, setMoTa] = useState(ev.moTa ?? '')
|
||
const [paymentTerms, setPaymentTerms] = useState(ev.paymentTerms ?? '')
|
||
|
||
// User 2026-05-07: re-trigger editing mode khi click pencil ở Panel 1 cho
|
||
// PHIẾU KHÁC (ev.id thay đổi) hoặc autoEdit prop change. useState init chỉ
|
||
// chạy mount-time → cần useEffect sync khi parent re-render với props mới.
|
||
useEffect(() => {
|
||
if (autoEdit && canEdit) {
|
||
setEditing(true)
|
||
// Sync values từ ev mới (tránh stale state khi switch giữa 2 phiếu)
|
||
setTenGoiThau(ev.tenGoiThau)
|
||
setDiaDiem(ev.diaDiem ?? '')
|
||
setMoTa(ev.moTa ?? '')
|
||
setPaymentTerms(ev.paymentTerms ?? '')
|
||
}
|
||
}, [autoEdit, canEdit, ev.id, ev.tenGoiThau, ev.diaDiem, ev.moTa, ev.paymentTerms])
|
||
|
||
const dirty = tenGoiThau !== ev.tenGoiThau
|
||
|| diaDiem !== (ev.diaDiem ?? '')
|
||
|| moTa !== (ev.moTa ?? '')
|
||
|| paymentTerms !== (ev.paymentTerms ?? '')
|
||
|
||
const save = useMutation({
|
||
mutationFn: async () => {
|
||
await api.put(`/purchase-evaluations/${ev.id}`, {
|
||
id: ev.id,
|
||
tenGoiThau,
|
||
diaDiem: diaDiem || null,
|
||
moTa: moTa || null,
|
||
paymentTerms: paymentTerms || null,
|
||
budgetId: ev.budgetId,
|
||
budgetManualName: ev.budgetManualName,
|
||
budgetManualAmount: ev.budgetManualAmount,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('Đã cập nhật thông tin')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
setEditing(false)
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
function reset() {
|
||
setTenGoiThau(ev.tenGoiThau)
|
||
setDiaDiem(ev.diaDiem ?? '')
|
||
setMoTa(ev.moTa ?? '')
|
||
setPaymentTerms(ev.paymentTerms ?? '')
|
||
}
|
||
|
||
if (!editing) {
|
||
return (
|
||
<dl className="space-y-2 text-sm">
|
||
<div className="flex items-start justify-between">
|
||
<FormRow label="a. Tên gói thầu" value={ev.tenGoiThau} />
|
||
{canEdit && (
|
||
<button
|
||
onClick={() => setEditing(true)}
|
||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 hover:text-brand-600"
|
||
title="Sửa thông tin gói thầu"
|
||
>
|
||
<Pencil className="h-3 w-3" /> Sửa
|
||
</button>
|
||
)}
|
||
</div>
|
||
<FormRow label="b. Dự án" value={ev.projectName} />
|
||
{(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>
|
||
<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>
|
||
<div className="md:col-span-2">
|
||
<Label className="text-[11px]">Điều khoản thanh toán</Label>
|
||
<Input
|
||
value={paymentTerms}
|
||
onChange={e => setPaymentTerms(e.target.value)}
|
||
placeholder="JSON hoặc text"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => { reset(); setEditing(false) }}
|
||
className="h-7 px-3 text-xs"
|
||
>
|
||
Hủy
|
||
</Button>
|
||
<Button
|
||
onClick={() => save.mutate()}
|
||
disabled={!dirty || !tenGoiThau || save.isPending}
|
||
className="h-7 px-3 text-xs"
|
||
>
|
||
{save.isPending ? 'Đang lưu…' : 'Lưu'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) =====
|
||
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3
|
||
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)".
|
||
// Save dùng POST /pe/:id/select-winner endpoint hiện có.
|
||
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||
const qc = useQueryClient()
|
||
const setWinner = useMutation({
|
||
mutationFn: async (supplierId: string) =>
|
||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||
onSuccess: () => {
|
||
toast.success('Đã chọn NCC.')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
if (!canEdit) {
|
||
return (
|
||
<FormRow
|
||
label="a. NCC / TP được chọn"
|
||
value={ev.selectedSupplierName
|
||
? <span className="font-medium text-emerald-700">✓ {ev.selectedSupplierName}</span>
|
||
: <span className="text-slate-400">— (chưa chọn)</span>}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
||
<div className="relative min-w-0 flex-1">
|
||
<Select
|
||
value={ev.selectedSupplierId ?? ''}
|
||
onChange={e => setWinner.mutate(e.target.value)}
|
||
disabled={ev.suppliers.length === 0 || setWinner.isPending}
|
||
className="text-sm"
|
||
>
|
||
<option value="">— Chọn NCC từ danh sách Section 3 —</option>
|
||
{ev.suppliers.map(s => (
|
||
<option key={s.id} value={s.supplierId}>
|
||
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
|
||
{setWinner.isPending && (
|
||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||
<span>Đang chọn NCC + sync cột giá Section 4…</span>
|
||
</div>
|
||
)}
|
||
{ev.suppliers.length === 0 && (
|
||
<p className="mt-1 text-[11px] text-amber-600">
|
||
Thêm NCC ở Section 3 trước rồi mới chọn winner.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== b. Ngân sách inline editor (Mig 17) =====
|
||
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần
|
||
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách /
|
||
// Duyệt). Edit chỉ enable khi !readOnly + phase editable (DangSoanThao /
|
||
// TraLai). Read-only khi pendingMe=1 hoặc phase đã gửi duyệt / đã duyệt /
|
||
// từ chối. Empty values hiển thị empty (per user 2026-05-07).
|
||
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||
const qc = useQueryClient()
|
||
|
||
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link
|
||
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
|
||
const [manualMode, setManualMode] = useState(initialManual)
|
||
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
|
||
const [manualName, setManualName] = useState(ev.budgetManualName ?? '')
|
||
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
|
||
|
||
// Eligible budgets — chỉ fetch khi user có khả năng edit
|
||
const eligibleBudgets = useQuery({
|
||
queryKey: ['eligible-budgets', ev.projectId],
|
||
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
|
||
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
|
||
})).data.items,
|
||
enabled: canEdit,
|
||
})
|
||
|
||
// Dirty detect — compare current state vs ev original
|
||
const dirty = manualMode !== initialManual
|
||
|| (manualMode && (manualName !== (ev.budgetManualName ?? '') || manualAmount !== (ev.budgetManualAmount ?? 0)))
|
||
|| (!manualMode && budgetId !== (ev.budgetId ?? ''))
|
||
|
||
const save = useMutation({
|
||
mutationFn: async () => {
|
||
const payload = manualMode
|
||
? { budgetId: null, budgetManualName: manualName || null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
|
||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||
await api.put(`/purchase-evaluations/${ev.id}`, {
|
||
id: ev.id,
|
||
tenGoiThau: ev.tenGoiThau,
|
||
diaDiem: ev.diaDiem,
|
||
moTa: ev.moTa,
|
||
paymentTerms: ev.paymentTerms,
|
||
...payload,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('Đã cập nhật ngân sách')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
// Read-only mode: chỉ display (không toggle, không edit)
|
||
if (!canEdit) {
|
||
return (
|
||
<FormRow
|
||
label="b. Ngân sách"
|
||
value={ev.budget ? (
|
||
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
|
||
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
|
||
{' · '}{ev.budget.tenNganSach}
|
||
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||
</a>
|
||
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
|
||
<span className="text-slate-700">
|
||
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
|
||
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
|
||
{ev.budgetManualAmount != null && (
|
||
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
|
||
)}
|
||
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
|
||
</span>
|
||
) : <span className="text-slate-400">—</span>}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// Editable mode (canEdit=true)
|
||
return (
|
||
<div className="flex gap-3">
|
||
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span>
|
||
<div className="min-w-0 flex-1 space-y-2">
|
||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
|
||
<input
|
||
type="checkbox"
|
||
checked={manualMode}
|
||
onChange={e => setManualMode(e.target.checked)}
|
||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||
/>
|
||
Nhập tay (không link)
|
||
</label>
|
||
{!manualMode ? (
|
||
<Select
|
||
value={budgetId}
|
||
onChange={e => setBudgetId(e.target.value)}
|
||
className="text-sm"
|
||
>
|
||
<option value="">—</option>
|
||
{eligibleBudgets.data?.map(b => (
|
||
<option key={b.id} value={b.id}>
|
||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||
</option>
|
||
))}
|
||
</Select>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||
<Input
|
||
value={manualName}
|
||
onChange={e => setManualName(e.target.value)}
|
||
placeholder="Tên ngân sách (vd Tạm tính T11/2025)"
|
||
maxLength={200}
|
||
className="text-sm"
|
||
/>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
value={manualAmount || ''}
|
||
onChange={e => setManualAmount(Number(e.target.value))}
|
||
placeholder="Số tiền (đ)"
|
||
className="text-sm"
|
||
/>
|
||
</div>
|
||
)}
|
||
{dirty && (
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
onClick={() => save.mutate()}
|
||
disabled={save.isPending}
|
||
className="h-7 px-3 text-xs"
|
||
>
|
||
{save.isPending ? 'Đang lưu…' : 'Lưu ngân sách'}
|
||
</Button>
|
||
<button
|
||
onClick={() => {
|
||
setManualMode(initialManual)
|
||
setBudgetId(ev.budgetId ?? '')
|
||
setManualName(ev.budgetManualName ?? '')
|
||
setManualAmount(ev.budgetManualAmount ?? 0)
|
||
}}
|
||
className="text-[11px] text-slate-500 hover:text-slate-700"
|
||
>
|
||
Hủy thay đổi
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
||
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
|
||
// c. Giá chào thầu = sum quotes của NCC được chọn (winner)
|
||
const winnerSupplierRowId = ev.selectedSupplierId
|
||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
||
: null
|
||
const giaChaoThau = winnerSupplierRowId
|
||
? ev.details
|
||
.flatMap(d => d.quotes)
|
||
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
|
||
.reduce((sum, q) => sum + q.thanhTien, 0)
|
||
: null
|
||
|
||
// d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null
|
||
const banSoSanhAttachments = ev.attachments.filter(
|
||
a => a.purchaseEvaluationSupplierId === null,
|
||
)
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<NccSelectorRow ev={ev} readOnly={readOnly} />
|
||
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
||
<FormRow
|
||
label="c. Giá chào thầu"
|
||
value={
|
||
winnerSupplierRowId === null ? (
|
||
<span className="text-slate-400">— (chọn NCC/TP ở (a) trước)</span>
|
||
) : giaChaoThau === 0 ? (
|
||
<span className="text-slate-400">— (chưa nhập báo giá ở Section 4)</span>
|
||
) : (
|
||
<span className="font-semibold text-slate-900">{giaChaoThau!.toLocaleString('vi-VN')} đ</span>
|
||
)
|
||
}
|
||
/>
|
||
<div>
|
||
<div className="flex gap-3">
|
||
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span>
|
||
<div className="min-w-0 flex-1">
|
||
<GeneralAttachmentsSection
|
||
evaluationId={ev.id}
|
||
attachments={banSoSanhAttachments}
|
||
readOnly={readOnly}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{ev.paymentTerms && (
|
||
<FormRow label="Điều khoản thanh toán" value={<span className="whitespace-pre-wrap">{ev.paymentTerms}</span>} />
|
||
)}
|
||
{ev.contractId && (
|
||
<FormRow
|
||
label="HĐ kế thừa"
|
||
value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline">✓ Xem 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>
|
||
)
|
||
}
|
||
|
||
// 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>
|
||
)
|
||
}
|
||
|
||
// ===== Tab: NCC =====
|
||
function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||
const qc = useQueryClient()
|
||
const [open, setOpen] = useState(false)
|
||
const [editRow, setEditRow] = useState<PeSupplier | null>(null)
|
||
|
||
const remove = useMutation({
|
||
mutationFn: async (rowId: string) => api.delete(`/purchase-evaluations/${ev.id}/suppliers/${rowId}`),
|
||
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
const setWinner = useMutation({
|
||
mutationFn: async (supplierId: string) =>
|
||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||
onSuccess: () => { toast.success('Đã chọn NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
return (
|
||
<div>
|
||
{!readOnly && (
|
||
<div className="mb-3 flex justify-end">
|
||
<Button onClick={() => setOpen(true)} className="gap-1.5 text-xs">
|
||
<Plus className="h-3.5 w-3.5" /> Thêm NCC
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{ev.suppliers.length === 0 ? (
|
||
<p className="text-sm text-slate-500">
|
||
{readOnly ? 'Chưa có NCC.' : 'Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.'}
|
||
</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full text-sm">
|
||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left">NCC</th>
|
||
<th className="px-3 py-2 text-left">Liên hệ</th>
|
||
<th className="px-3 py-2 text-left">Điều khoản TT</th>
|
||
<th className="px-3 py-2 text-left">File đính kèm</th>
|
||
{!readOnly && <th className="px-3 py-2"></th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{ev.suppliers.map(s => (
|
||
<tr key={s.id} className={cn('align-top', ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
||
<td className="px-3 py-2">
|
||
<div className="font-medium text-slate-900">{s.supplierName}</div>
|
||
{s.displayName && <div className="text-[11px] text-slate-500">{s.displayName}</div>}
|
||
{s.note && <div className="mt-0.5 text-[11px] text-amber-600">{s.note}</div>}
|
||
{readOnly && ev.selectedSupplierId === s.supplierId && (
|
||
<div className="mt-0.5 text-[11px] font-medium text-emerald-700">✓ NCC được chọn</div>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 text-[12px] text-slate-600">
|
||
{s.contactName && <div>{s.contactName}</div>}
|
||
{s.contactPhone && <div>{s.contactPhone}</div>}
|
||
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
||
</td>
|
||
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
|
||
<td className="px-3 py-2">
|
||
<SupplierAttachmentsCell
|
||
evaluationId={ev.id}
|
||
supplierRowId={s.id}
|
||
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
|
||
readOnly={readOnly}
|
||
/>
|
||
</td>
|
||
{!readOnly && (() => {
|
||
// User 2026-05-07: NCC đã được chọn (winner) → KHÔNG cho
|
||
// sửa/xóa (tránh thay đổi NCC đã chốt). Chỉ hiển thị
|
||
// checkmark active state.
|
||
// User 2026-05-07 (B11+): NCC đã có hạng mục báo giá (quotes
|
||
// entered in Section 4) → KHÔNG cho xóa (tránh mất báo giá đã nhập).
|
||
const isWinner = ev.selectedSupplierId === s.supplierId
|
||
const hasQuotes = ev.details.some(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === s.id))
|
||
const canDelete = !isWinner && !hasQuotes
|
||
return (
|
||
<td className="px-3 py-2">
|
||
<div className="flex justify-end gap-1">
|
||
<button
|
||
onClick={() => setWinner.mutate(s.supplierId)}
|
||
className={cn(
|
||
'rounded px-1.5 py-0.5 text-[11px]',
|
||
isWinner
|
||
? 'bg-emerald-100 text-emerald-700'
|
||
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
|
||
)}
|
||
title={isWinner ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'}
|
||
>
|
||
<Check className="h-3.5 w-3.5" />
|
||
</button>
|
||
{!isWinner && (
|
||
<button
|
||
onClick={() => setEditRow(s)}
|
||
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
|
||
title="Sửa"
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
{canDelete ? (
|
||
<button
|
||
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
|
||
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
||
title="Xóa"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
) : !isWinner && hasQuotes && (
|
||
<span
|
||
className="rounded px-1.5 py-0.5 text-slate-300 cursor-not-allowed"
|
||
title="NCC đã có báo giá ở Section 4 — xóa báo giá trước rồi mới xóa NCC"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
)
|
||
})()}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{open && <AddSupplierDialog evaluationId={ev.id} onClose={() => setOpen(false)} />}
|
||
{editRow && <EditSupplierDialog evaluationId={ev.id} row={editRow} onClose={() => setEditRow(null)} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: 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: '',
|
||
})
|
||
const mut = useMutation({
|
||
mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form),
|
||
onSuccess: () => { toast.success('Đã 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 || mut.isPending}>Thêm</Button>
|
||
</>}
|
||
>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>NCC (master)</Label>
|
||
<Select value={form.supplierId} onChange={e => setForm({ ...form, supplierId: e.target.value })}>
|
||
<option value="">-- Chọn --</option>
|
||
{suppliers.data?.map(s => (
|
||
<option key={s.id} value={s.id}>{s.code} — {s.name}</option>
|
||
))}
|
||
</Select>
|
||
</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 value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
|
||
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></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>
|
||
</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 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={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 value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
|
||
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></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á (matrix) =====
|
||
function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||
const qc = useQueryClient()
|
||
const [addOpen, setAddOpen] = useState(false)
|
||
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
|
||
const [quoteEdit, setQuoteEdit] = useState<{ detail: PeDetailRow; supplier: PeSupplier; existing: PeQuote | null } | null>(null)
|
||
|
||
const removeDetail = useMutation({
|
||
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${ev.id}/details/${id}`),
|
||
onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
const quoteKey = (detailId: string, supplierRowId: string) =>
|
||
ev.details.find(d => d.id === detailId)?.quotes.find(q => q.purchaseEvaluationSupplierId === supplierRowId) ?? null
|
||
|
||
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
|
||
// Match key: groupCode|itemCode (case-sensitive match; itemCode null cho phép).
|
||
const budgetBundle = useQuery({
|
||
queryKey: ['budget-detail-for-pe', ev.budgetId],
|
||
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
|
||
`/budgets/${ev.budgetId}`)).data,
|
||
enabled: !!ev.budgetId,
|
||
})
|
||
const budgetRowMap = (() => {
|
||
const m = new Map<string, number>()
|
||
budgetBundle.data?.details.forEach(d => {
|
||
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
|
||
})
|
||
return m
|
||
})()
|
||
const showBudgetCol = !!ev.budgetId
|
||
const totalPeNganSach = ev.details.reduce((sum, d) => sum + d.thanhTienNganSach, 0)
|
||
const totalBudget = budgetBundle.data?.tongNganSach ?? 0
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<p className="text-xs text-slate-500">
|
||
{ev.suppliers.length === 0
|
||
? (readOnly ? 'Chưa có NCC tham gia.' : 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.')
|
||
: readOnly
|
||
? `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC`
|
||
: `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC — click ô để nhập báo giá.`}
|
||
</p>
|
||
{!readOnly && (
|
||
<Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs">
|
||
<Plus className="h-3.5 w-3.5" /> Thêm hạng mục
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{ev.details.length === 0 ? (
|
||
<p className="text-sm text-slate-500">Chưa có hạng mục.</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full border border-slate-200 text-xs">
|
||
<thead className="bg-slate-50 text-slate-600">
|
||
<tr>
|
||
<th className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-left">Hạng mục</th>
|
||
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th>
|
||
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th>
|
||
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th>
|
||
{showBudgetCol && (
|
||
<th className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right" title="So với ngân sách đã link">
|
||
NS link · Δ
|
||
</th>
|
||
)}
|
||
{ev.suppliers.map(s => {
|
||
// User 2026-05-07: dùng tên NCC (master) thay vì displayName.
|
||
// Khi NCC là winner (selected ở Section 2.a) → column highlight
|
||
// emerald để cell giá ăn theo màu xanh (visual trace winner).
|
||
const isWinner = ev.selectedSupplierId === s.supplierId
|
||
return (
|
||
<th
|
||
key={s.id}
|
||
className={cn(
|
||
'border-r border-slate-200 px-2 py-2 text-right',
|
||
isWinner && 'bg-emerald-50 text-emerald-700',
|
||
)}
|
||
title={s.displayName ?? undefined}
|
||
>
|
||
{isWinner && '✓ '}{s.supplierName}
|
||
</th>
|
||
)
|
||
})}
|
||
{!readOnly && <th className="px-2 py-2"></th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{ev.details.map(d => (
|
||
<tr key={d.id}>
|
||
<td className="sticky left-0 z-10 border-r border-slate-200 bg-white px-2 py-2">
|
||
<div className="font-medium text-slate-900">{d.groupCode} {d.noiDung}</div>
|
||
<div className="text-[10px] text-slate-500">{d.groupName} · {d.donViTinh ?? ''}</div>
|
||
</td>
|
||
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{d.khoiLuongNganSach}</td>
|
||
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.donGiaNganSach)}</td>
|
||
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.thanhTienNganSach)}</td>
|
||
{showBudgetCol && (() => {
|
||
const bgValue = budgetRowMap.get(`${d.groupCode}|${d.itemCode ?? ''}`)
|
||
if (bgValue == null)
|
||
return <td className="border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right text-slate-300">—</td>
|
||
const delta = d.thanhTienNganSach - bgValue
|
||
return (
|
||
<td
|
||
className={cn(
|
||
'border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right font-mono',
|
||
delta > 0 && 'text-red-600',
|
||
delta < 0 && 'text-emerald-600',
|
||
delta === 0 && 'text-slate-500',
|
||
)}
|
||
title={`Ngân sách: ${fmtMoney(bgValue)} · Δ ${delta > 0 ? '+' : ''}${fmtMoney(delta)}`}
|
||
>
|
||
{fmtMoney(bgValue)}
|
||
<div className="text-[10px]">{delta === 0 ? '=' : (delta > 0 ? `+${fmtMoney(delta)}` : fmtMoney(delta))}</div>
|
||
</td>
|
||
)
|
||
})()}
|
||
{ev.suppliers.map(s => {
|
||
const q = quoteKey(d.id, s.id)
|
||
// Winner NCC (selected ở Section 2.a) → cell ăn theo màu xanh
|
||
// emerald (user 2026-05-07). isSelected per-quote checkbox bỏ
|
||
// (đã consolidate winner ở Section 2.a NccSelectorRow).
|
||
const isWinnerColumn = ev.selectedSupplierId === s.supplierId
|
||
return (
|
||
<td
|
||
key={s.id}
|
||
onClick={readOnly ? undefined : () => setQuoteEdit({ detail: d, supplier: s, existing: q })}
|
||
className={cn(
|
||
'border-r border-slate-200 px-2 py-2 text-right font-mono transition',
|
||
!readOnly && 'cursor-pointer hover:bg-brand-50',
|
||
isWinnerColumn && 'bg-emerald-50 font-semibold text-emerald-700',
|
||
)}
|
||
>
|
||
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300">—</span>}
|
||
</td>
|
||
)
|
||
})}
|
||
{!readOnly && (
|
||
<td className="px-2 py-2">
|
||
<div className="flex gap-1">
|
||
<button onClick={() => setEditDetail(d)} className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100">
|
||
<Pencil className="h-3 w-3" />
|
||
</button>
|
||
<button onClick={() => { if (confirm('Xóa hạng mục?')) removeDetail.mutate(d.id) }} className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50">
|
||
<Trash2 className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
{showBudgetCol && (
|
||
<tfoot className="border-t-2 border-slate-300 bg-slate-50 text-xs font-semibold">
|
||
<tr>
|
||
<td className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-right">Tổng:</td>
|
||
<td className="border-r border-slate-200"></td>
|
||
<td className="border-r border-slate-200"></td>
|
||
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(totalPeNganSach)}</td>
|
||
<td className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right font-mono">
|
||
{fmtMoney(totalBudget)}
|
||
{(() => {
|
||
const delta = totalPeNganSach - totalBudget
|
||
return (
|
||
<div className={cn(
|
||
'text-[10px]',
|
||
delta > 0 && 'text-red-600',
|
||
delta < 0 && 'text-emerald-600',
|
||
delta === 0 && 'text-slate-500',
|
||
)}>
|
||
{delta === 0 ? 'khớp ngân sách' : delta > 0 ? `vượt +${fmtMoney(delta)}` : `dưới ${fmtMoney(delta)}`}
|
||
</div>
|
||
)
|
||
})()}
|
||
</td>
|
||
{ev.suppliers.map(s => <td key={s.id} className="border-r border-slate-200" />)}
|
||
{!readOnly && <td />}
|
||
</tr>
|
||
</tfoot>
|
||
)}
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
|
||
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
|
||
{quoteEdit && (
|
||
<QuoteDialog
|
||
evaluationId={ev.id}
|
||
detailId={quoteEdit.detail.id}
|
||
supplierRowId={quoteEdit.supplier.id}
|
||
supplierName={quoteEdit.supplier.supplierName}
|
||
itemName={quoteEdit.detail.noiDung}
|
||
khoiLuong={quoteEdit.detail.khoiLuongThiCong || quoteEdit.detail.khoiLuongNganSach}
|
||
existing={quoteEdit.existing}
|
||
onClose={() => setQuoteEdit(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
|
||
const qc = useQueryClient()
|
||
const [form, setForm] = useState({
|
||
groupCode: row?.groupCode ?? 'A.I',
|
||
groupName: row?.groupName ?? '',
|
||
itemCode: row?.itemCode ?? '',
|
||
noiDung: row?.noiDung ?? '',
|
||
donViTinh: row?.donViTinh ?? '',
|
||
khoiLuongNganSach: row?.khoiLuongNganSach ?? 0,
|
||
khoiLuongThiCong: row?.khoiLuongThiCong ?? 0,
|
||
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)),
|
||
})
|
||
|
||
const updateAndRecalc = (patch: Partial<typeof form>) => {
|
||
const next = { ...form, ...patch }
|
||
// Auto-compute ThanhTien = KL ngân sách × ĐG ngân sách
|
||
next.thanhTienNganSach = Number(next.khoiLuongNganSach) * Number(next.donGiaNganSach)
|
||
setForm(next)
|
||
}
|
||
|
||
return (
|
||
<Dialog
|
||
open
|
||
onClose={onClose}
|
||
title={(row ? 'Sửa' : 'Thêm') + ' hạng mục'}
|
||
size="lg"
|
||
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 className="grid grid-cols-3 gap-3">
|
||
<div><Label>Nhóm (A.I/A.II...)</Label><Input value={form.groupCode} onChange={e => setForm({ ...form, groupCode: e.target.value })} /></div>
|
||
<div className="col-span-2"><Label>Tên nhóm</Label><Input value={form.groupName} onChange={e => setForm({ ...form, groupName: e.target.value })} placeholder="Bê tông / Phụ gia..." /></div>
|
||
<div><Label>Mã (tùy chọn)</Label><Input value={form.itemCode} onChange={e => setForm({ ...form, itemCode: e.target.value })} /></div>
|
||
<div className="col-span-2"><Label>Nội dung</Label><Input value={form.noiDung} onChange={e => setForm({ ...form, noiDung: e.target.value })} /></div>
|
||
<div><Label>ĐVT</Label><Input value={form.donViTinh} onChange={e => setForm({ ...form, donViTinh: e.target.value })} /></div>
|
||
<div><Label>KL ngân sách</Label><Input type="number" value={form.khoiLuongNganSach} onChange={e => updateAndRecalc({ khoiLuongNganSach: Number(e.target.value) })} /></div>
|
||
<div><Label>KL thi công</Label><Input type="number" value={form.khoiLuongThiCong} onChange={e => setForm({ ...form, khoiLuongThiCong: Number(e.target.value) })} /></div>
|
||
<div><Label>Đơn giá ngân sách</Label><Input type="number" value={form.donGiaNganSach} onChange={e => updateAndRecalc({ donGiaNganSach: Number(e.target.value) })} /></div>
|
||
<div className="col-span-2"><Label>Thành tiền ngân sách (auto)</Label><Input type="number" value={form.thanhTienNganSach} onChange={e => setForm({ ...form, thanhTienNganSach: Number(e.target.value) })} /></div>
|
||
<div className="col-span-3"><Label>Ghi chú</Label><Input value={form.ghiChu} onChange={e => setForm({ ...form, ghiChu: e.target.value })} /></div>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function QuoteDialog({
|
||
evaluationId, detailId, supplierRowId, supplierName, itemName, khoiLuong, existing, onClose,
|
||
}: {
|
||
evaluationId: string
|
||
detailId: string
|
||
supplierRowId: string
|
||
supplierName: string
|
||
itemName: string
|
||
khoiLuong: number
|
||
existing: PeQuote | null
|
||
onClose: () => void
|
||
}) {
|
||
const qc = useQueryClient()
|
||
// User 2026-05-07: Bỏ `isSelected` checkbox per-quote (consolidate winner
|
||
// selection ở Section 2.a NccSelectorRow). BE vẫn nhận isSelected nhưng FE
|
||
// luôn gửi `false` (existing.isSelected nếu có để giữ nguyên trạng thái cũ).
|
||
const [form, setForm] = useState({
|
||
bgVat: existing?.bgVat ?? 0,
|
||
chuaVat: existing?.chuaVat ?? 0,
|
||
thanhTien: existing?.thanhTien ?? 0,
|
||
note: existing?.note ?? '',
|
||
})
|
||
|
||
const updateAndRecalc = (patch: Partial<typeof form>) => {
|
||
const next = { ...form, ...patch }
|
||
next.thanhTien = Number(next.chuaVat) * khoiLuong
|
||
setForm(next)
|
||
}
|
||
|
||
const mut = useMutation({
|
||
mutationFn: async () =>
|
||
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
|
||
purchaseEvaluationDetailId: detailId,
|
||
purchaseEvaluationSupplierId: supplierRowId,
|
||
...form,
|
||
isSelected: existing?.isSelected ?? false, // giữ nguyên trạng thái cũ, không expose UI
|
||
}),
|
||
onSuccess: () => { toast.success('Đã lưu báo giá.'); 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 báo giá.'); 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>
|
||
</>}
|
||
>
|
||
{/* Loading overlay khi save có delay (user 2026-05-07) */}
|
||
<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 báo giá…' : 'Đang xóa…'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong> · KL {khoiLuong}</p>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div><Label>Đơn giá chưa VAT</Label><Input type="number" value={form.chuaVat} onChange={e => updateAndRecalc({ chuaVat: Number(e.target.value) })} /></div>
|
||
<div><Label>Đơn giá có VAT</Label><Input type="number" value={form.bgVat} onChange={e => setForm({ ...form, bgVat: Number(e.target.value) })} /></div>
|
||
<div><Label>Thành tiền (auto)</Label><Input type="number" value={form.thanhTien} onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })} /></div>
|
||
</div>
|
||
<div><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
|
||
</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
// ===== Tab: Duyệt =====
|
||
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
|
||
if (ev.approvals.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">
|
||
{ev.approvals.map(a => (
|
||
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
|
||
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
|
||
</span>
|
||
<span className="mx-2">→</span>
|
||
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
|
||
{PurchaseEvaluationPhaseLabel[a.toPhase]}
|
||
</span>
|
||
</div>
|
||
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
|
||
</div>
|
||
<div className="mt-1 text-xs text-slate-500">
|
||
{a.approverName ?? 'Hệ thống'}{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,
|
||
})
|
||
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải…</p>
|
||
if (!logs.data || logs.data.length === 0) return <p className="text-sm text-slate-500">Chưa có lịch sử.</p>
|
||
return (
|
||
<ol className="space-y-1.5 text-sm">
|
||
{logs.data.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>{l.userName ?? 'Hệ thống'}</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 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))
|
||
}
|
||
}
|
||
|
||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const f = e.target.files?.[0]
|
||
if (f) upload.mutate(f)
|
||
e.target.value = ''
|
||
}
|
||
|
||
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" />
|
||
<button
|
||
onClick={() => download(a)}
|
||
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
|
||
title={a.fileName}
|
||
>
|
||
{a.fileName}
|
||
</button>
|
||
<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>
|
||
{!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>
|
||
))}
|
||
{!readOnly && (
|
||
<div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
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 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))
|
||
}
|
||
}
|
||
|
||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const f = e.target.files?.[0]
|
||
if (f) upload.mutate(f)
|
||
e.target.value = ''
|
||
}
|
||
|
||
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" />
|
||
<button
|
||
onClick={() => download(a)}
|
||
className="min-w-0 flex-1 truncate text-left font-medium text-slate-800 hover:text-brand-700 hover:underline"
|
||
title={a.fileName}
|
||
>
|
||
{a.fileName}
|
||
</button>
|
||
<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>
|
||
{!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>
|
||
)}
|
||
{!readOnly && (
|
||
<div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
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>
|
||
)
|
||
}
|