Chunk 2/3 — mirror y hệt Chunk 1 sang fe-user (rule §3.9). 3 file: ~ components/pe/PeDetailTabs.tsx — InfoTab inline edit + autoEditHeader prop ~ components/pe/PeListPanel.tsx — pencil icon group-hover absolute right ~ pages/pe/PurchaseEvaluationWorkspacePage.tsx — URL editHeader=1 wiring Verify: npm run build fe-user pass · 0 TS error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1570 lines
65 KiB
TypeScript
1570 lines
65 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 { 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,
|
||
PurchaseEvaluationPhase,
|
||
PurchaseEvaluationPhaseColor,
|
||
PurchaseEvaluationPhaseLabel,
|
||
PurchaseEvaluationTypeLabel,
|
||
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 navigate = useNavigate()
|
||
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
|
||
const opinionsReadOnly = readOnly || mode === 'workspace'
|
||
|
||
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>
|
||
<span
|
||
className={cn(
|
||
'rounded px-1.5 py-0.5 text-[11px] font-medium',
|
||
PurchaseEvaluationPhaseColor[evaluation.phase],
|
||
)}
|
||
>
|
||
{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>
|
||
<div className="flex gap-2">
|
||
{isDraft && !readOnly && (
|
||
<>
|
||
<Button variant="ghost" onClick={() => navigate(`/purchase-evaluations/new?id=${evaluation.id}`)} className="gap-1.5 text-xs">
|
||
<Pencil className="h-3.5 w-3.5" /> Sửa header
|
||
</Button>
|
||
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
|
||
<Trash2 className="h-3.5 w-3.5" /> Xóa
|
||
</Button>
|
||
</>
|
||
)}
|
||
<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>
|
||
</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 && isDraft). 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).
|
||
function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boolean; autoEdit: boolean }) {
|
||
const isDraft = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|
||
const canEdit = !readOnly && isDraft
|
||
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 ?? '')
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
// ===== 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 + isDraft (Drafter sửa). Read-only
|
||
// khi pendingMe=1 hoặc phase đã chuyển khỏi DangSoanThao. Empty values hiển
|
||
// thị empty (per user 2026-05-07).
|
||
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||
const isDraft = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|
||
const canEdit = !readOnly && isDraft
|
||
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">
|
||
<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>}
|
||
/>
|
||
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
||
<FormRow
|
||
label="c. Giá chào thầu"
|
||
value={giaChaoThau != null ? (
|
||
<span className="font-semibold text-slate-900">{giaChaoThau.toLocaleString('vi-VN')} đ</span>
|
||
) : <span className="text-slate-400">— (chưa chọn NCC / chưa nhập báo giá)</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 && (
|
||
<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]',
|
||
ev.selectedSupplierId === s.supplierId
|
||
? 'bg-emerald-100 text-emerald-700'
|
||
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
|
||
)}
|
||
title="Chọn NCC thắng"
|
||
>
|
||
<Check className="h-3.5 w-3.5" />
|
||
</button>
|
||
<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>
|
||
<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>
|
||
</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 => (
|
||
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
|
||
{s.displayName ?? 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)
|
||
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',
|
||
q?.isSelected && '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()
|
||
const [form, setForm] = useState({
|
||
bgVat: existing?.bgVat ?? 0,
|
||
chuaVat: existing?.chuaVat ?? 0,
|
||
thanhTien: existing?.thanhTien ?? 0,
|
||
isSelected: existing?.isSelected ?? false,
|
||
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,
|
||
}),
|
||
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)),
|
||
})
|
||
|
||
return (
|
||
<Dialog
|
||
open
|
||
onClose={onClose}
|
||
title={`Báo giá — ${supplierName}`}
|
||
footer={<>
|
||
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={del.isPending}>Xóa</Button>}
|
||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
|
||
</>}
|
||
>
|
||
<div className="space-y-3">
|
||
<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>
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input type="checkbox" checked={form.isSelected} onChange={e => setForm({ ...form, isSelected: e.target.checked })} />
|
||
Chọn NCC này cho hạng mục
|
||
</label>
|
||
<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>
|
||
)
|
||
}
|