[CLAUDE] Contract: K7 SP-002 bridge KHKK->HD hoi-2-khoa + Line.ContractId + grant seeder (gate PASS-WITH-FLAGS 10, F-8 fixed)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
BE: POST {id}/create-contract (Contracts.Create AND KeHoachKyKet.Read) + guard 4-ve IsActive/IsUserSelectable
+ Line.ContractId write-site DAU TIEN + list-DTO ContractIds + SeedContractCreateAccessAsync (Drafter/
Procurement/Admin upgrade-only, cau III default cho ratify). FE x2 app SHA-pair 3/3: nut bridge gate 2-khoa
+ dialog khuon-san + cay GD3/GD4 doc TAP contractIds (union pe.contractId legacy). Test +17 (12 bridge T1-T12
+ 5 authz) suite 639/0; ngoai-le CreateContract trong AuthorizePolicyRegressionTests (#85).
Gate F-8 fixed truoc commit: neo so-dong :2516 chet-luc-land -> neo ten ham (3 cho) + 13/13->12/13 theo do
prod + khai chu-dich CanRead + sua tro-nham-ham. No mang theo (0-blocker): MAJOR-race-2-request-dong-thoi
(chong-trung in-memory, phuong an unique/RowVersion = nhip sau) + DraftData-khong-carry + a11y/O(n.m) FE.
UAT-NOI-TRUOC: nut sang sau restart + user RE-LOGIN (menu cache); acceptance seeder log = 0 added + 2 upgraded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -28,7 +28,9 @@ import {
|
||||
KHKK_APPROVAL_GROUPS,
|
||||
KHKK_PHASE_BADGE,
|
||||
KHKK_PHASE_LABELS,
|
||||
KhkkPhase,
|
||||
khkkGroupMenuLabel,
|
||||
khkkPlanContractIds,
|
||||
type KhkkListItemDto,
|
||||
type PagedResult,
|
||||
} from '@/types/khkk'
|
||||
@ -56,6 +58,10 @@ const KHKK_MENU_KEYS = ['KeHoachKyKet']
|
||||
const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)]
|
||||
const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)]
|
||||
|
||||
// [K7 S167 — vá-4b] Chuông DEV bắn TỐI ĐA 1 LẦN/phiên (buildStages chạy lại theo từng gói
|
||||
// thầu ⇒ không có cờ này thì console ngập). Xem chỗ dùng ở GĐ3.
|
||||
let warnedMissingLineContractIds = false
|
||||
|
||||
export type PipelineLinkage = {
|
||||
canPe: boolean
|
||||
canKhkk: boolean
|
||||
@ -164,7 +170,8 @@ export type BuildStagesOptions = {
|
||||
* GĐ1 = chính các phiếu ấy
|
||||
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo
|
||||
* nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5
|
||||
* GĐ3 = HĐ trong TẬP id của gói (xem forward-provision B3-A5 ở thân hàm)
|
||||
* GĐ3 = HĐ trong TẬP id của gói — hợp của `pe.contractId` (legacy) và contract-id trên
|
||||
* các DÒNG phiếu KHKK (K7 vá-4b; xem thân hàm)
|
||||
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
|
||||
*/
|
||||
export function usePipelineStages() {
|
||||
@ -269,26 +276,41 @@ export function usePipelineStages() {
|
||||
// HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì
|
||||
// biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3.
|
||||
//
|
||||
// [K5 S166 — forward-provision B3-A5] Lấy HĐ của gói bằng một TẬP id gộp từ
|
||||
// NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`. Hôm nay tập ấy chỉ có
|
||||
// một nguồn, nhưng hình dạng đã đúng nên thêm nguồn sau là thêm một dòng.
|
||||
// [K5 S166 — forward-provision B3-A5 · K7 S167 vá-4b ĐÃ CẮM NGUỒN 2] Lấy HĐ của gói
|
||||
// bằng một TẬP id gộp từ NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`.
|
||||
//
|
||||
// 🔴 VÌ SAO CẦN: 1 phiếu có thể đẻ N hợp đồng (liên danh — endpoint
|
||||
// `create-contract` trả `contractIds: string[]`, xem `PeDetailTabs.tsx:2186`),
|
||||
// NHƯNG BE chỉ ghi ngược HĐ ĐẦU vào phiếu:
|
||||
// 🔴 VÌ SAO CẦN: 1 gói có thể đẻ N hợp đồng (liên danh, và từ K7 là N dòng phiếu
|
||||
// KHKK × mỗi lần bấm "Đưa vào HĐ"), NHƯNG phía PE chỉ ghi ngược HĐ ĐẦU:
|
||||
// `CreateContractFromEvaluationFeatures.cs:143-145`
|
||||
// "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog"
|
||||
// ⇒ HĐ thứ 2 trở đi hiện KHÔNG có đường nào để cây nhìn thấy: `PeListItem`
|
||||
// không có `lines[]`, `ContractListItem` không có `purchaseEvaluationId`,
|
||||
// `Contract.cs` cũng không có khoá ngược. Đây là giới hạn DỮ LIỆU, không
|
||||
// phải chỗ này thiếu logic — đừng "sửa" bằng cách bịa field không tồn tại.
|
||||
// ⇒ KHI BE mở đường (một trong hai: list DTO trả `contractIds[]`, hoặc dòng
|
||||
// phiếu có `contractId` riêng), nối vào ngay dưới đây và xoá ghi chú này.
|
||||
// ⇒ đọc mỗi `pe.contractId` thì HĐ thứ 2 trở đi VÔ HÌNH trên cây (spec cụm-3 vá-4).
|
||||
//
|
||||
// 2 nguồn (dedupe bằng `Set` — chúng CỐ Ý chồng nhau ở HĐ đầu tiên):
|
||||
// (1) `pe.contractId` — LEGACY, giữ cho phiếu PE cũ đã bắc cầu bằng đường PE→HĐ.
|
||||
// (2) `khkkPlanContractIds(k)` — HĐ bắc cầu từ TỪNG DÒNG phiếu KHKK của gói. K7 ghi
|
||||
// `Line.ContractId` là NGUỒN THẬT, `pe.ContractId ??=` chỉ còn là fallback hiển
|
||||
// thị ⇒ N HĐ / 1 PE hiện ĐỦ, đúng OG-1 "máy rộng, UI hẹp".
|
||||
const contractIdsOfPackage = new Set<string>()
|
||||
for (const p of peItems) {
|
||||
if (p.contractId) contractIdsOfPackage.add(p.contractId)
|
||||
// ← chỗ cắm nguồn thứ hai (HĐ 2..N). Ví dụ khi có: `p.contractIds?.forEach(...)`
|
||||
// hoặc `p.lines?.forEach(l => l.contractId && add(l.contractId))`.
|
||||
}
|
||||
for (const k of khkkItems) {
|
||||
for (const cid of khkkPlanContractIds(k)) contractIdsOfPackage.add(cid)
|
||||
}
|
||||
// Nguồn (2) IM khi BE chưa trả field trên LIST payload — mà "im vì chưa nối" trông y
|
||||
// hệt "im vì gói chưa có HĐ nào". Nói ra ở DEV thay vì để người sau tưởng cây đã đủ.
|
||||
// Chỉ báo khi có phiếu ĐÃ DUYỆT (chỉ phiếu duyệt rồi mới bắc cầu được) mà KHÔNG phiếu
|
||||
// nào mang field — `[]` (BE đã trả, gói chưa có HĐ) thì im, đúng như phải thế.
|
||||
if (import.meta.env.DEV && !warnedMissingLineContractIds) {
|
||||
const approved = khkkItems.filter(k => k.phase === KhkkPhase.DaDuyet)
|
||||
if (approved.length > 0 && approved.every(k => k.contractIds == null)) {
|
||||
warnedMissingLineContractIds = true
|
||||
console.warn(
|
||||
'[pipeline GĐ3] Phiếu KHKK đã duyệt nhưng payload list KHÔNG có `contractIds` ⇒ cây ' +
|
||||
'chỉ thấy HĐ qua `pe.contractId` (HĐ ĐẦU). API cũ chưa deploy hoặc cache cũ — xem ghi ' +
|
||||
'chú `types/khkk.ts` (K7 vá-4b, BE `…ListItemDto:75`).',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const stage3Leaves: PipelineStage['leaves'] = []
|
||||
@ -296,8 +318,13 @@ export function usePipelineStages() {
|
||||
// `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước.
|
||||
for (const contractId of contractIdsOfPackage) {
|
||||
const c = linkage.contractById.get(contractId)
|
||||
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200.
|
||||
const fallbackName = peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? null
|
||||
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200. [K7 vá-4b] tra CẢ
|
||||
// 2 nguồn: HĐ đến từ dòng phiếu KHKK thì KHÔNG phiếu PE nào trỏ tới nó ⇒ chỉ tra
|
||||
// `peItems` sẽ rơi về `null` và dòng phụ trống trơn.
|
||||
const fallbackName =
|
||||
peItems.find(p => p.contractId === contractId)?.tenGoiThau
|
||||
?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau
|
||||
?? null
|
||||
if (linkage.canContract) {
|
||||
stage3Leaves.push({
|
||||
id: contractId,
|
||||
|
||||
@ -12,7 +12,8 @@ import { useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft, ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||||
ArrowLeft, ClipboardList, FileCheck, FileSignature, Paperclip, Pencil, Plus, Save, Trash2,
|
||||
Upload, Users,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
@ -20,17 +21,21 @@ import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { usePermission } from '@/hooks/usePermission'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { ContractTypeLabel } from '@/types/forms'
|
||||
import {
|
||||
DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS,
|
||||
DossierItemKind, DossierItemStatus, KHKK_APPROVAL_GROUP_LABELS,
|
||||
KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||
KhkkAttachmentPurpose, KhkkPhase,
|
||||
type CreateContractFromKhkkInput, type CreateContractFromKhkkResult,
|
||||
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
|
||||
type UpsertKhkkDossierItemInput,
|
||||
} from '@/types/khkk'
|
||||
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
|
||||
@ -123,6 +128,18 @@ export function KhkkDetailPage() {
|
||||
const [ghiChu, setGhiChu] = useState('')
|
||||
const [hoSoLink, setHoSoLink] = useState('')
|
||||
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
||||
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||
|
||||
// [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.
|
||||
// Chữ ký hook là `can(menuKey, action)` (`hooks/usePermission.ts:17`) — không có dạng
|
||||
// `usePermission('X','Y')`.
|
||||
const { can } = usePermission()
|
||||
const canReadPlan = can('KeHoachKyKet', 'Read')
|
||||
const canCreateContract = can('Contracts', 'Create')
|
||||
|
||||
const plan = useQuery({
|
||||
queryKey: ['khkk-detail', id],
|
||||
@ -276,6 +293,23 @@ export function KhkkDetailPage() {
|
||||
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 ?? '')
|
||||
@ -311,6 +345,21 @@ export function KhkkDetailPage() {
|
||||
>
|
||||
{KHKK_PHASE_LABELS[phase]}
|
||||
</span>
|
||||
{/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `<span title>` 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 && (
|
||||
<span title={bridgeBlockReason ?? 'Tạo hợp đồng từ một dòng của kế hoạch này'}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={bridgeBlockReason != null}
|
||||
onClick={() => setBridgeOpen(true)}
|
||||
>
|
||||
<FileSignature className="mr-2 h-4 w-4" />
|
||||
Đưa vào HĐ
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
{canDeletePlan && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -467,7 +516,21 @@ export function KhkkDetailPage() {
|
||||
<tbody>
|
||||
{k.lines.map((l) => (
|
||||
<tr key={l.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-3 py-2 font-medium text-brand-800">{l.supplierName ?? '—'}</td>
|
||||
<td className="px-3 py-2 font-medium text-brand-800">
|
||||
{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 && (
|
||||
<a
|
||||
href={`/contracts/${l.contractId}`}
|
||||
className="ml-2 text-[10px] font-medium text-brand-600 hover:underline"
|
||||
title="Dòng này đã được đưa vào hợp đồng — bấm để mở HĐ"
|
||||
>
|
||||
✓ HĐ
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-700">
|
||||
{l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
@ -713,6 +776,245 @@ export function KhkkDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* [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 && (
|
||||
<CreateContractFromKhkkDialog plan={k} onClose={() => setBridgeOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===================== 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<string | null>(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<CreateContractFromKhkkResult>(
|
||||
`/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 (
|
||||
<Dialog
|
||||
open
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
title="Đưa dòng kế hoạch vào Hợp đồng"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Huỷ
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => create.mutate()}
|
||||
disabled={create.isPending || !lineId || !workflowId}
|
||||
>
|
||||
{create.isPending ? 'Đang tạo...' : 'Tạo hợp đồng'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-900">
|
||||
<div className="font-semibold">Tạo hợp đồng từ kế hoạch “{plan.maKeHoach ?? 'chưa cấp mã'}”</div>
|
||||
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
|
||||
<li>Mỗi lần bấm tạo <strong>1 hợp đồng cho 1 dòng</strong> (1 nhà cung cấp). Liên danh nhiều NCC thì làm lần lượt.</li>
|
||||
<li>Giá trị HĐ lấy đúng <strong>giá duyệt đã chốt</strong> của dòng — không lấy giá đề xuất.</li>
|
||||
<li>HĐ mới ở trạng thái <strong>Nháp</strong> và đi theo quy trình duyệt bạn chọn dưới đây.</li>
|
||||
<li>Dòng đã đưa vào HĐ thì <strong>không đưa lần hai</strong> — cần sửa thì xử lý ở chính hợp đồng đó.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Chọn dòng đưa vào hợp đồng</Label>
|
||||
{plan.lines.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Phiếu chưa có dòng nhà cung cấp nào.</div>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-1.5">
|
||||
{plan.lines.map((l) => {
|
||||
const done = l.contractId != null
|
||||
const noPrice = l.approvedAmount == null
|
||||
const disabled = done || noPrice
|
||||
return (
|
||||
<li key={l.id}>
|
||||
<label
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm',
|
||||
disabled && 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400',
|
||||
!disabled && lineId === l.id && 'border-brand-400 bg-brand-50',
|
||||
!disabled && lineId !== l.id && 'border-slate-200 hover:bg-slate-50',
|
||||
)}
|
||||
title={
|
||||
done
|
||||
? 'Dòng này đã có hợp đồng.'
|
||||
: noPrice
|
||||
? 'Dòng chưa có giá duyệt chốt — phiếu phải qua bước chốt giá.'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="khkk-bridge-line"
|
||||
checked={lineId === l.id}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
setLineId(l.id)
|
||||
setTenHopDongOverride(null)
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium">{l.tenHangMuc ?? '(chưa gán hạng mục)'}</span>
|
||||
<span className="text-slate-500"> · {l.supplierName ?? '—'}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{formatVnd(l.approvedAmount)}</span>
|
||||
{done && (
|
||||
<a
|
||||
href={`/contracts/${l.contractId}`}
|
||||
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline"
|
||||
>
|
||||
Đã có HĐ
|
||||
</a>
|
||||
)}
|
||||
{!done && noPrice && (
|
||||
<span className="shrink-0 text-[11px]">Chưa có giá chốt</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="bridge-type">Loại hợp đồng</Label>
|
||||
<Select
|
||||
id="bridge-type"
|
||||
value={contractType}
|
||||
onChange={(e) => setContractType(Number(e.target.value))}
|
||||
>
|
||||
{Object.entries(ContractTypeLabel).map(([v, label]) => (
|
||||
<option key={v} value={v}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="bridge-workflow">Quy trình duyệt hợp đồng</Label>
|
||||
<Select
|
||||
id="bridge-workflow"
|
||||
value={workflowId}
|
||||
onChange={(e) => setWorkflowId(e.target.value)}
|
||||
disabled={workflows.isLoading || noWorkflow}
|
||||
>
|
||||
<option value="">— Chọn quy trình —</option>
|
||||
{(workflows.data ?? []).map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.code} v{w.version} — {w.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{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.
|
||||
<p className="mt-1 text-[11px] text-red-700">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="bridge-name">Tên hợp đồng</Label>
|
||||
<Input
|
||||
id="bridge-name"
|
||||
value={tenHopDong}
|
||||
onChange={(e) => setTenHopDongOverride(e.target.value)}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-slate-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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -189,6 +189,17 @@ export interface KhkkListItemDto {
|
||||
totalApprovedAmount: number | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
// [K7 S167 — vá-4b] TẬP id HĐ đã sinh ra từ CÁC DÒNG của phiếu này.
|
||||
// ✅ KHỚP BE: `ContractSigningPlanListItemDto:75 List<Guid> ContractIds` (lane BE K7 vá-4a,
|
||||
// append-CUỐI) ⇒ JSON `contractIds`. Đối chiếu đã ghi ở `sub-implfe-k7.md` mục KHỚP-DTO.
|
||||
// VÌ SAO CẦN: cây 4 folder phải hiện ĐỦ N hợp đồng của một gói, mà `pe.contractId` chỉ
|
||||
// giữ HĐ ĐẦU (`CreateContractFromEvaluationFeatures.cs:143-145`) — HĐ thứ 2 trở đi
|
||||
// CHỈ có ở dòng phiếu KHKK (`ContractSigningPlanLineDto:74 Guid? ContractId`).
|
||||
// 🔴 KHAI OPTIONAL DÙ BE KHÔNG NULL: payload đang nằm trong cache React Query từ trước
|
||||
// lượt này (và API cũ chưa deploy) KHÔNG có field ⇒ optional để cây bớt nguồn chứ
|
||||
// KHÔNG vỡ; chỗ đọc phân biệt được vắng-field ⟂ mảng-rỗng (xem `usePipelineStages`).
|
||||
// ⇒ Mọi nơi đọc qua `khkkPlanContractIds()` bên dưới — đổi tên chỉ sửa ĐÚNG 1 hàm đó.
|
||||
contractIds?: string[] | null
|
||||
}
|
||||
|
||||
export interface KhkkLineDto {
|
||||
@ -290,6 +301,22 @@ export interface KhkkDetailDto extends KhkkListItemDto {
|
||||
budgetFrozen: boolean
|
||||
}
|
||||
|
||||
// [K7 S167 — vá-4b] Tập id HỢP ĐỒNG mà một phiếu KHKK đã đẻ ra. Gom từ MỌI nguồn CÓ THẬT
|
||||
// trên payload thay vì đọc một ô cố định — hình dạng "một TẬP" là thứ K5 đã chừa sẵn:
|
||||
// (1) `contractIds` — LIST payload (BE `…ListItemDto:75`; vắng khi payload đến từ cache
|
||||
// cũ / API chưa deploy ⇒ nhánh này trả rỗng, KHÔNG ném lỗi);
|
||||
// (2) `lines[].contractId` — DETAIL payload, TỒN TẠI HÔM NAY trên đĩa
|
||||
// (`KhkkDetailDto.lines` + `KhkkLineDto.contractId`, mirror BE `…LineDto:74`).
|
||||
// Trả mảng CÓ THỂ TRÙNG — nơi gọi đang đổ vào `Set` nên không khử trùng hai lần.
|
||||
export function khkkPlanContractIds(
|
||||
plan: KhkkListItemDto & Partial<Pick<KhkkDetailDto, 'lines'>>,
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
for (const id of plan.contractIds ?? []) if (id) out.push(id)
|
||||
for (const line of plan.lines ?? []) if (line.contractId) out.push(line.contractId)
|
||||
return out
|
||||
}
|
||||
|
||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + có NCC trúng thầu).
|
||||
// CỐ Ý là TẬP CON của `ApprovedPeAwaitingPlanDto` BE trả — field thừa bỏ qua runtime.
|
||||
// 🔴 [K2 S164 — OG-7] Vị ngữ "chưa có plan sống" ĐÃ BỎ khỏi picker (`Features.cs:1291-1295`):
|
||||
@ -372,6 +399,28 @@ export interface KhkkTransitionResult {
|
||||
currentApprovalLevelOrder: number | null
|
||||
}
|
||||
|
||||
// ===================== K7 S167 — cầu KHKK → Hợp đồng ================================
|
||||
// 🔴 HỢP-ĐỒNG LITERAL với lane BE K7 (nguồn `sub-invest-fable-b2-cum3.md §K7.1:29-54`,
|
||||
// lane BE viết SONG SONG lượt này — lệch 1 chữ = đứt CÂM, bài W2 6-điểm-đứt):
|
||||
// POST /contract-signing-plans/{id}/create-contract policy **`Contracts.Create`**
|
||||
// (gotcha #85 — key theo TÀI NGUYÊN SINH RA là Contract, KHÔNG phải `KeHoachKyKet.*`)
|
||||
// 201 → { contractId, maHopDong }
|
||||
// `lineIds` là MẢNG vì máy BE gộp-capable (OG-1 "máy rộng"), còn ô chọn hiện gửi ĐÚNG 1
|
||||
// phần tử (OG-1 "giao diện 1-1") ⇒ mở gộp sau = đổi ô chọn, KHÔNG đụng hợp-đồng này.
|
||||
export interface CreateContractFromKhkkInput {
|
||||
lineIds: string[]
|
||||
contractType: number
|
||||
approvalWorkflowId: string
|
||||
tenHopDong?: string | null
|
||||
}
|
||||
|
||||
export interface CreateContractFromKhkkResult {
|
||||
contractId: string
|
||||
// BE gen mã NGAY lúc bắc cầu (khuôn bridge PE `:113`), nhưng khai nullable để lỡ nhánh
|
||||
// nào chưa gen thì FE hiện câu khác chứ không in "undefined" lên toast.
|
||||
maHopDong: string | null
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
|
||||
Reference in New Issue
Block a user