Files
solution-erp/fe-admin/src/pages/khkk/KhkkDetailPage.tsx
pqhuy1987 0bfadc2011
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m27s
[CLAUDE] Contract: K8-blocker Lines-editor KHKK — gan hang muc SP-002 cho dong (command hua-trong-comment chua tung land)
- Dry-run K8 bat TRUOC gio chay: auto-gen line moi CatalogEntryId=null + comment hen "UpdateContractSigningPlanLineCommand" (K4b) nhung K4b that ship ?group= => 0 impl/0 route/0 FE; submit-guard va-5 chan null => MOI phieu KHKK tao sau K2 ket Nhap vinh vien (dung cho cicd-K2 khai "chan-ly-rong")
- BE: UpdateContractSigningPlanLineCommand(PlanId,LineId,CatalogEntryId) — EnsureDraftEditable + rao ContractId!=null 409 (gate F-6) + fail-fast entry song/IsActive/dung-nhom 409 + denorm TenHangMuc + changelog EntityType.Line EntityId=line.Id + ten NCC vao Summary (gate F-1)
- API: PUT /contract-signing-plans/{id}/lines/{lineId} policy KeHoachKyKet.Update + UpdateSigningPlanLineBody
- FE x2 app KhkkDetailPage (SHA-pair 0d6147d0): select gan tai bang (Nhap/TraLai, khoa dong da bac cau HD) + option-fallback giu hang-muc-da-luu khi vang options (gate F-2, 3 ca loading/error/ngung-hieu-luc) + dong loi query + amber nhom-0-hang-muc (F-3) + aria-label per-dong (F-8)
- Test +5 Fact: duong gan->submit-QUA guard that · khac-nhom 409 · phase 409 · IsActive=false 409 · gan-DE doi ca Id lan snapshot (gate F-4)
- Gate PASS-WITH-FLAGS-8/0-blocker — disposition tung dong trong sub-reviewer-gate-lineeditor.md; F-5/F-7 ghi no cung goi RowVersion/unique voi no K7 race-2-request
- scripts/backup-sql.ps1 default D:\ -> C:\ (VPS khong co o D — do that B0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:08:47 +07:00

1094 lines
47 KiB
TypeScript

// [W2 KHKK — S161 2026-07-29] Chi tiết "Kế hoạch ký kết HĐ" (GĐ2).
// Khuôn `pages/office/ProposalDetailPage.tsx` (SectionCard + Field idiom PURO).
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 4 section: (1) Thông tin + ghi chú/link hồ sơ sửa được khi nháp · (2) Dòng giá
// per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế
// hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File
// đính kèm (mở MỌI phase — triết lý PE S147).
// [W3 S161] +panel duyệt `KhkkWorkflowPanel` NGAY DƯỚI header (trên "Thông tin kế hoạch"):
// sơ đồ Bước→Cấp + banner đến-lượt + Gửi duyệt / Duyệt / Trả lại / Từ chối.
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, FileSignature, Paperclip, Pencil, Plus, Save, Trash2,
Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
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 KhkkLineDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput,
} from '@/types/khkk'
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
function formatVnd(n: number | null): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('vi-VN')
}
function formatSize(b: number): string {
return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`
}
// Section card — .card-accent shell + .icon-chip header (Hồ sơ-NS idiom).
function SectionCard({
title, icon, accent, head, chipBg, chipFg, count, actions, children,
}: {
title: string
icon: ReactNode
accent: string
head: string
chipBg: string
chipFg: string
count?: number
actions?: ReactNode
children: ReactNode
}) {
return (
<section className="card-accent" style={{ ['--accent' as string]: accent }}>
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: chipBg, ['--chip-fg' as string]: chipFg }}
aria-hidden
>
{icon}
</span>
<h3 className={cn('text-sm font-semibold tracking-tight', head)}>{title}</h3>
{count != null && (
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{count}
</span>
)}
{actions && <span className="ml-auto">{actions}</span>}
</header>
<div className="p-5">{children}</div>
</section>
)
}
function Field({
label, value, mono, full, children,
}: {
label: string
value?: ReactNode
mono?: boolean
full?: boolean
children?: ReactNode
}) {
const empty = value == null || value === ''
return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
<div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
{children ?? (empty ? '—' : value)}
</div>
</div>
)
}
const EMPTY_ITEM: UpsertKhkkDossierItemInput = {
kind: DossierItemKind.MauVatLieu,
name: '',
status: DossierItemStatus.ChuaNop,
tvgsName: '',
note: '',
}
export function KhkkDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const [editHeader, setEditHeader] = useState(false)
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],
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${id}`)).data,
enabled: !!id,
})
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['khkk-detail', id] })
qc.invalidateQueries({ queryKey: ['khkk-list'] })
}
const saveHeader = useMutation({
mutationFn: async () => {
await api.put(`/contract-signing-plans/${id}`, {
ghiChu: ghiChu.trim() || null,
hoSoLink: hoSoLink.trim() || null,
})
},
onSuccess: () => {
toast.success('Đã lưu')
setEditHeader(false)
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
// [S168 K8-blocker — Lines-editor] Gán hạng mục SP-002 cho dòng: options lọc ĐÚNG nhóm
// duyệt của phiếu (BE fail-fast tầng-gán + submit-guard chốt cuối). Chỉ tải khi phiếu
// còn sửa được (Nháp/Trả lại) — tránh 1 request thừa trên phiếu đã trình/duyệt.
const planDraftEditable =
plan.data?.phase === KhkkPhase.DangSoanThao || plan.data?.phase === KhkkPhase.TraLai
const catalogOptions = useQuery({
queryKey: ['contract-catalog', plan.data?.approvalGroup],
queryFn: async () =>
(
await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', {
params: { approvalGroup: plan.data!.approvalGroup },
})
).data,
enabled: planDraftEditable && plan.data?.approvalGroup != null,
})
const assignLine = useMutation({
mutationFn: async (p: { lineId: string; catalogEntryId: string }) =>
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }),
onSuccess: () => {
toast.success('Đã gán hạng mục')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
// [F-3 S161] BE verify id-path == cmd.ContractSigningPlanId. [F-4] POST ép Id=null
// = LUÔN insert ⇒ bấm "Sửa" phải đi PUT, không thì đẻ bản sao im lặng.
const payload = {
...body,
contractSigningPlanId: id,
name: body.name.trim(),
tvgsName: body.tvgsName?.trim() || null,
note: body.note?.trim() || null,
}
if (body.id) await api.put(`/contract-signing-plans/${id}/dossier-items/${body.id}`, payload)
else await api.post(`/contract-signing-plans/${id}/dossier-items`, payload)
},
onSuccess: () => {
toast.success('Đã lưu căn cứ')
setItemForm(null)
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteItem = useMutation({
mutationFn: async (itemId: string) =>
api.delete(`/contract-signing-plans/${id}/dossier-items/${itemId}`),
onSuccess: () => {
toast.success('Đã xóa căn cứ')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteAttachment = useMutation({
mutationFn: async (attId: string) => api.delete(`/contract-signing-plans/${id}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa file')
invalidate()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deletePlan = useMutation({
mutationFn: async () => api.delete(`/contract-signing-plans/${id}`),
onSuccess: () => {
toast.success('Đã xóa kế hoạch')
qc.invalidateQueries({ queryKey: ['khkk-list'] })
navigate('/khkk/list')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
async function download(att: KhkkAttachmentDto) {
try {
const res = await api.get(`/contract-signing-plans/${id}/attachments/${att.id}/download`, {
responseType: 'blob',
})
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* lỗi đã toast ở onError */ }
}
}
if (plan.isLoading) {
return (
<div className="space-y-5">
<PageHeader eyebrow="Giai đoạn 2" title="Đang tải..." icon={<FileCheck className="h-5 w-5" />} />
</div>
)
}
if (plan.isError || !plan.data) {
return (
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title="Lỗi"
icon={<FileCheck className="h-5 w-5" />}
actions={
<Button variant="outline" onClick={() => navigate('/khkk/list')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Quay lại
</Button>
}
/>
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800">
Không tải đưc dữ liệu kế hoạch kết.
</div>
</div>
)
}
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 (
<div className="space-y-5">
<PageHeader
eyebrow={k.maKeHoach ?? 'Kế hoạch ký kết'}
title={k.peTenGoiThau ?? 'Kế hoạch ký kết HĐ'}
icon={<FileCheck className="h-5 w-5" />}
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 && (
<span
className="inline-flex items-center rounded-md border border-violet-300 bg-violet-100 px-2.5 py-1 text-xs font-semibold text-violet-800"
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
>
N{k.approvalGroup}
</span>
)}
<span
className={cn(
'inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium',
KHKK_PHASE_BADGE[phase],
)}
>
{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"
onClick={() => {
if (window.confirm('Xóa kế hoạch ký kết này?')) deletePlan.mutate()
}}
disabled={deletePlan.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</Button>
)}
<Button variant="outline" onClick={() => navigate('/khkk/list')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Danh sách
</Button>
</>
}
/>
{/* [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. */}
<KhkkWorkflowPanel plan={k} onChanged={invalidate} />
{/* Section 1: Thông tin */}
<SectionCard
title="Thông tin kế hoạch"
icon={<FileCheck className="h-4 w-4" />}
accent="var(--color-brand-500)"
head="text-brand-800"
chipBg="var(--color-brand-50)"
chipFg="var(--color-brand-600)"
actions={
isDraft && !editHeader ? (
<Button variant="outline" size="sm" onClick={startEditHeader}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Sửa
</Button>
) : undefined
}
>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Mã kế hoạch" value={k.maKeHoach ?? '—'} mono />
<Field label="Phiếu Duyệt NCC nguồn">
<span className="font-mono text-sm">{k.peMaPhieu ?? '—'}</span>
</Field>
<Field label="Dự án" value={k.projectName ?? '—'} />
<Field label="Phòng ban" value={k.departmentName ?? '—'} />
<Field label="Người soạn" value={k.drafterFullName ?? '—'} />
<Field label="Quy trình">
{k.workflowCode ? (
<span>
<span className="font-mono">{k.workflowCode}</span> - {k.workflowName}
</span>
) : (
<span className="text-slate-300"> Chưa chọn </span>
)}
</Field>
<Field label="Ngày tạo" value={formatDateTime(k.createdAt)} />
<Field label="Hạn xử lý (tham khảo)" value={k.slaDeadline ? formatDateTime(k.slaDeadline) : '—'} />
</div>
{editHeader ? (
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 bg-slate-50/60 p-4">
<div>
<Label htmlFor="hoSoLink">Link hồ (NAS)</Label>
<Input
id="hoSoLink"
value={hoSoLink}
onChange={(e) => setHoSoLink(e.target.value)}
maxLength={1000}
placeholder="\\\\nas\\du-an\\..."
/>
</div>
<div>
<Label htmlFor="ghiChu">Ghi chú</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
/>
<div className="mt-1 text-xs text-muted-foreground">{ghiChu.length}/2000</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setEditHeader(false)}>
Huỷ
</Button>
<Button size="sm" onClick={() => saveHeader.mutate()} disabled={saveHeader.isPending}>
<Save className="mr-2 h-3.5 w-3.5" />
{saveHeader.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
<Field label="Link hồ sơ" full>
{k.hoSoLink ? (
<span className="break-all font-normal text-slate-700">{k.hoSoLink}</span>
) : (
<span className="text-slate-300"></span>
)}
</Field>
<Field label="Ghi chú" full>
{k.ghiChu ? (
<span className="whitespace-pre-wrap font-normal text-slate-700">{k.ghiChu}</span>
) : (
<span className="text-slate-300"></span>
)}
</Field>
</div>
)}
</SectionCard>
{/* Section 2: Dòng giá per-NCC trúng thầu */}
<SectionCard
title="Giá đề xuất theo NCC trúng thầu"
icon={<Users className="h-4 w-4" />}
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 ? (
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
🔒 Giá đã chốt tại thời điểm duyệt
</span>
) : undefined
}
>
{k.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div>
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
{/* [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). */}
<th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá tham chiếu (phiếu NCC)</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá đ xuất</th>
<th className="label-eyebrow px-3 py-2 text-right">Giá duyệt</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
</tr>
</thead>
<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 ?? '—'}
{/* [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">
{/* 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 ? (
<>
<select
value={l.catalogEntryId ?? ''}
onChange={(e) =>
e.target.value &&
assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })
}
disabled={assignLine.isPending}
aria-label={`Gán hạng mục SP-002 cho dòng ${l.supplierName ?? l.id}`}
className="h-8 w-full min-w-[12rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
>
<option value=""> Chọn hạng mục </option>
{/* [gate-lineeditor F-2] Hạng mục ĐÃ LƯU mà không có trong options
(đang tải / query lỗi / entry hết hiệu lực) ⇒ chèn option đại diện
từ dữ liệu đã lưu — không thì select rơi về placeholder và dòng
đã gán TRÔNG như chưa gán (vắng-mặt trông giống ổn). */}
{l.catalogEntryId &&
!(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
<option value={l.catalogEntryId}>
{l.tenHangMuc ?? '(hạng mục đã gán)'}
</option>
)}
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.tenVi}
</option>
))}
</select>
{catalogOptions.isError && (
<div className="mt-1 text-[11px] text-red-600">
Không tải đưc danh mục hạng mục {' '}
<button type="button" className="underline" onClick={() => catalogOptions.refetch()}>
thử lại
</button>
</div>
)}
{catalogOptions.isSuccess && (catalogOptions.data?.length ?? 0) === 0 && (
<div className="mt-1 text-[11px] text-amber-700">
Nhóm duyệt N{k.approvalGroup} chưa hạng mục đang hiệu lực liên hệ quản trị danh mục.
</div>
)}
</>
) : l.tenHangMuc ? (
l.tenHangMuc
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</td>
<td className="px-3 py-2 text-xs text-slate-600">{l.note ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
<div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu 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.
</div>
</SectionCard>
{/* Section 3: Căn cứ hồ sơ b.8-9 */}
<SectionCard
title="Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)"
icon={<ClipboardList className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={k.dossierItems.length}
actions={
isDraft ? (
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
<Plus className="mr-2 h-3.5 w-3.5" />
Thêm căn cứ
</Button>
) : undefined
}
>
{k.dossierItems.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div>
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
<th className="label-eyebrow px-3 py-2 text-left">Tên căn cứ</th>
<th className="label-eyebrow px-3 py-2 text-left">Trạng thái</th>
<th className="label-eyebrow px-3 py-2 text-left">TVGS</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
{isDraft && <th className="label-eyebrow px-3 py-2 text-right">Thao tác</th>}
</tr>
</thead>
<tbody>
{k.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td>
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2">
<span
className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
DOSSIER_STATUS_BADGE[d.status],
)}
>
{DOSSIER_STATUS_LABELS[d.status]}
</span>
</td>
<td className="px-3 py-2 text-xs text-slate-600">
{d.tvgsName ?? '—'}
{d.tvgsResultAt && (
<span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-600">{d.note ?? '—'}</td>
{isDraft && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<button
type="button"
onClick={() =>
setItemForm({
id: d.id,
kind: d.kind,
supplierId: d.supplierId,
name: d.name,
status: d.status,
tvgsName: d.tvgsName ?? '',
note: d.note ?? '',
})
}
className="rounded px-1.5 py-1 text-violet-600 hover:bg-violet-50"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Xóa căn cứ "${d.name}"?`)) deleteItem.mutate(d.id)
}}
className="rounded px-1.5 py-1 text-red-600 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</SectionCard>
{/* Section 4: File đính kèm — mở mọi phase */}
<SectionCard
title="File đính kèm"
icon={<Paperclip className="h-4 w-4" />}
accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={k.attachments.length}
actions={
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
<Upload className="mr-2 h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
<input type="file" multiple className="hidden" onChange={onPickFiles} />
</label>
}
>
{k.attachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div>
) : (
<ul className="space-y-2">
{k.attachments.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-brand-800">{a.fileName}</div>
<div className="text-xs text-muted-foreground">
{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">
Tải xuống
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id)
}}
className="text-xs text-red-600 hover:underline"
>
Xóa
</button>
</div>
</li>
))}
</ul>
)}
</SectionCard>
{/* Dialog thêm/sửa căn cứ */}
<Dialog
open={!!itemForm}
onClose={() => setItemForm(null)}
title={itemForm?.id ? 'Sửa căn cứ hồ sơ' : 'Thêm căn cứ hồ sơ'}
>
{itemForm && (
<div className="space-y-3">
<div>
<Label htmlFor="item-kind">Loại căn cứ</Label>
<select
id="item-kind"
value={itemForm.kind}
onChange={(e) => setItemForm({ ...itemForm, kind: Number(e.target.value) as DossierItemKindValue })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{Object.entries(DOSSIER_KIND_LABELS).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="item-name">
Tên căn cứ <span className="text-red-500">*</span>
</Label>
<Input
id="item-name"
value={itemForm.name}
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
maxLength={300}
placeholder="vd. Mẫu thép D10 Hoà Phát"
/>
</div>
<div>
<Label htmlFor="item-status">Trạng thái</Label>
<select
id="item-status"
value={itemForm.status}
onChange={(e) => setItemForm({ ...itemForm, status: Number(e.target.value) as DossierItemStatusValue })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{Object.entries(DOSSIER_STATUS_LABELS).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="item-tvgs">Tên TVGS xử </Label>
<Input
id="item-tvgs"
value={itemForm.tvgsName ?? ''}
onChange={(e) => setItemForm({ ...itemForm, tvgsName: e.target.value })}
maxLength={200}
/>
</div>
<div>
<Label htmlFor="item-note">Ghi chú</Label>
<Textarea
id="item-note"
value={itemForm.note ?? ''}
onChange={(e) => setItemForm({ ...itemForm, note: e.target.value })}
maxLength={1000}
rows={3}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setItemForm(null)}>
Huỷ
</Button>
<Button
onClick={() => upsertItem.mutate(itemForm)}
disabled={upsertItem.isPending || !itemForm.name.trim()}
>
{upsertItem.isPending ? 'Đang lưu...' : 'Lưu'}
</Button>
</div>
</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>
)
}