)
}
const k = plan.data
const phase = k.phase as KhkkPhaseValue
const isDraft = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
const canDeletePlan = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TuChoi
// [K7 S167] Dòng bắc cầu được = CHƯA có HĐ ∧ ĐÃ có giá duyệt chốt — đúng 2 guard BE
// (`§K7.1` guard 4 + 5). Chặn sớm ở FE để người dùng không bấm rồi mới ăn 409.
const bridgeableLines = k.lines.filter(l => l.contractId == null && l.approvedAmount != null)
// Nút chỉ có nghĩa khi phiếu ĐÃ DUYỆT (giá mới chốt) và người dùng đọc được phiếu.
const showBridge = phase === KhkkPhase.DaDuyet && canReadPlan
// Thiếu vế nào thì NÓI RA vế đó thay vì ẩn nút: người dùng đang đứng trên phiếu, ẩn đi họ
// chỉ đoán "hệ thống không có chức năng". Khuôn nút-khoá-kèm-title `PeWorkflowPanel.tsx:547-561`.
const bridgeBlockReason = !canCreateContract
? 'Bạn chưa có quyền "Tạo hợp đồng" (Contracts.Create) — nhờ quản trị cấp quyền rồi thử lại.'
: k.lines.length === 0
? 'Phiếu chưa có dòng nhà cung cấp nào.'
: bridgeableLines.length === 0
? (k.lines.every((l) => l.contractId != null)
? 'Mọi dòng của phiếu đã được đưa vào hợp đồng.'
: 'Chưa dòng nào có giá duyệt chốt — phiếu phải qua bước chốt giá trước khi đưa vào HĐ.')
: null
const startEditHeader = () => {
setGhiChu(k.ghiChu ?? '')
setHoSoLink(k.hoSoLink ?? '')
setEditHeader(true)
}
return (
}
accent="brand"
actions={
<>
{/* [K2 S164] Badge NHÓM DUYỆT — đứng TRƯỚC badge phase vì nhóm là thứ quyết
phiếu đi đường nào (8 quy trình khác nhau), phase chỉ là chỗ đang đứng.
`!= null` guard: phiếu nạp từ cache trước K2 chưa có field ⇒ đừng in
"Nundefined" (bài "vắng-mặt trông giống ổn"). */}
{k.approvalGroup != null && (
N{k.approvalGroup}
)}
{KHKK_PHASE_LABELS[phase]}
{/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `` bọc ngoài vì
`Button` có `disabled:pointer-events-none` (Button.tsx:12) ⇒ title đặt thẳng
trên nút đã khoá sẽ không hiện, người dùng mất luôn câu giải thích. */}
{showBridge && (
)}
{canDeletePlan && (
)}
>
}
/>
{/* [W3 S161] Panel duyệt — đặt NGAY dưới header để người duyệt thấy việc-phải-làm
trước nội dung phiếu (mirror thứ tự PE: workflow panel là thứ đầu tiên trong
màn Duyệt). `onChanged` dùng `invalidate` của trang để refetch theo id URL. */}
{/* Section 1: Thông tin */}
}
accent="var(--color-brand-500)"
head="text-brand-800"
chipBg="var(--color-brand-50)"
chipFg="var(--color-brand-600)"
actions={
isDraft && !editHeader ? (
) : undefined
}
>
)}
{/* Section 2: Dòng giá per-NCC trúng thầu */}
}
accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={k.lines.length}
actions={
// [K2 S164 — QĐ-9] `budgetFrozen` = cờ HIỂN THỊ do BE dẫn xuất từ phase
// (`DetailDto:166`). Số đã đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=`
// tại choke-point finalize — badge này chỉ nói cho người dùng biết điều đó.
k.budgetFrozen ? (
🔒 Giá đã chốt tại thời điểm duyệt
) : undefined
}
>
{k.lines.length === 0 ? (
Phiếu nguồn chưa có NCC trúng thầu.
) : (
Nhà cung cấp
{/* [K2 S164 → S168] Hạng mục SP-002 của dòng — đường GÁN (Lines-editor) land
@S168 (mảnh hẹn-K4b bị rơi, dry-run K8 bắt): Nháp/Trả lại gán tại bảng,
dòng trống KHÔNG trình duyệt được (submit-guard BE). */}
Hạng mục
Giá tham chiếu (phiếu NCC)
Giá đề xuất
Giá duyệt
Ghi chú
{k.lines.map((l) => (
{l.supplierName ?? '—'}
{/* [K7 S167] Dòng đã bắc cầu — khuôn dấu "✓ HĐ" của danh sách PE
(`PurchaseEvaluationsListPage.tsx:599`). Thấy ngay ở bảng chứ không
phải mở dialog mới biết dòng nào đã xong. */}
{l.contractId && (
✓ HĐ
)}
{/* Dòng đã bắc cầu HĐ thì khoá (đổi hạng mục sau khi có HĐ = đổi lịch sử). */}
{isDraft && !l.contractId ? (
<>
{catalogOptions.isError && (
Nhóm duyệt N{k.approvalGroup} chưa có hạng mục đang hiệu lực — liên hệ quản trị danh mục.
)}
>
) : l.tenHangMuc ? (
l.tenHangMuc
) : (
—
)}
{formatVnd(l.peReferenceAmount)}
{formatVnd(l.proposedAmount)}
{formatVnd(l.approvedAmount)}
{l.note ?? '—'}
))}
)}
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
)}
{/* Dialog thêm/sửa căn cứ */}
{/* [K7 S167 — vá-16] Dialog bắc cầu. Render có điều kiện (không dùng prop `open`) để
mỗi lần mở là state mới — khuôn dialog Xóa-phiếu `PeWorkflowPanel.tsx:879`. */}
{bridgeOpen && (
setBridgeOpen(false)} />
)}
)
}
// ===================== K7 S167 — cầu KHKK → Hợp đồng =================================
// [vá-16] 0 KHUÔN MỚI — ghép 2 khuôn đang sống:
// · vỏ dialog + khối "nói thẳng hệ quả" = nút Xóa-phiếu S155 (`PeWorkflowPanel.tsx:879-918`);
// · ô chọn Quy trình duyệt = picker `ContractCreatePage.tsx:333-343`
// (GET `/approval-workflows-v2?applicableType=3` → lọc theo cờ).
// [OG-1 "máy rộng, UI hẹp"] Máy BE nhận `lineIds[]` (gộp được N dòng vào 1 HĐ); ô chọn ở
// đây CỐ Ý 1-1 — mỗi lần bấm đúng 1 dòng. Mở gộp sau = đổi radio thành checkbox, KHÔNG
// đụng hợp-đồng API.
type BridgeWorkflowOption = {
id: string
code: string
version: number
name: string
isActive: boolean
isUserSelectable: boolean
}
function CreateContractFromKhkkDialog({ plan, onClose }: { plan: KhkkDetailDto; onClose: () => void }) {
const navigate = useNavigate()
const qc = useQueryClient()
const bridgeable = plan.lines.filter((l) => l.contractId == null && l.approvedAmount != null)
const [lineId, setLineId] = useState(() => bridgeable[0]?.id ?? '')
const [contractType, setContractType] = useState(1)
const [workflowId, setWorkflowId] = useState('')
// null = "đang dùng tên mặc định theo dòng đang chọn". Giữ null thay vì đồng bộ bằng
// useEffect: đổi dòng thì tên gợi ý đổi theo NGAY trong render, không có nhịp lệch.
const [tenHopDongOverride, setTenHopDongOverride] = useState(null)
const selected: KhkkLineDto | null = plan.lines.find((l) => l.id === lineId) ?? null
const defaultName = selected
? `${plan.peTenGoiThau ?? 'Kế hoạch ký kết'} — ${selected.tenHangMuc ?? selected.supplierName ?? ''}`.trim()
: ''
const tenHopDong = tenHopDongOverride ?? defaultName
const workflows = useQuery({
// Query key RIÊNG (không dùng lại `approval-workflows-v2-contract` của ContractCreatePage):
// cùng URL nhưng LỌC KHÁC ⇒ dùng chung ô cache là để trang này ăn dữ liệu trang kia một
// cách câm (bài S159).
queryKey: ['khkk-bridge-contract-workflows'],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: BridgeWorkflowOption[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: 3 } },
)
const bucket = res.data.types.find((t) => t.applicableType === 3)
// 🔴 LỌC ĐỦ 2 CỜ (spec cụm-3 vá-2): `isUserSelectable` (admin ghim cho user chọn) ∧
// `isActive` (bản còn hiệu lực). Picker cũ `ContractCreatePage.tsx:341` chỉ lọc 1 cờ ⇒
// mời chọn được bản đã retire rồi ăn 409 từ guard BE. Ở đây lọc đủ ngay từ nguồn.
return (bucket?.history ?? []).filter((w) => w.isUserSelectable && w.isActive)
},
})
const create = useMutation({
mutationFn: async () => {
const body: CreateContractFromKhkkInput = {
lineIds: [lineId],
contractType,
approvalWorkflowId: workflowId,
tenHopDong: tenHopDong.trim() || null,
}
return (
await api.post(
`/contract-signing-plans/${plan.id}/create-contract`,
body,
)
).data
},
onSuccess: (data) => {
toast.success(data.maHopDong ? `Đã tạo hợp đồng ${data.maHopDong}` : 'Đã tạo hợp đồng từ kế hoạch')
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
// Cây 4 folder đọc 2 index riêng (`usePipelineStages.ts`) với `staleTime` 60s — không
// gọi 2 dòng này thì HĐ vừa tạo cả phút sau mới hiện trên cây.
qc.invalidateQueries({ queryKey: ['pipeline-khkk-index'] })
qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] })
onClose()
navigate(`/contracts/${data.contractId}`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const noWorkflow = !workflows.isLoading && (workflows.data?.length ?? 0) === 0
return (
)
}