[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

- 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:
pqhuy1987
2026-06-13 01:07:27 +07:00
parent 6db195dd42
commit 79ef8da9f4
70 changed files with 9052 additions and 5956 deletions

View File

@ -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 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
</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">