[CLAUDE] PurchaseEvaluation: Section B (THỰC HIỆN) tách 3 cột per-cột kỳ-này (Mig 59, anh Kiệt FDC / CCM)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m8s

Section B "Tổng hợp ngân sách trình ký" tách 3 cột Dự án|PRO|CCM giống Section A —
mỗi cột nhập "NS kỳ này" RIÊNG + tính thực-hiện độc-lập (lũy-kế / còn-lại / % /
so-sánh) theo ngân-sách-full của cột đó:
- PRO: budgetPeriodAmount (có sẵn) đối chiếu proFull
- CCM: ccmBudgetPeriodAmount (MỚI, Mig 59) đối chiếu ccmFull — CCM nhập (canEditCcm)
- Dự án: — (chưa wire, như A)
Số thực-tế dùng chung mọi cột (NCC đề xuất, lũy kế lịch sử). Submit-guard giữ trên
budgetPeriodAmount (không đụng).

Mig 59 AddCcmBudgetPeriodToPurchaseEvaluation (1 cột nullable, no backfill). NEW
endpoint PATCH /budget/ccm-period role-gate Admin|CostControl (fail-closed). FE
Section B rewrite <BudgetRow> list -> <table> 3 cột (colCalc per-cột), 2 app SHA-mirror.
Test 395->402 (+7 authz setter). Build sạch BE + 2 FE. Workflow wf_aafb18fe-d9d
(BE->FE->test + 3-lane review: math PASS, authz PASS, mirror self-gated identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-24 16:06:59 +07:00
parent f1b10e9c33
commit 9b1ee0c3a5
14 changed files with 7121 additions and 397 deletions

View File

@ -1105,50 +1105,8 @@ function VndInlineEdit({
)
}
// 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, indent = false,
}: {
label: React.ReactNode
sub?: React.ReactNode
value: React.ReactNode
third?: React.ReactNode
tone?: 'brand' | 'brand-soft' | 'blue-soft'
danger?: boolean
mono?: boolean
/** [S77 Tra Sol] mục con (không có số) → thụt dòng + gạch đầu dòng phân biệt với mục cha có số. */
indent?: 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={cn('min-w-0 flex-1', indent && 'pl-5')}>
<div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>
{indent && <span className="mr-1 text-slate-400"></span>}{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>
)
}
// [Mig 59] BudgetRow (1-cột flex) GỠ — Section B nay là <table> 3 cột (DỰ ÁN|PRO|CCM)
// mirror Section A. Dòng/ô dùng BudgetSharedNumCell / BudgetColValue / BudgetColCompareCell.
// Block tiêu đề (A / B)
function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
@ -1252,6 +1210,50 @@ function BudgetNoteCell({ editable, value, setValue, savedValue, saving, onSave
)
}
// [Mig 59] Ô số CHUNG mọi cột (Section B THỰC HIỆN) — 1 giá trị span hết 3 cột Dự án/PRO/CCM
// (dòng 1/2/6 không tách cột). value null → "Chưa chọn".
function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan: number }) {
return (
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
{value == null
? <span className="font-sans text-slate-400">Chưa chọn</span>
: <span className={cn(value < 0 && 'font-semibold text-red-600')}>{fmtVnd(value)}</span>}
</td>
)
}
// [Mig 59] Ô giá trị 1 cột (PRO/CCM) Section B — display read-only. value null → "—".
// signed → fmtVndSigned (âm trong ngoặc). pct → dòng % phụ dưới. asCell bọc <td> sẵn
// (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào <td> editable dòng 3).
function BudgetColValue({ value, pct, signed = false, asCell = false }: {
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean
}) {
const body = value == null
? <span className="text-slate-300"></span>
: (
<>
<span className={cn('font-mono text-[13px] tabular-nums', value < 0 ? 'font-semibold text-red-600' : 'text-slate-900')}>
{signed ? fmtVndSigned(value) : fmtVnd(value)}
</span>
{pct && <div className="text-[10px] text-slate-400">{pct}</div>}
</>
)
return asCell
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
: <>{body}</>
}
// [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse
// fmtCompareValue); âm → đỏ. pct dòng phụ. Luôn bọc <td>.
function BudgetColCompareCell({ value, base, pct }: { value: number; base: number; pct: string | null }) {
return (
<td className={cn('border border-slate-300 px-2 py-1.5 text-right align-top font-mono tabular-nums', value < 0 && 'font-semibold text-red-600')}>
{fmtCompareValue(value, base)}
{pct && <div className="text-[10px] font-sans font-normal text-slate-400">{pct}</div>}
</td>
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient()
const bs = ev.budgetSummary
@ -1292,6 +1294,15 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
onSuccess: () => { toast.success('Đã lưu'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// [Mig 59 anh Kiệt FDC / CCM] PATCH /budget/ccm-period — "NS kỳ này" cột CCM (Section B
// tách 3 cột). Role-gate Admin|CostControl (BE Forbidden nếu khác). Absolute-set (null=
// clear). Gate FE qua bs.canEditCcm (BE-computed capability, mirror ô NS Block A).
const ccmPeriodMut = useMutation({
mutationFn: async (body: { ccmBudgetPeriodAmount: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget/ccm-period`, body),
onSuccess: () => { toast.success('Đã lưu NS kỳ này (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
@ -1309,6 +1320,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount)
useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount])
useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// [Mig 59] Live-recompute cột CCM: giữ "NS kỳ này (CCM)" đang gõ → row5/7/9 + So sánh
// cột CCM nhảy ngay. Default ← bs.initialAmount (NS Ban hành lần đầu CCM) khi chưa nhập.
const [draftCcmRow3, setDraftCcmRow3] = useState<number | null>(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null)
useEffect(() => { setDraftCcmRow3(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null) }, [ev.ccmBudgetPeriodAmount, bs?.initialAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) {
@ -1320,30 +1335,46 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
}
// ===== Số liệu Excel =====
const full = bs.fullAmount
// [S76] Full mỗi cột (ma trận A) = ban hành + hiệu chỉnh của cột đó.
// [Mig 59] Bỏ `full` đơn-cột (bs.fullAmount cũ) — Section A+B nay tách 3 cột, mỗi cột
// dùng full RIÊNG = ban hành + hiệu chỉnh của cột đó (proFull / ccmFull dưới).
const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 0)
const ccmFull = (bs.initialAmount ?? 0) + (bs.adjustmentAmount ?? 0)
// Cột "có dữ liệu" = đã nhập ban hành HOẶC hiệu chỉnh → hiện full (kể cả 0/âm); else "—".
const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
// ===== Số THỰC-TẾ dùng CHUNG mọi cột (KHÔNG per-cột) =====
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 = draftRow3 ?? 0 // Ngân sách - kỳ này (drafter, live)
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 = draftRow8 ?? row7 // Giá trị thực hiện dự kiến còn lại (live)
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)
// [Mig 59] Mỗi cột tính ĐỘC-LẬP theo "NS kỳ này" + "full" của CỘT đó. Helper trả về
// bộ row3/5/7/8/9 + 3 so-sánh cho 1 cột. periodAmount = NS kỳ này (live); colFull =
// full cột; remaining = giá trị TH dự kiến còn lại (null → default row7).
const colCalc = (periodAmount: number | null, colFull: number, remaining: number | null) => {
const r3 = periodAmount ?? 0
const r5 = row1 + r3 // Lũy kế NS đã dùng (= 1 + 3 cột)
const r7 = colFull - r5 // NS còn lại (= full cột 5 cột)
const r8 = remaining ?? r7 // Giá trị TH dự kiến còn lại (default = 7 cột)
const r9 = row4 + r8 // Giá trị tổng TH dự kiến (= 4 + 8 cột)
return {
r3, r5, r7, r8, r9,
cmpPeriod: r3 - row4, // So với NS kỳ này (= 3 cột 4)
cmp56: r5 - row6, // So với NS (= 5 cột 6)
cmpFull: colFull - r9, // So với NS full (= full cột 9 cột)
}
}
// Cột PRO: kỳ này = budgetPeriodAmount (live draftRow3) · full = proFull · row8 editable
// (draftRow8 ↔ expectedRemainingAmount, drafter). Cột CCM: kỳ này = ccmBudgetPeriodAmount
// (live draftCcmRow3) · full = ccmFull · row8 = default (chưa có field CCM-remaining riêng).
const pro = colCalc(draftRow3, proFull, draftRow8)
const ccm = colCalc(draftCcmRow3, ccmFull, null)
// Cờ tô màu cảnh báo
// Cờ tô màu cảnh báo (cảnh báo MỀM, dựa cột PRO — luồng drafter chính). Hiện banner khi
// PRO HOẶC CCM vượt (đề xuất > NS kỳ này, hoặc tổng TH > NS full).
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = draftRow8 != null && draftRow8 > row7
const remainingOver = draftRow8 != null && draftRow8 > pro.r7
const anyOver = pro.cmpPeriod < 0 || pro.cmpFull < 0 || ccm.cmpPeriod < 0 || ccm.cmpFull < 0
return (
<div className="overflow-hidden rounded-lg border border-slate-300">
@ -1353,8 +1384,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full
(cmpFull<0). Số dư còn lại âm vẫn lưu + gửi duyệt được. */}
{(cmpPeriod < 0 || cmpFull < 0) && (
(cmpFull<0) ở BẤT KỲ cột PRO/CCM. Số dư còn lại âm vẫn lưu + gửi duyệt được. */}
{anyOver && (
<div className="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
<span className="shrink-0 text-sm leading-none"></span>
<span>
@ -1469,148 +1500,186 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
</table>
</div>
{/* ===== Block B — THỰC HIỆN ===== */}
{/* ===== Block B — THỰC HIỆN: BẢNG LƯỚI 3 cột (DỰ ÁN | PRO | CCM) =====
[Mig 59 anh Kiệt FDC / CCM] Mirror style Block A. Mỗi cột tính ĐỘC-LẬP theo "NS kỳ
này" + "full" của cột đó (pro/ccm = colCalc). Số thực-tế (1/2/4/6) dùng CHUNG mọi
cột. "NS kỳ này": PRO editable drafter (VndInlineEdit + /budget-adjust hiện có) ·
CCM editable canEditCcm (VndInlineEdit + PATCH /budget/ccm-period) · DỰ Á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)}
/>
{/* 3Ngâ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 ?? bs.proInitialAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này"
onLiveChange={setDraftRow3}
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.suppliers.some(s => s.isWinner)
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
: <span className="text-slate-400"> (chưa chọn)</span>}
</span>
}
/>
<BudgetRow
tone="blue-soft"
label="Giá trị kỳ này"
indent
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"
indent
sub="= 3 4"
value={fmtCompareValue(cmpPeriod, row3)}
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"
indent
sub="= 5 6"
value={fmtCompareValue(cmp56, row5)}
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={<span className={cn(row7 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(row7)}</span>}
third={fmtPct(row7, full) ?? undefined}
/>
{/* 8Giá 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 ?? row7}
allowNegative
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
<span className={cn('font-mono tabular-nums', row8 < 0 ? 'font-semibold text-red-600' : remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
{fmtVndSigned(row8)}
</span>
)}
</div>
<div className="w-24 shrink-0" />
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr className="bg-slate-100 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
<th className="border border-slate-300 px-3 py-1.5 text-left">Khoản mục</th>
<th className="w-20 border border-slate-300 px-2 py-1.5 text-center">Dự án</th>
<th className="w-44 border border-slate-300 px-2 py-1.5 text-center">PRO</th>
<th className="w-44 border border-slate-300 px-2 py-1.5 text-center">CCM</th>
</tr>
</thead>
<tbody>
{/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">1. Ngân sách trình duyệt trước</td>
<BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1} />
</tr>
{/* 2Kỳ trước đã chọn thầu (CHUNG) */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">2. Kỳ trước đã chọn thầu</td>
<BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2} />
</tr>
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
3. Ngân sách - kỳ này
<div className="text-[10px] text-slate-400">% so với NS full của cột</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
{/* PRO — drafter giữ luồng /budget-adjust hiện có */}
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{drafterEditable ? (
<VndInlineEdit
initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này (PRO)"
onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : (
<BudgetColValue value={ev.budgetPeriodAmount != null ? pro.r3 : null} pct={fmtPct(pro.r3, proFull)} />
)}
{drafterEditable && <div className="mt-0.5 text-[10px] text-slate-400">{fmtPct(pro.r3, proFull) ?? ''}</div>}
</td>
{/* CCM — endpoint MỚI /budget/ccm-period, gate canEditCcm */}
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{bs.canEditCcm ? (
<VndInlineEdit
initial={ev.ccmBudgetPeriodAmount ?? bs.initialAmount}
saving={ccmPeriodMut.isPending || peFetching}
label="Ngân sách kỳ này (CCM)"
onLiveChange={setDraftCcmRow3}
onSave={v => ccmPeriodMut.mutate({ ccmBudgetPeriodAmount: v })}
/>
) : (
<BudgetColValue value={ev.ccmBudgetPeriodAmount != null ? ccm.r3 : null} pct={fmtPct(ccm.r3, ccmFull)} />
)}
{bs.canEditCcm && <div className="mt-0.5 text-[10px] text-slate-400">{fmtPct(ccm.r3, ccmFull) ?? ''}</div>}
</td>
</tr>
{/* 4 — Đề xuất kỳ này (CHUNG): NCC + giá trị + so sánh per-cột */}
<tr className="bg-blue-50">
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">4. Đ xuất kỳ này Tên thầu phụ / NCC</td>
<td className="border border-slate-300 px-2 py-1.5 text-left font-sans text-slate-700" colSpan={3}>
{ev.suppliers.some(s => s.isWinner)
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
: <span className="text-slate-400"> (chưa chọn)</span>}
</td>
</tr>
<tr className="bg-blue-50">
<td className="border border-slate-300 px-3 py-1.5 pl-6 text-slate-700"><span className="mr-1 text-slate-400"></span>Giá trị kỳ này (CHUNG)</td>
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums" colSpan={3}>
{proposalOver
? <span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
: fmtVnd(row4)}
</td>
</tr>
<tr className="bg-blue-50">
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
<span className="mr-1 text-slate-400"></span>So sánh với ngân sách kỳ này
<div className="text-[10px] text-slate-400">= 3 4</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<BudgetColCompareCell value={pro.cmpPeriod} base={pro.r3} pct={fmtPct(pro.cmpPeriod, pro.r3)} />
<BudgetColCompareCell value={ccm.cmpPeriod} base={ccm.r3} pct={fmtPct(ccm.cmpPeriod, ccm.r3)} />
</tr>
{/* 5 — Lũy kế ngân sách đã sử dụng (PER-cột = 1 + 3) */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
5. Lũy kế ngân sách đã sử dụng
<div className="text-[10px] text-slate-400">= 1 + 3</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<BudgetColValue asCell value={pro.r5} />
<BudgetColValue asCell value={ccm.r5} />
</tr>
{/* 6Lũy kế thực hiện (CHUNG = 2 + 4) */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
6. Lũy kế thực hiện
<div className="text-[10px] text-slate-400">= 2 + 4</div>
</td>
<BudgetSharedNumCell colSpan={3} value={row6} />
</tr>
<tr>
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
<span className="mr-1 text-slate-400"></span>So với NS
<div className="text-[10px] text-slate-400">= 5 6</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<BudgetColCompareCell value={pro.cmp56} base={pro.r5} pct={fmtPct(pro.cmp56, pro.r5)} />
<BudgetColCompareCell value={ccm.cmp56} base={ccm.r5} pct={fmtPct(ccm.cmp56, ccm.r5)} />
</tr>
{/* 7 — Ngân sách còn lại (PER-cột = full 5) + % /full cột */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 align-top text-slate-700">
7. Ngân sách còn lại
<div className="text-[10px] text-slate-400">= NS full 5</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<BudgetColValue asCell signed value={pro.r7} pct={fmtPct(pro.r7, proFull)} />
<BudgetColValue asCell signed value={ccm.r7} pct={fmtPct(ccm.r7, ccmFull)} />
</tr>
{/* 8 — Giá trị thực hiện dự kiến còn lại — PRO editable (drafter); CCM default */}
<tr className={cn(remainingOver && 'bg-red-50')}>
<td className={cn('border border-slate-300 px-3 py-1.5 align-top', 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>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<td className="border border-slate-300 px-1.5 py-1.5 text-right align-top">
{drafterEditable ? (
<VndInlineEdit
initial={ev.expectedRemainingAmount ?? pro.r7}
allowNegative
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
<span className={cn('font-mono text-[13px] tabular-nums', pro.r8 < 0 ? 'font-semibold text-red-600' : remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
{fmtVndSigned(pro.r8)}
</span>
)}
</td>
<BudgetColValue asCell signed value={ccm.r8} />
</tr>
{/* 9 — Giá trị tổng thực hiện dự kiến (PER-cột = 4 + 8) — brand đậm */}
<tr className="bg-[#1F7DC1] font-semibold text-white">
<td className="border border-slate-300 px-3 py-1.5 align-top">
9. Giá trị tổng thực hiện dự kiến
<div className="text-[10px] text-white/70">= 4 + 8</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-white/60"></td>
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono font-bold tabular-nums align-middle">
<span className={cn(pro.r9 < 0 && 'text-red-200')}>{fmtVndSigned(pro.r9)}</span>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono font-bold tabular-nums align-middle">
<span className={cn(ccm.r9 < 0 && 'text-red-200')}>{fmtVndSigned(ccm.r9)}</span>
</td>
</tr>
<tr className="bg-[#1F7DC1]/10">
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
<span className="mr-1 text-slate-400"></span>So sánh với Ngân sách full
<div className="text-[10px] text-slate-400">= NS full 9</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<BudgetColCompareCell value={pro.cmpFull} base={proFull} pct={fmtPct(pro.cmpFull, proFull)} />
<BudgetColCompareCell value={ccm.cmpFull} base={ccmFull} pct={fmtPct(ccm.cmpFull, ccmFull)} />
</tr>
</tbody>
</table>
</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={<span className={cn(row9 < 0 && 'font-bold text-red-600')}>{fmtVndSigned(row9)}</span>}
/>
<BudgetRow
tone="brand-soft"
label="So sánh với Ngân sách full"
indent
sub="= Ngân sách full 9"
value={fmtCompareValue(cmpFull, full)}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
</div>
)
}

View File

@ -445,7 +445,11 @@ export type PeDetailBundle = {
createdAt: string
updatedAt: string | null
// S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP):
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) cột PRO — drafter nhập
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] 'NS kỳ này' cột CCM (Section B THỰC HIỆN
// tách 3 cột Dự án|PRO|CCM). PRO=budgetPeriodAmount sẵn có; CCM=cột này độc-lập.
// Nhập qua PATCH /budget/ccm-period role-gate Admin|CostControl (absolute-set, 0 hợp lệ).
ccmBudgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) cột CCM
expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role.