diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 5c605c3..ab08dad 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -1380,6 +1380,128 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: ) } +// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại khối "c. Giá chào thầu". +// PRO nhập dải Min/Max (PUT /suggested-price/pro {minPrice,maxPrice}); CCM nhập 1 +// giá (PUT /suggested-price/ccm {ccmPrice}). Role-gate qua canEditPro/CcmSuggestedPrice +// (BE-computed capability — mirror budget PRO/CCM, KHÔNG ràng phase). Read-only khi +// !canEdit → hiện text giá. Khi DaDuyet + approvedPriceAmount → dòng "Giá chốt duyệt". +const APPROVED_PRICE_SOURCE_LABEL: Record = { + Ncc: 'Giá NCC', + ProMin: 'PRO Min', + ProMax: 'PRO Max', + Ccm: 'CCM', +} +function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { + const qc = useQueryClient() + const invalidate = () => { + qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) + qc.invalidateQueries({ queryKey: ['pe-list'] }) + } + // PRO Min/Max — ABSOLUTE SET cả cặp (mirror budget CCM dual-field: field không đổi + // echo giá trị hiện tại để không bị clear). + const proPriceMut = useMutation({ + mutationFn: async (body: { minPrice: number | null; maxPrice: number | null }) => + api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body), + onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() }, + onError: e => toast.error(getErrorMessage(e)), + }) + const ccmPriceMut = useMutation({ + mutationFn: async (body: { ccmPrice: number | null }) => + api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body), + onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() }, + onError: e => toast.error(getErrorMessage(e)), + }) + + const canEditPro = !readOnly && ev.canEditProSuggestedPrice + const canEditCcm = !readOnly && ev.canEditCcmSuggestedPrice + const hasAnyValue = ev.proSuggestedMinPrice != null + || ev.proSuggestedMaxPrice != null + || ev.ccmSuggestedPrice != null + const approved = ev.phase === PurchaseEvaluationPhase.DaDuyet && ev.approvedPriceAmount != null + + // Ẩn hoàn toàn khi không edit được + chưa có giá nào + chưa chốt → tránh khối rỗng. + if (!canEditPro && !canEditCcm && !hasAnyValue && !approved) return null + + return ( +
+
+ Giá đề xuất (ngoài giá chào thầu) +
+ + {/* PRO — Giá Min / Giá Max */} +
+ Giá đề xuất (PRO) +
+
+ Min + {canEditPro ? ( + proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice })} + /> + ) : ( + + {ev.proSuggestedMinPrice != null ? fmtVnd(ev.proSuggestedMinPrice) : } + + )} +
+
+ Max + {canEditPro ? ( + proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v })} + /> + ) : ( + + {ev.proSuggestedMaxPrice != null ? fmtVnd(ev.proSuggestedMaxPrice) : } + + )} +
+
+
+ + {/* CCM — 1 giá */} +
+ Giá đề xuất (CCM) +
+ {canEditCcm ? ( + ccmPriceMut.mutate({ ccmPrice: v })} + /> + ) : ( + + {ev.ccmSuggestedPrice != null ? fmtVnd(ev.ccmSuggestedPrice) : } + + )} +
+
+ + {/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */} + {approved && ( +
+ Giá chốt duyệt + + {fmtVnd(ev.approvedPriceAmount!)} + {ev.approvedPriceSource && ( + + {APPROVED_PRICE_SOURCE_LABEL[ev.approvedPriceSource] ?? ev.approvedPriceSource} + + )} + +
+ )} +
+ ) +} + // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) ===== function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId @@ -1415,6 +1537,10 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly ) } /> + {/* [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất PRO (Min/Max) + CCM — + NGOÀI giá NCC. Role-gate qua canEditPro/CcmSuggestedPrice (mirror budget, + KHÔNG ràng phase). Khi DaDuyet + approvedPriceAmount → show giá chốt + nguồn. */} +
d. Bản so sánh diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index d13d941..df2975a 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -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(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({ @@ -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 ( - + } > {isCancel && ( @@ -455,6 +492,53 @@ export function PeWorkflowPanel({ vẫn phải ký duyệt thật để phiếu thành "Đã duyệt".
)} + {/* [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 && ( +
+ +
+ )} + {/* [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 && ( +
+ +
+ {priceCandidates.map(c => ( + + ))} + {priceCandidates.length === 0 && ( +
+ 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. +
+ )} +
+
+ )}