[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

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:
pqhuy1987
2026-08-01 13:04:49 +07:00
parent c97f948a7b
commit aaed699228
13 changed files with 2241 additions and 46 deletions

View File

@ -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
</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Đ"
>
</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 &ldquo;{plan.maKeHoach ?? 'chưa cấp mã'}&rdquo;</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ị lấy đúng <strong>giá duyệt đã chốt</strong> của dòng không lấy giá đ xuất.</li>
<li> mới trạng thái <strong>Nháp</strong> đi theo quy trình duyệt bạn chọn dưới đây.</li>
<li>Dòng đã đưa vào thì <strong>không đưa lần hai</strong> cần sửa thì xử 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 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"
>
Đã
</a>
)}
{!done && noPrice && (
<span className="shrink-0 text-[11px]">Chưa 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 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
đư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 hạng mục của dòng.
</p>
</div>
</div>
</Dialog>
)
}