[CLAUDE] PurchaseEvaluation: ngan sach goi thau theo Excel anh Kiet - bang tong hop 2 block + nhap theo role PRO/CCM + xoa module Budget cu (Mig 50)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s
- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
@ -40,9 +40,8 @@ import {
|
||||
type PeQuote,
|
||||
type PeSupplier,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||
import { SupplierType, SupplierTypeLabel } from '@/types/master'
|
||||
import type { Paged, Supplier } from '@/types/master'
|
||||
import type { Supplier } from '@/types/master'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
@ -174,9 +173,10 @@ export function PeDetailTabs({
|
||||
const gia = computeGiaChaoThau(evaluation)
|
||||
if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu")
|
||||
}
|
||||
// 3. Chưa nhập Ngân sách (không link Budget entity VÀ không nhập manual amount)
|
||||
if (evaluation.budgetId == null && (evaluation.budgetManualAmount == null || evaluation.budgetManualAmount <= 0)) {
|
||||
missing.push("Chưa nhập Ngân sách")
|
||||
// 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
|
||||
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
||||
if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
|
||||
missing.push("Chưa nhập Ngân sách kỳ này")
|
||||
}
|
||||
// 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3)
|
||||
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
|
||||
@ -286,12 +286,9 @@ export function PeDetailTabs({
|
||||
? <LevelOpinionsSectionV2 ev={evaluation} />
|
||||
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
|
||||
</Section>
|
||||
{/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" cho phép Drafter
|
||||
(Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC Admin
|
||||
sửa Budget link / Manual amount. BE PATCH /budget-adjust riêng. */}
|
||||
<Section title="5. Điều chỉnh ngân sách">
|
||||
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
{/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA:
|
||||
module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3
|
||||
thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
|
||||
</div>
|
||||
|
||||
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
|
||||
@ -680,9 +677,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
|
||||
diaDiem: diaDiem || null,
|
||||
moTa: moTa || null,
|
||||
paymentTerms: paymentTerms || null,
|
||||
budgetId: ev.budgetId,
|
||||
budgetManualName: ev.budgetManualName,
|
||||
budgetManualAmount: ev.budgetManualAmount,
|
||||
// S61 — module Budget cũ XÓA HẲN; PE giữ 2 ô ngân sách mới (echo lại
|
||||
// giá trị hiện tại để PUT update không xóa nhầm — drafter sửa qua bảng
|
||||
// TỔNG HỢP NGÂN SÁCH / PATCH budget-adjust).
|
||||
budgetPeriodAmount: ev.budgetPeriodAmount,
|
||||
expectedRemainingAmount: ev.expectedRemainingAmount,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -854,325 +853,434 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 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()
|
||||
// ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
|
||||
// Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
|
||||
// BE trả `ev.budgetSummary`. 2 block:
|
||||
// A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
|
||||
// dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
|
||||
// B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
|
||||
// (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
|
||||
// budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
|
||||
|
||||
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link.
|
||||
// Session 20 turn 6: user yêu cầu manual mode chỉ nhập số tiền — bỏ Tên field
|
||||
// khỏi UI. State manualName drop, BE save luôn null cho field này. Data cũ với
|
||||
// tên vẫn hiển thị OK ở read-only display (ev.budgetManualName).
|
||||
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
|
||||
const [manualMode, setManualMode] = useState(initialManual)
|
||||
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
|
||||
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
|
||||
// fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
|
||||
const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
|
||||
const fmtVndSigned = (v: number) =>
|
||||
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
|
||||
const fmtPct = (num: number, denom: number): string | null =>
|
||||
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
|
||||
|
||||
// Eligible budgets — chỉ fetch khi user có khả năng edit
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets', ev.projectId],
|
||||
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})).data.items,
|
||||
enabled: canEdit,
|
||||
})
|
||||
|
||||
// Dirty detect — compare current state vs ev original
|
||||
const dirty = manualMode !== initialManual
|
||||
|| (manualMode && manualAmount !== (ev.budgetManualAmount ?? 0))
|
||||
|| (!manualMode && budgetId !== (ev.budgetId ?? ''))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = manualMode
|
||||
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
|
||||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
await api.put(`/purchase-evaluations/${ev.id}`, {
|
||||
id: ev.id,
|
||||
tenGoiThau: ev.tenGoiThau,
|
||||
diaDiem: ev.diaDiem,
|
||||
moTa: ev.moTa,
|
||||
paymentTerms: ev.paymentTerms,
|
||||
...payload,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã cập nhật ngân sách')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// Read-only mode: chỉ display (không toggle, không edit)
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<FormRow
|
||||
label="b. Ngân sách"
|
||||
value={ev.budget ? (
|
||||
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
|
||||
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
|
||||
{' · '}{ev.budget.tenNganSach}
|
||||
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||
</a>
|
||||
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
|
||||
<span className="text-slate-700">
|
||||
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
|
||||
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
|
||||
{ev.budgetManualAmount != null && (
|
||||
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
|
||||
)}
|
||||
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
|
||||
</span>
|
||||
) : <span className="text-slate-400">—</span>}
|
||||
/>
|
||||
)
|
||||
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
|
||||
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
|
||||
function VndInlineEdit({
|
||||
initial, allowNegative = false, onSave, saving, label,
|
||||
}: {
|
||||
initial: number | null
|
||||
allowNegative?: boolean
|
||||
onSave: (v: number | null) => void
|
||||
saving: boolean
|
||||
label?: string
|
||||
}) {
|
||||
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
|
||||
const [neg, setNeg] = useState((initial ?? 0) < 0)
|
||||
const parse = (): number | null => {
|
||||
const n = parseVnd(text)
|
||||
if (n === 0 && text.trim() === '') return null
|
||||
return allowNegative && neg ? -n : n
|
||||
}
|
||||
|
||||
// Editable mode (canEdit=true)
|
||||
const dirty = parse() !== initial
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualMode}
|
||||
onChange={e => setManualMode(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
/>
|
||||
Nhập tay (không link)
|
||||
</label>
|
||||
{!manualMode ? (
|
||||
<Select
|
||||
value={budgetId}
|
||||
onChange={e => setBudgetId(e.target.value)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{eligibleBudgets.data?.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<div className="relative max-w-xs">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(manualAmount)}
|
||||
onChange={e => setManualAmount(parseVnd(e.target.value))}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right text-sm"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
)}
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{save.isPending ? 'Đang lưu…' : 'Lưu ngân sách'}
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setManualMode(initialManual)
|
||||
setBudgetId(ev.budgetId ?? '')
|
||||
setManualAmount(ev.budgetManualAmount ?? 0)
|
||||
}}
|
||||
className="text-[11px] text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Hủy thay đổi
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{allowNegative && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNeg(v => !v)}
|
||||
className={cn(
|
||||
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
|
||||
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||||
)}
|
||||
title="Đảo dấu âm/dương"
|
||||
>
|
||||
{neg ? '−' : '+'}
|
||||
</button>
|
||||
)}
|
||||
<div className="relative w-40">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
|
||||
placeholder="0"
|
||||
aria-label={label}
|
||||
className="h-7 pr-6 font-mono text-right text-[13px]"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-[11px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onSave(parse())}
|
||||
disabled={!dirty || saving}
|
||||
className="h-7 px-2 text-[11px]"
|
||||
>
|
||||
{saving ? '…' : 'Lưu'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú).
|
||||
// tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50.
|
||||
function BudgetRow({
|
||||
label, sub, value, third, tone, danger, mono = true,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
sub?: React.ReactNode
|
||||
value: React.ReactNode
|
||||
third?: React.ReactNode
|
||||
tone?: 'brand' | 'brand-soft' | 'blue-soft'
|
||||
danger?: boolean
|
||||
mono?: boolean
|
||||
}) {
|
||||
const toneCls =
|
||||
tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
|
||||
: tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
|
||||
: tone === 'blue-soft' ? 'bg-blue-50'
|
||||
: ''
|
||||
return (
|
||||
<div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>{label}</div>
|
||||
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'w-48 shrink-0 text-right tabular-nums',
|
||||
mono && 'font-mono',
|
||||
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
|
||||
)}>
|
||||
{value}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'w-24 shrink-0 text-right text-[11px]',
|
||||
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
|
||||
)}>
|
||||
{third}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section "Điều chỉnh ngân sách" (S22+4 — Feature 2) =====
|
||||
// Cho phép Drafter (DangSoanThao/TraLai) HOẶC Approver currentLevel (ChoDuyet)
|
||||
// HOẶC Admin sửa BudgetId + BudgetManualName + BudgetManualAmount qua endpoint
|
||||
// PATCH /budget-adjust riêng. Audit changelog tự động.
|
||||
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const { user: currentUser } = useAuth()
|
||||
// Block tiêu đề (A / B)
|
||||
function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border-b border-slate-200 bg-slate-100 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const bs = ev.budgetSummary
|
||||
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
|
||||
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|
||||
|| ev.phase === PurchaseEvaluationPhase.TraLai
|
||||
// F4 Approver scope (Mig 30): phase ChoDuyet + actor in currentApproval.approvers
|
||||
// + currentLevel có flag AllowApproverEditBudget=true (admin Designer tick per slot).
|
||||
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
|
||||
const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
|
||||
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
|
||||
&& actorInCurrentLevel
|
||||
&& approverEditBudgetAllowed
|
||||
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
|
||||
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
|
||||
const drafterEditable = !readOnly && isEditablePhase(ev.phase)
|
||||
|
||||
// S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly
|
||||
// pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match +
|
||||
// Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true)
|
||||
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace).
|
||||
const canAdjust = isAdmin
|
||||
|| (!readOnly && isDrafter && isDrafterPhase)
|
||||
|| isApproverChoDuyet
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
}
|
||||
|
||||
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
|
||||
const [manualMode, setManualMode] = useState(initialManual)
|
||||
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
|
||||
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
|
||||
// S59 UAT vòng 4 (anh chốt "chỗ tên ngân sách bỏ đi"): bỏ ô "Tên (không bắt buộc)"
|
||||
// — user không hiểu ý nghĩa; manual budget chỉ còn Số tiền. Tên cũ (phiếu trước)
|
||||
// vẫn hiển thị read-only, sẽ về null khi Lưu điều chỉnh lần tới.
|
||||
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets-adjust', ev.projectId],
|
||||
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})).data.items,
|
||||
enabled: editing && canAdjust,
|
||||
// PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
|
||||
const proMut = useMutation({
|
||||
mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
|
||||
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
|
||||
onSuccess: () => { toast.success('Đã lưu dự trù PRO'); invalidate() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
|
||||
const ccmMut = useMutation({
|
||||
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) =>
|
||||
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
|
||||
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
|
||||
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
|
||||
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
|
||||
const adjustMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = manualMode
|
||||
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
|
||||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
await api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã điều chỉnh ngân sách')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
setEditing(false)
|
||||
},
|
||||
mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
|
||||
api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
|
||||
onSuccess: () => { toast.success('Đã lưu'); invalidate() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// History defer S22+5 — changelog fetch separate endpoint, KHÔNG có trong
|
||||
// PeDetailBundle. UAT user xem ở Panel "Lịch sử thay đổi" thông qua tab History.
|
||||
// proNote inline-edit state (Textarea — không dùng VndInlineEdit)
|
||||
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
|
||||
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
|
||||
|
||||
// Display read mode
|
||||
const displayLink = ev.budget ? (
|
||||
<span>
|
||||
<span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span>
|
||||
{' · '}{ev.budget.tenNganSach}
|
||||
{' · '}<span className="font-semibold text-slate-900">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||
</span>
|
||||
) : (ev.budgetManualAmount != null || ev.budgetManualName) ? (
|
||||
<span>
|
||||
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
|
||||
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
|
||||
{ev.budgetManualAmount != null && (
|
||||
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
|
||||
)}
|
||||
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
|
||||
</span>
|
||||
) : <span className="italic text-slate-400">Chưa có ngân sách</span>
|
||||
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
|
||||
if (!bs) {
|
||||
return (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
|
||||
Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục để dùng ngân sách gói thầu.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Số liệu Excel =====
|
||||
const full = bs.fullAmount
|
||||
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
|
||||
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
|
||||
const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
|
||||
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
|
||||
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
|
||||
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
|
||||
const row7 = full - row5 // Ngân sách còn lại
|
||||
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
|
||||
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
|
||||
|
||||
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 − row4)
|
||||
const cmp56 = row5 - row6 // So với NS (row5 − row6)
|
||||
const cmpFull = full - row9 // So sánh với Ngân sách full (full − row9)
|
||||
|
||||
// Cờ tô màu cảnh báo
|
||||
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
|
||||
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Read mode + Edit toggle */}
|
||||
{!editing && (
|
||||
<div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Wallet className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
<div className="text-sm text-slate-700">{displayLink}</div>
|
||||
</div>
|
||||
{canAdjust && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setManualMode(initialManual)
|
||||
setBudgetId(ev.budgetId ?? '')
|
||||
setManualAmount(ev.budgetManualAmount ?? 0)
|
||||
setEditing(true)
|
||||
}}
|
||||
variant="ghost"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
>
|
||||
<Pencil className="h-3 w-3" /> Điều chỉnh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||||
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
|
||||
Tổng hợp ngân sách trình ký
|
||||
</div>
|
||||
|
||||
{/* Edit mode */}
|
||||
{editing && canAdjust && (
|
||||
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
|
||||
{isApproverChoDuyet && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||
ⓘ Bạn đang điều chỉnh ngân sách lúc phiếu đang duyệt — thay đổi sẽ được ghi vào lịch sử.
|
||||
</div>
|
||||
)}
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualMode}
|
||||
onChange={e => setManualMode(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
{/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
|
||||
<BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
|
||||
|
||||
{/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
|
||||
<BudgetRow
|
||||
tone="brand"
|
||||
label={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
Ngân sách (full gói thầu)
|
||||
{bs.fullIsEstimate && (
|
||||
<span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">dự trù PRO</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
value={fmtVnd(full)}
|
||||
/>
|
||||
|
||||
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách Ban hành lần đầu"
|
||||
value={
|
||||
bs.canEditCcm ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.initialAmount}
|
||||
saving={ccmMut.isPending}
|
||||
label="Ngân sách ban hành lần đầu"
|
||||
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })}
|
||||
/>
|
||||
Nhập tay (không link Budget)
|
||||
</label>
|
||||
{!manualMode ? (
|
||||
<div>
|
||||
<Label className="text-[11px]">Chọn Budget từ danh sách</Label>
|
||||
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)} className="text-sm">
|
||||
<option value="">— (huỷ link)</option>
|
||||
{eligibleBudgets.data?.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-xs">
|
||||
<Label className="text-[11px]">Số tiền (VND)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(manualAmount)}
|
||||
onChange={e => setManualAmount(parseVnd(e.target.value))}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right text-sm"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
|
||||
<BudgetRow
|
||||
label="Ngân sách V0 / hiệu chỉnh tăng giảm"
|
||||
value={
|
||||
bs.canEditCcm ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.adjustmentAmount}
|
||||
allowNegative
|
||||
saving={ccmMut.isPending}
|
||||
label="Ngân sách hiệu chỉnh tăng giảm"
|
||||
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })}
|
||||
/>
|
||||
) : bs.adjustmentAmount != null ? (
|
||||
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
|
||||
) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dòng 4 — Dự trù PRO (PRO editable) */}
|
||||
<BudgetRow
|
||||
label="Dự trù PRO"
|
||||
value={
|
||||
bs.canEditPro ? (
|
||||
<VndInlineEdit
|
||||
initial={bs.proEstimateAmount}
|
||||
saving={proMut.isPending}
|
||||
label="Dự trù PRO"
|
||||
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
|
||||
/>
|
||||
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
|
||||
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
|
||||
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
|
||||
<div className="w-72 shrink-0">
|
||||
{bs.canEditPro ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={proNoteText}
|
||||
onChange={e => setProNoteText(e.target.value)}
|
||||
placeholder="Ghi chú dự trù…"
|
||||
rows={2}
|
||||
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
|
||||
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
|
||||
className="h-6 px-2 text-[11px]"
|
||||
>
|
||||
{proMut.isPending ? '…' : 'Lưu ghi chú'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
|
||||
{bs.proNote || <span className="text-slate-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(false)}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => adjustMut.mutate()}
|
||||
disabled={adjustMut.isPending}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{adjustMut.isPending ? 'Đang lưu…' : 'Lưu điều chỉnh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */}
|
||||
{/* ===== Block B — THỰC HIỆN ===== */}
|
||||
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
|
||||
|
||||
{/* 1 — Ngân sách trình duyệt trước */}
|
||||
<BudgetRow
|
||||
label="1. Ngân sách trình duyệt trước"
|
||||
value={bs.previousSubmittedCount === 0
|
||||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
||||
: fmtVnd(row1)}
|
||||
/>
|
||||
|
||||
{/* 2 — Kỳ trước đã chọn thầu */}
|
||||
<BudgetRow
|
||||
label="2. Kỳ trước đã chọn thầu"
|
||||
value={bs.previousSelectedCount === 0
|
||||
? <span className="font-sans text-slate-400">Chưa chọn</span>
|
||||
: fmtVnd(row2)}
|
||||
/>
|
||||
|
||||
{/* 3 — Ngân sách - kỳ này (drafter editable) + % /full */}
|
||||
<BudgetRow
|
||||
label="3. Ngân sách - kỳ này"
|
||||
value={
|
||||
drafterEditable ? (
|
||||
<VndInlineEdit
|
||||
initial={ev.budgetPeriodAmount}
|
||||
saving={adjustMut.isPending}
|
||||
label="Ngân sách kỳ này"
|
||||
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
|
||||
/>
|
||||
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400">—</span>
|
||||
}
|
||||
third={fmtPct(row3, full) ?? undefined}
|
||||
/>
|
||||
|
||||
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */}
|
||||
<BudgetRow
|
||||
tone="blue-soft"
|
||||
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
|
||||
value={
|
||||
<span className="font-sans text-slate-700">
|
||||
{ev.selectedSupplierName ?? <span className="text-slate-400">— (chưa chọn)</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<BudgetRow
|
||||
tone="blue-soft"
|
||||
label="Giá trị kỳ này"
|
||||
value={
|
||||
proposalOver ? (
|
||||
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
|
||||
) : fmtVnd(row4)
|
||||
}
|
||||
/>
|
||||
<BudgetRow
|
||||
tone="blue-soft"
|
||||
label="So sánh với ngân sách kỳ này"
|
||||
sub="= 3 − 4"
|
||||
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
|
||||
third={fmtPct(cmpPeriod, row3) ?? undefined}
|
||||
danger={cmpPeriod < 0}
|
||||
/>
|
||||
|
||||
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */}
|
||||
<BudgetRow
|
||||
label="5. Lũy kế ngân sách đã sử dụng"
|
||||
sub="= 1 + 3"
|
||||
value={fmtVnd(row5)}
|
||||
/>
|
||||
|
||||
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 − 6) */}
|
||||
<BudgetRow
|
||||
label="6. Lũy kế thực hiện"
|
||||
sub="= 2 + 4"
|
||||
value={fmtVnd(row6)}
|
||||
/>
|
||||
<BudgetRow
|
||||
label="So với NS"
|
||||
sub="= 5 − 6"
|
||||
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
|
||||
third={fmtPct(cmp56, row5) ?? undefined}
|
||||
danger={cmp56 < 0}
|
||||
/>
|
||||
|
||||
{/* 7 — Ngân sách còn lại (= full − 5) + % /full */}
|
||||
<BudgetRow
|
||||
label="7. Ngân sách còn lại"
|
||||
sub="= Ngân sách full − 5"
|
||||
value={fmtVnd(row7)}
|
||||
third={fmtPct(row7, full) ?? undefined}
|
||||
/>
|
||||
|
||||
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */}
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]',
|
||||
remainingOver && 'bg-red-50',
|
||||
)}>
|
||||
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}>
|
||||
8. Giá trị thực hiện dự kiến còn lại
|
||||
<div className="text-[10px] text-slate-400">mặc định = 7 nếu chưa nhập</div>
|
||||
</div>
|
||||
<div className="w-48 shrink-0 text-right">
|
||||
{drafterEditable ? (
|
||||
<VndInlineEdit
|
||||
initial={ev.expectedRemainingAmount}
|
||||
saving={adjustMut.isPending}
|
||||
label="Giá trị thực hiện dự kiến còn lại"
|
||||
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('font-mono tabular-nums', remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
|
||||
{fmtVnd(row8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-24 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
|
||||
<BudgetRow
|
||||
tone="brand"
|
||||
label="9. Giá trị tổng thực hiện dự kiến"
|
||||
sub="= 4 + 8"
|
||||
value={fmtVnd(row9)}
|
||||
/>
|
||||
<BudgetRow
|
||||
tone="brand-soft"
|
||||
label="So sánh với Ngân sách full"
|
||||
sub="= Ngân sách full − 9"
|
||||
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
|
||||
third={fmtPct(cmpFull, full) ?? undefined}
|
||||
danger={cmpFull < 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1197,7 +1305,9 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<NccSelectorRow ev={ev} readOnly={readOnly} />
|
||||
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
||||
{/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
|
||||
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
|
||||
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
|
||||
<FormRow
|
||||
label="c. Giá chào thầu"
|
||||
value={
|
||||
@ -1620,21 +1730,9 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
|
||||
|
||||
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
|
||||
const budgetBundle = useQuery({
|
||||
queryKey: ['budget-detail-for-pe', ev.budgetId],
|
||||
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
|
||||
`/budgets/${ev.budgetId}`)).data,
|
||||
enabled: !!ev.budgetId,
|
||||
})
|
||||
const budgetRowMap = (() => {
|
||||
const m = new Map<string, number>()
|
||||
budgetBundle.data?.details.forEach(d => {
|
||||
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
|
||||
})
|
||||
return m
|
||||
})()
|
||||
const showBudgetCol = !!ev.budgetId
|
||||
// S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
|
||||
// không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
|
||||
// TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -1658,8 +1756,6 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
detail={d}
|
||||
ev={ev}
|
||||
readOnly={readOnly}
|
||||
budgetRowMap={budgetRowMap}
|
||||
showBudgetCol={showBudgetCol}
|
||||
onEditDetail={() => setEditDetail(d)}
|
||||
/>
|
||||
))}
|
||||
@ -1675,13 +1771,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
|
||||
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
|
||||
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
|
||||
function HangMucCard({
|
||||
detail, ev, readOnly, budgetRowMap, showBudgetCol, onEditDetail,
|
||||
detail, ev, readOnly, onEditDetail,
|
||||
}: {
|
||||
detail: PeDetailRow
|
||||
ev: PeDetailBundle
|
||||
readOnly: boolean
|
||||
budgetRowMap: Map<string, number>
|
||||
showBudgetCol: boolean
|
||||
onEditDetail: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
@ -1707,9 +1801,6 @@ function HangMucCard({
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const bgValue = budgetRowMap.get(`${detail.groupCode}|${detail.itemCode ?? ''}`)
|
||||
const delta = bgValue != null ? detail.thanhTienNganSach - bgValue : null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
{/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap +
|
||||
@ -1741,20 +1832,9 @@ function HangMucCard({
|
||||
<span className="ml-1 text-xs font-normal text-slate-500">đ</span>
|
||||
</div>
|
||||
</div>
|
||||
{showBudgetCol && bgValue != null && (
|
||||
<div className="border-l border-slate-200 pl-3">
|
||||
<div className="text-[10px] uppercase text-slate-400">NS link</div>
|
||||
<div className="font-mono text-[11px]">{fmtMoney(bgValue)}</div>
|
||||
<div className={cn(
|
||||
'font-mono text-[10px]',
|
||||
delta! > 0 && 'text-red-600',
|
||||
delta! < 0 && 'text-emerald-600',
|
||||
delta === 0 && 'text-slate-500',
|
||||
)}>
|
||||
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
|
||||
Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
|
||||
trình ký" cấp phiếu (PeBudgetSummaryTable). */}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex flex-shrink-0 gap-1">
|
||||
|
||||
Reference in New Issue
Block a user