[CLAUDE] PurchaseEvaluation: Mig 54 giá đề xuất PRO/CCM + CEO chọn giá chốt + CCM duyệt-done ô-tích
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s
Theo note anh Kiệt FDC (go-live so-sánh-giá thứ Hai): - (1) Giá chào thầu thêm giá đề xuất NGOÀI giá NCC: PRO nhập dải Min/Max + CCM nhập 1 giá (2 lệnh role-gate Procurement/CostControl, fail-closed). Khi duyệt cấp cuối, người duyệt CHỌN 1 giá chốt (Ncc/ProMin/ProMax/Ccm) -> luu ApprovedPriceAmount/Source (bind tai moi nhanh DaDuyet, bat buoc chon; auto-approve he thong mien). - (3) CCM duyet-done mien CEO: DOI tu AUTO-threshold (S69) sang O-TICH-TAY (finalizeByCcmDelegation) -- CCM chu dong tich, fail-closed theo nguong + role + gia goi. An toan hon (khong vo tinh bo CEO). - Mig 54 additive-nullable (5 cot PE) - FE 2 app SHA-mirror - test 306->334 (+28: opt-in 6->11, +10 gia chot, +13 setter authz). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -40,6 +40,10 @@ export function PeWorkflowPanel({
|
||||
// Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối. Default false (admin opt-in
|
||||
// per slot tick → checkbox visible trong dialog Approve, default unchecked).
|
||||
const [skipToFinalApprover, setSkipToFinalApprover] = useState(false)
|
||||
// [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" + ① người duyệt
|
||||
// cuối chọn 1 giá chốt (Ncc / ProMin / ProMax / Ccm).
|
||||
const [finalizeByCcm, setFinalizeByCcm] = useState(false)
|
||||
const [approvedPriceSource, setApprovedPriceSource] = useState<string | null>(null)
|
||||
const qc = useQueryClient()
|
||||
const { user: currentUser } = useAuth()
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
@ -81,6 +85,27 @@ export function PeWorkflowPanel({
|
||||
const isV2Pending = !!evaluation.currentApproval
|
||||
const blockedByV2Level = isV2Pending && !actorInV2Level
|
||||
|
||||
// [Mig 54] ③ CCM duyệt-done miễn CEO: chỉ đủ điều kiện khi user là CCM (CostControl) +
|
||||
// workflow có ngưỡng CEO + giá gói < ngưỡng (khớp guard fail-closed BE). ① bộ chọn giá
|
||||
// chốt hiện khi đây là duyệt CUỐI (Cấp cuối Bước cuối đang "Current") HOẶC CCM tích done.
|
||||
const isCcm = currentUser?.roles?.includes('CostControl') ?? false
|
||||
const ccmDelegationEligible = isCcm
|
||||
&& evaluation.ceoApprovalThreshold != null
|
||||
&& evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold
|
||||
const flowStepsForFinal = evaluation.approvalFlow?.steps ?? []
|
||||
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
|
||||
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
|
||||
const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
|
||||
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
|
||||
const priceCandidates = ([
|
||||
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
|
||||
{ source: 'ProMin', label: 'PRO — Giá Min', amount: evaluation.proSuggestedMinPrice },
|
||||
{ source: 'ProMax', label: 'PRO — Giá Max', amount: evaluation.proSuggestedMaxPrice },
|
||||
{ source: 'Ccm', label: 'CCM đề xuất', amount: evaluation.ccmSuggestedPrice },
|
||||
] as { source: string; label: string; amount: number | null | undefined }[])
|
||||
.filter((c): c is { source: string; label: string; amount: number } => c.amount != null)
|
||||
const selectedPriceAmount = priceCandidates.find(c => c.source === approvedPriceSource)?.amount ?? null
|
||||
|
||||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
|
||||
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
|
||||
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({
|
||||
@ -110,6 +135,8 @@ export function PeWorkflowPanel({
|
||||
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
|
||||
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
|
||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done.
|
||||
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm)
|
||||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||
targetPhase: target,
|
||||
decision: isReject ? 2 : 1,
|
||||
@ -120,6 +147,10 @@ export function PeWorkflowPanel({
|
||||
// Mig 31 (S23 t1) — F2 Approver scope ChoDuyet duyệt thẳng Cấp cuối.
|
||||
// BE check matchingLevel.AllowApproverSkipToFinal (admin opt-in per slot).
|
||||
skipToFinal: !isReject && skipToFinalApprover,
|
||||
// [Mig 54 2026-06-18] ③ CCM duyệt done miễn CEO + ① giá chốt người duyệt chọn.
|
||||
finalizeByCcmDelegation: !isReject && finalizeByCcm,
|
||||
approvedPriceAmount: sendPrice ? selectedPriceAmount : null,
|
||||
approvedPriceSource: sendPrice ? approvedPriceSource : null,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -132,6 +163,8 @@ export function PeWorkflowPanel({
|
||||
setReturnMode(WorkflowReturnMode.Drafter)
|
||||
setReturnTargetUserId(null)
|
||||
setSkipToFinalApprover(false)
|
||||
setFinalizeByCcm(false)
|
||||
setApprovedPriceSource(null)
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
@ -322,6 +355,10 @@ export function PeWorkflowPanel({
|
||||
: isSendBack
|
||||
? '← Trả lại Drafter sửa'
|
||||
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
|
||||
// [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn.
|
||||
const isApproveAction = !isCancel && !isSendBack
|
||||
const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm)
|
||||
const priceMissing = shouldPickPrice && priceCandidates.length > 0 && !approvedPriceSource
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@ -329,7 +366,7 @@ export function PeWorkflowPanel({
|
||||
title={dialogTitle}
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
|
||||
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
|
||||
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
|
||||
</>}
|
||||
>
|
||||
{isCancel && (
|
||||
@ -455,6 +492,53 @@ export function PeWorkflowPanel({
|
||||
vẫn phải ký duyệt thật để phiếu thành "Đã duyệt".
|
||||
</div>
|
||||
)}
|
||||
{/* [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done, miễn CEO" —
|
||||
chỉ hiện khi user là CCM + gói < ngưỡng CEO uỷ quyền. */}
|
||||
{isApproveAction && ccmDelegationEligible && (
|
||||
<div className="mb-3">
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800 hover:bg-emerald-100/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={finalizeByCcm}
|
||||
onChange={e => setFinalizeByCcm(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Duyệt done, miễn CEO (CEO uỷ quyền cho TP CCM)</span>
|
||||
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
|
||||
Gói {fmtMoney(evaluation.winnerQuoteTotal)}đ < ngưỡng CEO {fmtMoney(evaluation.ceoApprovalThreshold ?? 0)}đ — tích để hoàn tất ngay, không cần trình CEO.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
|
||||
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
|
||||
{shouldPickPrice && (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<Label className="text-[12px]">
|
||||
Chọn giá chốt (duyệt theo giá đề xuất) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{priceCandidates.map(c => (
|
||||
<label key={c.source} className="flex items-center gap-2 rounded border border-brand-200 bg-white px-2 py-1.5 text-[12px] hover:bg-brand-50/40">
|
||||
<input
|
||||
type="radio"
|
||||
checked={approvedPriceSource === c.source}
|
||||
onChange={() => setApprovedPriceSource(c.source)}
|
||||
/>
|
||||
<span className="flex-1 text-slate-700">{c.label}</span>
|
||||
<span className="font-mono font-semibold text-slate-900">{fmtMoney(c.amount)}đ</span>
|
||||
</label>
|
||||
))}
|
||||
{priceCandidates.length === 0 && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
|
||||
Chưa có giá nào để chọn — nhập giá đề xuất (PRO/CCM) hoặc chọn NCC thắng thầu trước khi duyệt cuối.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Label>Ghi chú (tùy chọn)</Label>
|
||||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||||
</Dialog>
|
||||
@ -557,3 +641,7 @@ function fmtTime(iso: string): string {
|
||||
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtMoney(n: number): string {
|
||||
return n.toLocaleString('vi-VN')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user