// [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 (
{icon}

{title}

{count != null && ( {count} )} {actions && {actions}}
{children}
) } function Field({ label, value, mono, full, children, }: { label: string value?: ReactNode mono?: boolean full?: boolean children?: ReactNode }) { const empty = value == null || value === '' return (
{label}
{children ?? (empty ? '—' : value)}
) } 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(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(`/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) { 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 (
} />
) } if (plan.isError || !plan.data) { return (
} actions={ } />
Không tải được dữ liệu kế hoạch ký kết.
) } const k = plan.data const phase = k.phase as KhkkPhaseValue 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 (
} 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 && ( N{k.approvalGroup} )} {KHKK_PHASE_LABELS[phase]} {/* [K7 S167 — vá-1 vế FE] Nút bắc cầu KHKK → HĐ. `` 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 && ( )} {canDeletePlan && ( )} } /> {/* [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. */} {/* Section 1: Thông tin */} } accent="var(--color-brand-500)" head="text-brand-800" chipBg="var(--color-brand-50)" chipFg="var(--color-brand-600)" actions={ isDraft && !editHeader ? ( ) : undefined } >
{k.peMaPhieu ?? '—'} {k.workflowCode ? ( {k.workflowCode} - {k.workflowName} ) : ( — Chưa chọn — )}
{editHeader ? (
setHoSoLink(e.target.value)} maxLength={1000} placeholder="\\\\nas\\du-an\\..." />