[CLAUDE] PurchaseEvaluation: CCM 3-fix — multi-winner sum + budget=0 hợp lệ + cấp KẾT THÚC quy trình (Mig 58, anh Kiệt FDC)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
CCM (Lưu Trần / anh Kiệt FDC) — làm 3/4 mục (mục 3 "CCM sửa ngân sách" để riêng): 1. Multi-winner: chọn NHIỀU NTP/NCC/ĐVDV cùng trúng → winnerQuoteTotal = SUM ThanhTien MỌI đơn vị isWinner (vd 3 ông 37.8+9+10 = 56.8tr). Cột mới PurchaseEvaluationSupplier.IsWinner (Mig 58, backfill từ SelectedSupplierId). SelectWinner → multi-toggle SupplierIds[]. FE: bảng so sánh tick nhiều ô + dòng "Tổng chi phí đã chọn"; mọi guard/checklist/computeGiaChaoThau/tạo-HĐ đổi sang isWinner (selectedSupplierId = null khi liên-danh ≥2). 2. Ngân sách = 0 hợp lệ: nới submit-guard + 3 validator (chỉ chặn khi NULL, số 0 cố ý đi qua; fallback ProInitial chỉ khi null). Mirror BE + FE. 3. Cấp KẾT THÚC quy trình: ApprovalWorkflowLevel.AllowApproverFinalize (Mig 58) — admin bật per-cấp trong Workflow Designer; duyệt tới cấp đó TỰ DaDuyet, bỏ qua CEO (AUTO theo cấu hình quy trình, không ô-tích runtime — anh Kiệt chốt). FE: banner cảnh báo + bắt chọn giá chốt. Mig 58 AddPeMultiWinnerAndLevelFinalize (2 AddColumn bool + backfill IsWinner, additive/reversible). Test 377→395 (+PeMultiWinnerTests +PeApproverFinalizeTests +budget=0; auto-model gọn bớt 2 test bypass/opt-out). FE 2 app SHA-mirror. Build sạch BE + 2 FE local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -73,14 +73,10 @@ const NCC_PALETTES = [
|
||||
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
|
||||
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
|
||||
function computeGiaChaoThau(ev: PeDetailBundle): number | null {
|
||||
const winnerSupplierRowId = ev.selectedSupplierId
|
||||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
||||
: null
|
||||
if (winnerSupplierRowId === null) return null
|
||||
return ev.details
|
||||
.flatMap(d => d.quotes)
|
||||
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
|
||||
.reduce((sum, q) => sum + q.thanhTien, 0)
|
||||
// [Mig 58] Multi-winner: null khi CHƯA chọn đơn vị nào; else = winnerQuoteTotal (BE
|
||||
// SUM ThanhTien MỌI đơn vị isWinner — single hoặc liên-danh ≥2). 0 = winner chưa báo giá.
|
||||
if (!ev.suppliers.some(s => s.isWinner)) return null
|
||||
return ev.winnerQuoteTotal
|
||||
}
|
||||
|
||||
// Main detail content — flat render 3 section không tabs.
|
||||
@ -194,8 +190,9 @@ export function PeDetailTabs({
|
||||
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
|
||||
const missingForApproval = useMemo(() => {
|
||||
const missing: string[] = []
|
||||
// 1. Chưa chọn Đơn vị NCC/TP
|
||||
if (evaluation.selectedSupplierId == null) {
|
||||
// 1. Chưa chọn Đơn vị NCC/TP ([Mig 58] multi-winner: ≥1 đơn vị isWinner; selectedSupplierId
|
||||
// null khi liên-danh ≥2 nên KHÔNG dùng nó — mirror BE submit-guard IsWinner).
|
||||
if (!evaluation.suppliers.some(s => s.isWinner)) {
|
||||
missing.push("Chưa chọn Đơn vị NCC/TP")
|
||||
} else {
|
||||
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
|
||||
@ -207,8 +204,10 @@ export function PeDetailTabs({
|
||||
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
|
||||
// [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) —
|
||||
// mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được).
|
||||
// [Mig 58 — anh Kiệt FDC] NS = 0 là HỢP LỆ (gói cho-không / vật tư cấp sẵn). Chỉ
|
||||
// block khi CHƯA khai (null). Mirror BE submit-guard nới lỏng cùng đợt.
|
||||
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||
if (effBudget == null || effBudget <= 0) {
|
||||
if (effBudget == null) {
|
||||
missing.push("Chưa nhập Ngân sách kỳ này")
|
||||
}
|
||||
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
|
||||
@ -227,14 +226,14 @@ export function PeDetailTabs({
|
||||
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
|
||||
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
|
||||
const submitChecklist = useMemo(() => {
|
||||
const supplierOk = evaluation.selectedSupplierId != null
|
||||
const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
|
||||
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
|
||||
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
|
||||
return [
|
||||
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
|
||||
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
|
||||
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
|
||||
{ label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 },
|
||||
{ label: 'Ngân sách kỳ này', ok: effBudget != null },
|
||||
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
|
||||
]
|
||||
}, [evaluation])
|
||||
@ -929,30 +928,40 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
|
||||
)
|
||||
}
|
||||
|
||||
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) =====
|
||||
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3
|
||||
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)".
|
||||
// Save dùng POST /pe/:id/select-winner endpoint hiện có.
|
||||
// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] =====
|
||||
// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng
|
||||
// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner).
|
||||
// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`;
|
||||
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner).
|
||||
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||||
const qc = useQueryClient()
|
||||
const setWinner = useMutation({
|
||||
mutationFn: async (supplierId: string | null) =>
|
||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||||
// gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker.
|
||||
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
|
||||
const setWinners = useMutation({
|
||||
mutationFn: async (supplierIds: string[]) =>
|
||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã cập nhật NCC được chọn.')
|
||||
toast.success('Đã cập nhật đơn vị trúng thầu.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
|
||||
const toggle = (supplierId: string, on: boolean) =>
|
||||
setWinners.mutate(on
|
||||
? [...winnerIds, supplierId]
|
||||
: winnerIds.filter(id => id !== supplierId))
|
||||
const busy = setWinners.isPending || peFetching
|
||||
|
||||
if (!canEdit) {
|
||||
const winners = ev.suppliers.filter(s => s.isWinner)
|
||||
return (
|
||||
<FormRow
|
||||
label="a. NCC / TP được chọn"
|
||||
value={ev.selectedSupplierName
|
||||
? <span className="font-medium text-emerald-700">✓ {ev.selectedSupplierName}</span>
|
||||
value={winners.length > 0
|
||||
? <span className="font-medium text-emerald-700">✓ {winners.map(s => s.supplierName).join(', ')}</span>
|
||||
: <span className="text-slate-400">— (chưa chọn)</span>}
|
||||
/>
|
||||
)
|
||||
@ -962,30 +971,49 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||||
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Select
|
||||
value={ev.selectedSupplierId ?? ''}
|
||||
onChange={e => setWinner.mutate(e.target.value || null)}
|
||||
disabled={ev.suppliers.length === 0 || setWinner.isPending}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="">— Chọn NCC từ danh sách Section 3 —</option>
|
||||
{ev.suppliers.map(s => (
|
||||
<option key={s.id} value={s.supplierId}>
|
||||
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
|
||||
{setWinner.isPending && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||
<span>Đang chọn NCC + sync cột giá Section 4…</span>
|
||||
{ev.suppliers.length === 0 ? (
|
||||
<p className="text-[11px] text-amber-600">
|
||||
Thêm NCC ở Section 3 trước rồi mới chọn trúng thầu.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{ev.suppliers.map(s => (
|
||||
<label
|
||||
key={s.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded border px-2 py-1 text-[12px] transition',
|
||||
s.isWinner
|
||||
? 'border-emerald-300 bg-emerald-50 font-medium text-emerald-800'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5"
|
||||
checked={s.isWinner}
|
||||
disabled={busy}
|
||||
onChange={e => toggle(s.supplierId, e.target.checked)}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
||||
</span>
|
||||
<span className="text-[11px] text-emerald-600">{s.isWinner ? 'Trúng thầu' : ''}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ev.suppliers.length === 0 && (
|
||||
<p className="mt-1 text-[11px] text-amber-600">
|
||||
Thêm NCC ở Section 3 trước rồi mới chọn winner.
|
||||
</p>
|
||||
{/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */}
|
||||
{winnerIds.length > 0 && (
|
||||
<div className="mt-1.5 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
|
||||
<span className="text-emerald-700">Tổng chi phí đã chọn ({winnerIds.length} đơn vị):</span>
|
||||
<span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
|
||||
</div>
|
||||
)}
|
||||
{busy && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||
<span>Đang cập nhật trúng thầu + sync cột giá Section 4…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -1483,7 +1511,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
||||
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
|
||||
value={
|
||||
<span className="font-sans text-slate-700">
|
||||
{ev.selectedSupplierName ?? <span className="text-slate-400">— (chưa chọn)</span>}
|
||||
{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>
|
||||
}
|
||||
/>
|
||||
@ -1793,14 +1823,12 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
|
||||
|
||||
// ===== 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
|
||||
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
|
||||
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
|
||||
const winnerSupplierRowId = ev.selectedSupplierId
|
||||
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
|
||||
: null
|
||||
const hasWinner = ev.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
|
||||
const giaChaoThau = computeGiaChaoThau(ev)
|
||||
|
||||
// d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi
|
||||
@ -1819,7 +1847,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
||||
<FormRow
|
||||
label="c. Giá chào thầu"
|
||||
value={
|
||||
winnerSupplierRowId === null ? (
|
||||
!hasWinner ? (
|
||||
<span className="text-slate-400">— (chọn NCC/TP ở (a) trước)</span>
|
||||
) : giaChaoThau === 0 ? (
|
||||
<span className="text-slate-400">— (chưa nhập báo giá ở Section 4)</span>
|
||||
@ -2049,7 +2077,7 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-500">
|
||||
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
|
||||
NCC: <strong>{evaluation.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') || evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
|
||||
</p>
|
||||
<div>
|
||||
<Label>Loại HĐ</Label>
|
||||
@ -2436,12 +2464,19 @@ function HangMucCard({
|
||||
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
const setWinner = useMutation({
|
||||
mutationFn: async (supplierId: string | null) =>
|
||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||||
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||
// [Mig 58 anh Kiệt FDC] Multi-winner: gửi TOÀN BỘ tập ID trúng thầu (chọn ≥2 = liên
|
||||
// danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render.
|
||||
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
|
||||
const setWinners = useMutation({
|
||||
mutationFn: async (supplierIds: string[]) =>
|
||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
|
||||
onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
const toggleWinner = (supplierId: string, on: boolean) =>
|
||||
setWinners.mutate(on
|
||||
? [...winnerIds, supplierId]
|
||||
: winnerIds.filter(id => id !== supplierId))
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
@ -2537,7 +2572,7 @@ function HangMucCard({
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{ev.suppliers.map((s, idx) => {
|
||||
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
|
||||
const isWinner = ev.selectedSupplierId === s.supplierId
|
||||
const isWinner = s.isWinner
|
||||
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
|
||||
const canDelete = !isWinner && !hasQuotes
|
||||
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
|
||||
@ -2606,12 +2641,13 @@ function HangMucCard({
|
||||
<td className="px-2 py-1.5">
|
||||
<div className="flex justify-end gap-0.5">
|
||||
<button
|
||||
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
|
||||
onClick={() => toggleWinner(s.supplierId, !isWinner)}
|
||||
disabled={setWinners.isPending}
|
||||
className={cn(
|
||||
'rounded px-1 py-0.5',
|
||||
'rounded px-1 py-0.5 disabled:opacity-50',
|
||||
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
|
||||
)}
|
||||
title={isWinner ? 'Bỏ chọn đơn vị này (click để hủy)' : 'Chọn đơn vị NCC/TP'}
|
||||
title={isWinner ? 'Bỏ chọn trúng thầu (chọn ≥2 = liên danh)' : 'Chọn trúng thầu (chọn ≥2 = liên danh)'}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@ -110,6 +110,10 @@ export function PeWorkflowPanel({
|
||||
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
|
||||
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
|
||||
const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
|
||||
// [Mig 58 item 4] Cấp Approver hiện tại được cấu hình KẾT THÚC trong Quy trình duyệt
|
||||
// (admin bật per-slot ở Designer). Duyệt tới cấp này = TỰ kết thúc (BE auto theo cờ cấp,
|
||||
// KHÔNG cần ô-tích). FE dùng cờ này để: (1) hiện banner cảnh báo + (2) bắt chọn giá chốt.
|
||||
const approverFinalizeEligible = levelOptions?.allowApproverFinalize === true
|
||||
// Ứ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 },
|
||||
@ -149,8 +153,9 @@ 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)
|
||||
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done
|
||||
// HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá).
|
||||
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
|
||||
// [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi
|
||||
// chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE).
|
||||
// File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side).
|
||||
@ -430,7 +435,7 @@ export function PeWorkflowPanel({
|
||||
: `✓ 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 shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
|
||||
// [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates
|
||||
// rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh
|
||||
// người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge).
|
||||
@ -588,6 +593,16 @@ export function PeWorkflowPanel({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt:
|
||||
BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */}
|
||||
{isApproveAction && approverFinalizeEligible && (
|
||||
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
|
||||
<span className="font-medium">✅ Cấp này KẾT THÚC quy trình</span>
|
||||
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
|
||||
Được cấu hình sẵn trong Quy trình duyệt — bạn duyệt xong là phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt.
|
||||
</span>
|
||||
</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 && (
|
||||
|
||||
@ -51,6 +51,7 @@ type LevelDto = {
|
||||
allowApproverEditDetails: boolean
|
||||
allowApproverEditBudget: boolean
|
||||
allowApproverSkipToFinal: boolean
|
||||
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO)
|
||||
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
|
||||
canEditProBudget: boolean
|
||||
canEditCcmBudget: boolean
|
||||
@ -106,6 +107,7 @@ type EditLevelEntry = {
|
||||
allowApproverEditDetails: boolean
|
||||
allowApproverEditBudget: boolean
|
||||
allowApproverSkipToFinal: boolean
|
||||
allowApproverFinalize: boolean
|
||||
}
|
||||
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
|
||||
|
||||
@ -154,6 +156,7 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||
allowApproverEditDetails: l.allowApproverEditDetails ?? false,
|
||||
allowApproverEditBudget: l.allowApproverEditBudget ?? false,
|
||||
allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false,
|
||||
allowApproverFinalize: l.allowApproverFinalize ?? false,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@ -171,6 +174,7 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
|
||||
allowApproverEditDetails: false,
|
||||
allowApproverEditBudget: false,
|
||||
allowApproverSkipToFinal: false,
|
||||
allowApproverFinalize: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -598,6 +602,7 @@ function Designer({
|
||||
allowApproverEditDetails: e.allowApproverEditDetails,
|
||||
allowApproverEditBudget: e.allowApproverEditBudget,
|
||||
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
|
||||
allowApproverFinalize: e.allowApproverFinalize,
|
||||
})),
|
||||
})),
|
||||
})
|
||||
@ -989,6 +994,17 @@ function Designer({
|
||||
/>
|
||||
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
|
||||
</label>
|
||||
{/* [Mig 58 CCM item 4 — anh Kiệt FDC] Duyệt là KẾT THÚC: NV slot
|
||||
này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương). */}
|
||||
<label className="col-span-2 flex items-center gap-1 text-[11px] text-emerald-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={entry.allowApproverFinalize}
|
||||
onChange={e => updateField('allowApproverFinalize', e.target.checked)}
|
||||
/>
|
||||
<span>Duyệt là KẾT THÚC quy trình (không trình tiếp CEO)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -150,6 +150,10 @@ export type PeSupplier = {
|
||||
paymentTermText: string | null
|
||||
note: string | null
|
||||
order: number
|
||||
// [Mig 58 — anh Kiệt FDC] Multi-winner: đơn vị này có thuộc tập đơn vị trúng thầu
|
||||
// (≥1 đơn vị được chọn) không. Nguồn-sự-thật cho checkbox "Chọn trúng thầu" — KHÔNG
|
||||
// dùng selectedSupplierId (null khi joint ≥2 winner). winnerQuoteTotal = SUM mọi isWinner.
|
||||
isWinner: boolean
|
||||
}
|
||||
|
||||
export type PeQuote = {
|
||||
@ -399,6 +403,7 @@ export type ApprovalWorkflowOptions = {
|
||||
allowApproverEditDetails: boolean
|
||||
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
|
||||
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
|
||||
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO)
|
||||
}
|
||||
|
||||
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
||||
|
||||
Reference in New Issue
Block a user