[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
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:
@ -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)}
|
||||
/>
|
||||
|
||||
{/* 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 ?? 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}
|
||||
/>
|
||||
|
||||
{/* 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 ?? 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>
|
||||
{/* 2 — Kỳ 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>
|
||||
{/* 6 — Lũ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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user