[CLAUDE] PurchaseEvaluation: luy ke TAM TINH vs CHINH XAC — ComputePendingAsync + 5-field DTO + FE 2-app sub-dong amber/banner man duyet + D4 audit-note (UAT anh Kiet 15-07, 0 mig, test 520→528)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m27s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
||||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||
// → PeApprovalsSection + PeHistorySection).
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
@ -1181,12 +1181,13 @@ 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 }) {
|
||||
function BudgetSharedNumCell({ value, colSpan, sub }: { value: number | null; colSpan: number; sub?: ReactNode }) {
|
||||
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>}
|
||||
{sub}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@ -1194,8 +1195,8 @@ function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan
|
||||
// [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
|
||||
function BudgetColValue({ value, pct, signed = false, asCell = false, sub }: {
|
||||
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean; sub?: ReactNode
|
||||
}) {
|
||||
const body = value == null
|
||||
? <span className="text-slate-300">—</span>
|
||||
@ -1208,8 +1209,8 @@ function BudgetColValue({ value, pct, signed = false, asCell = false }: {
|
||||
</>
|
||||
)
|
||||
return asCell
|
||||
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
|
||||
: <>{body}</>
|
||||
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}{sub}</td>
|
||||
: <>{body}{sub}</>
|
||||
}
|
||||
|
||||
// [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse
|
||||
@ -1317,6 +1318,24 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
|
||||
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
|
||||
|
||||
// [S134 anh Kiệt FDC] Lũy kế TẠM TÍNH — phần góp từ phiếu cùng gói thầu CHƯA duyệt
|
||||
// (?? guard data cũ chưa có field). Chỉ hiện khi phiếu CHƯA freeze (số DaDuyet là
|
||||
// snapshot bất biến S133 — KHÔNG thêm tạm-tính). N8: row1/5 dùng pendingSubmitted*,
|
||||
// row2/6 dùng pendingSelected*.
|
||||
const pendingSubmittedTotal = bs.pendingSubmittedTotal ?? 0
|
||||
const pendingSubmittedCount = bs.pendingSubmittedCount ?? 0
|
||||
const pendingSelectedTotal = bs.pendingSelectedTotal ?? 0
|
||||
const pendingSelectedCount = bs.pendingSelectedCount ?? 0
|
||||
const pendingPriorPes = bs.pendingPriorPes ?? []
|
||||
const showPending = !bs.budgetFrozen && (pendingSubmittedCount > 0 || pendingSelectedCount > 0 || pendingPriorPes.length > 0)
|
||||
// Sub-dòng amber "Tạm tính" — LUÔN kèm chữ "Tạm tính", KHÔNG bao giờ thay số chính xác.
|
||||
const pendingSub = (base: number, delta: number, count: number, withDetail: boolean) => (
|
||||
<div className="mt-0.5 text-[10px] font-sans italic text-amber-700">
|
||||
Tạm tính: {fmtVnd(base + delta)}
|
||||
{withDetail && ` (+${fmtVnd(delta)} từ ${count} phiếu chưa duyệt)`}
|
||||
</div>
|
||||
)
|
||||
|
||||
// [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).
|
||||
@ -1495,12 +1514,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
{/* 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} />
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(row1, pendingSubmittedTotal, pendingSubmittedCount, true) : undefined} />
|
||||
</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} />
|
||||
<BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2}
|
||||
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row2, pendingSelectedTotal, pendingSelectedCount, true) : undefined} />
|
||||
</tr>
|
||||
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
|
||||
<tr>
|
||||
@ -1573,8 +1594,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
<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} />
|
||||
<BudgetColValue asCell value={pro.r5}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(pro.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
|
||||
<BudgetColValue asCell value={ccm.r5}
|
||||
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(ccm.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
|
||||
</tr>
|
||||
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
|
||||
<tr>
|
||||
@ -1582,7 +1605,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
6. Lũy kế thực hiện
|
||||
<div className="text-[10px] text-slate-400">= 2 + 4</div>
|
||||
</td>
|
||||
<BudgetSharedNumCell colSpan={3} value={row6} />
|
||||
<BudgetSharedNumCell colSpan={3} value={row6}
|
||||
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} />
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
|
||||
@ -1654,6 +1678,28 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* [S134] Danh sách phiếu trước cùng gói thầu CHƯA duyệt (informational — N9:
|
||||
PLAIN TEXT + badge, KHÔNG link vì click phiếu Nháp/chưa-duyệt → 403 S89). */}
|
||||
{showPending && pendingPriorPes.length > 0 && (
|
||||
<div className="border-t border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-6 text-amber-800">
|
||||
<span className="font-medium">Phiếu trước cùng gói chưa duyệt: </span>
|
||||
{pendingPriorPes.map((p, i) => (
|
||||
<span key={p.id}>
|
||||
{i > 0 && ', '}
|
||||
<span className="font-mono">{p.maPhieu ?? '(chưa có mã)'}</span>{' '}
|
||||
<span className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||
p.phase === 10 ? 'bg-amber-100 text-amber-800'
|
||||
: p.phase === 98 ? 'bg-orange-100 text-orange-800'
|
||||
: p.phase === 1 ? 'bg-slate-200 text-slate-700'
|
||||
: 'bg-amber-100 text-amber-800',
|
||||
)}>
|
||||
{PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -130,6 +130,17 @@ export function PeWorkflowPanel({
|
||||
isStepAfterFinalize(stepOrder)
|
||||
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
|
||||
&& finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder)
|
||||
// [S134 anh Kiệt FDC] Phiếu trước cùng gói thầu CHƯA duyệt (informational nudge — KHÔNG
|
||||
// hard-block). M2: banner "cần duyệt phiếu trước" chỉ có nghĩa khi ĐANG chờ duyệt (phase
|
||||
// ChoDuyet); phiếu Nháp/TraLai/TuChoi cũng có pending fill nhưng banner vô nghĩa ở đó.
|
||||
const pendingPriors = evaluation.budgetSummary?.pendingPriorPes ?? []
|
||||
const showPendingBanner = pendingPriors.length > 0
|
||||
&& !evaluation.budgetSummary?.budgetFrozen
|
||||
&& !readOnly
|
||||
&& evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
|
||||
const pendingPriorsText = pendingPriors
|
||||
.map(p => `${p.maPhieu ?? '(chưa có mã)'} (${PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`})`)
|
||||
.join(', ')
|
||||
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
|
||||
// [S117 anh Kiệt FDC] Khi PRO có CẢ Min & Max → thêm ứng viên "duyệt theo khoảng"
|
||||
// (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max].
|
||||
@ -436,6 +447,14 @@ export function PeWorkflowPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* [S134] Banner cảnh báo phiếu trước cùng gói thầu CHƯA duyệt (M2: chỉ khi phase
|
||||
ChoDuyet). Informational — KHÔNG disable nút duyệt. */}
|
||||
{showPendingBanner && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
⚠ {pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} — cần duyệt phiếu trước trước phiếu này.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{next.length > 0 && !readOnly && (
|
||||
<div>
|
||||
<Label className="text-xs">Hành động:</Label>
|
||||
@ -522,6 +541,13 @@ export function PeWorkflowPanel({
|
||||
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
|
||||
</>}
|
||||
>
|
||||
{/* [S134] NHẮC LẠI phiếu trước chưa duyệt TRONG dialog duyệt (trên bộ chọn giá)
|
||||
— người duyệt cuối chắc chắn thấy. KHÔNG đụng disable nút Xác nhận (B3). */}
|
||||
{isApproveAction && showPendingBanner && (
|
||||
<div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
⚠ {pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} — cần duyệt phiếu trước trước phiếu này.
|
||||
</div>
|
||||
)}
|
||||
{isCancel && (
|
||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
⚠ Phiếu sẽ bị khoá hoàn toàn (không edit/transition được nữa). Drafter cần tạo phiếu mới nếu muốn làm lại.
|
||||
|
||||
@ -331,6 +331,13 @@ export type PeBudgetSummary = {
|
||||
previousSelectedCount: number
|
||||
currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn)
|
||||
budgetFrozen: boolean // [S133] true = phiếu DaDuyet, số NS đã chốt snapshot
|
||||
// [S134] Lũy kế TẠM TÍNH — phần góp từ phiếu cùng gói thầu CHƯA duyệt (Phase NOT IN
|
||||
// {DaDuyet, TuChoi}). Cộng dồn để hiển thị số "tạm tính", KHÔNG thay số CHÍNH XÁC.
|
||||
pendingSubmittedTotal: number // delta cộng vào previousSubmittedTotal (row 1/5)
|
||||
pendingSubmittedCount: number
|
||||
pendingSelectedTotal: number // delta cộng vào previousSelectedTotal (row 2/6)
|
||||
pendingSelectedCount: number
|
||||
pendingPriorPes: { id: string; maPhieu: string | null; phase: number }[] | null
|
||||
}
|
||||
|
||||
// Mirror BE PeDepartmentKind enum
|
||||
|
||||
Reference in New Issue
Block a user