[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:
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user