// [W2 KHKK — S161 · dựng lại S171] Trang chi tiết "Kế hoạch ký kết HĐ" (GĐ2), route // `/khkk/:id` GIỮ NGUYÊN (deep-link · bookmark · fallback `() const navigate = useNavigate() const qc = useQueryClient() const { user } = useAuth() const [searchParams] = useSearchParams() const [bridgeOpen, setBridgeOpen] = useState(false) // [M-10] Xóa/quay lại KHÔNG được rụng `?group=` — ném người dùng về "tất cả nhóm" là // mất chỗ họ đang đứng. `?group=n` là ĐÚNG URL leaf của nhóm n nên sidebar vẫn sáng đúng. const groupParam = searchParams.get('group') const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const backToList = group !== null ? `/khkk/list?group=${group}` : '/khkk/list' // [K7 S167 — vá-1 vế FE] HỘI 2 KHÓA, đúng bằng server, KHÔNG lỏng hơn: // · `KeHoachKyKet.Read` — cửa đọc chính phiếu này (`ContractSigningPlansController.cs:36`, // `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key ⇒ KHÔNG OR `Khkk_*`, gotcha #85); // · `Contracts.Create` — cửa của endpoint bắc cầu: tài nguyên SINH RA là Contract nên key // phải là của endpoint đích, không phải của màn đang đứng. const { can } = usePermission() const canReadPlan = can('KeHoachKyKet', 'Read') const canCreateContract = can('Contracts', 'Create') const plan = useQuery({ queryKey: ['khkk-detail', id], queryFn: async () => (await api.get(`/contract-signing-plans/${id}`)).data, enabled: !!id, }) const invalidate = () => { qc.invalidateQueries({ queryKey: ['khkk-detail', id] }) qc.invalidateQueries({ queryKey: ['khkk-list'] }) } if (plan.isLoading) { return (
} />
) } if (plan.isError || !plan.data) { return (
} actions={ } />
Không tải được dữ liệu kế hoạch ký kết.
) } const k = plan.data const phase = k.phase as KhkkPhaseValue // [P2-7] `readOnly` là quyết-định của HOST (trang này cũng là một host). const readOnly = !(phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai) || (!!user?.id && k.drafterUserId !== user.id) // [K7 S167] Dòng bắc cầu được = CHƯA có HĐ ∧ ĐÃ có giá duyệt chốt — đúng 2 guard BE. const bridgeableLines = k.lines.filter(l => l.contractId == null && l.approvedAmount != null) const showBridge = phase === KhkkPhase.DaDuyet && canReadPlan // Thiếu vế nào thì NÓI RA vế đó thay vì ẩn nút (khuôn nút-khoá-kèm-title PE `: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 return (
} accent="brand" actions={ <> {k.approvalGroup != null && ( N{k.approvalGroup} )} {KHKK_PHASE_LABELS[phase]} {/* `` 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. */} {showBridge && ( )} } /> {/* Panel duyệt đứng TRƯỚC nội dung — người duyệt thấy việc-phải-làm trước (mirror thứ tự của màn Duyệt NCC). Nút Xóa phiếu nay nằm trong panel này. */} navigate(backToList)} /> {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`. // [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ù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Ờ: `isUserSelectable` (admin ghim cho user chọn) ∧ `isActive` (bản còn // hiệu lực). Picker cũ chỉ lọc 1 cờ ⇒ mời chọn bản đã retire rồi ăn 409 từ guard BE. 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 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 ( } >
Tạo hợp đồng từ kế hoạch “{plan.maKeHoach ?? 'chưa cấp mã'}”
  • Mỗi lần bấm tạo 1 hợp đồng cho 1 dòng (1 nhà cung cấp). Liên danh nhiều NCC thì làm lần lượt.
  • Giá trị HĐ lấy đúng giá duyệt đã chốt của dòng — không lấy giá đề xuất.
  • HĐ mới ở trạng thái Nháp và đi theo quy trình duyệt bạn chọn dưới đây.
  • Dòng đã đưa vào HĐ thì không đưa lần hai — cần sửa thì xử lý ở chính hợp đồng đó.
{plan.lines.length === 0 ? (
Phiếu chưa có dòng nhà cung cấp nào.
) : (
    {plan.lines.map((l) => { const done = l.contractId != null const noPrice = l.approvedAmount == null const disabled = done || noPrice return (
  • ) })}
)}
{noWorkflow && ( // Rỗng-vì-chưa-cấu-hình phải khác câu với rỗng-vì-đang-tải: người dùng cần biết // đây là việc của quản trị, không phải chờ thêm.

Chưa có quy trình duyệt hợp đồng nào được ghim cho người dùng chọn (cần đang hiệu lực và được ghim). Nhờ quản trị mở trong màn Quy trình duyệt rồi quay lại.

)}
setTenHopDongOverride(e.target.value)} maxLength={500} />

Bỏ trống thì hệ thống tự đặt theo tên gói thầu và hạng mục của dòng.

) }