[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

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:
pqhuy1987
2026-06-24 15:22:35 +07:00
parent ae4f106fd7
commit f1b10e9c33
26 changed files with 7578 additions and 245 deletions

View File

@ -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 </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>

View File

@ -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 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 && (

View File

@ -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 KẾT THÚC quy trình (không trình tiếp CEO)</span>
</label>
</div>
</div>
)

View File

@ -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