// [S171 KHKK 3-panel] PANEL 2 (giữa) — nội-dung phiếu "Kế hoạch ký kết HĐ" (GĐ2). // File MIRROR SHA256 identical với fe-admin counterpart. // // 🔴 Khối ý-kiến cấp duyệt (Section 5) là bản thứ 3 của cùng một hình (PE // `PeDetailTabs.tsx:705-774` · HĐ `components/contracts/WorkflowHistoryPanel.tsx`). // Gộp được KHI VÀ CHỈ KHI BE 3 module thống-nhất shape `status` precompute — trước đó // gộp chỉ đẻ ra `if (module === …)`. // // Bố-cục mirror Duyệt NCC: dải 4 giai-đoạn trên cùng, rồi các Section ĐÁNH SỐ nối nhau // bằng `divide-y`, ý-kiến cấp duyệt nằm DƯỚI CÙNG (Ảnh 3 khoanh đỏ). // 1. Thông tin kế hoạch 4. File đính kèm // 2. Giá đề xuất theo NCC 5. Ý kiến cấp duyệt // 3. Căn cứ hồ sơ (b.8-9) // 🔸 KHÁC PE ở NỘI DUNG (miễn-trừ P2-3, có lý-do nghiệp-vụ): GĐ2 không có báo-giá / so-sánh // NCC (đã chốt ở GĐ1) mà có căn-cứ hồ-sơ TVGS. Giống nhau ở KHUNG, không ở chữ. // // [P2-7] `readOnly` là prop TỪ HOST — host quyết `phiếu ≠ Nháp ∨ user ≠ người soạn`. // Không dựng workspace riêng cho việc này. // [R-8] KHKK KHÔNG có phiếu V1-legacy (module sinh 2026-07-29) ⇒ KHÔNG port nhánh fallback // V1 của PE (`PeDetailTabs.tsx:434-436`) — đó là code chết ngay ngày đầu. import { useState, type ReactNode } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, } from 'lucide-react' import { toast } from 'sonner' import { PePipelineStrip } from '@/components/pe/PePipelineStrip' 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 { Textarea } from '@/components/ui/Textarea' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' 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 DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto, type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue, type UpsertKhkkDossierItemInput, } from '@/types/khkk' function formatVnd(n: number | null | undefined): string { if (n === null || n === undefined) return '—' return n.toLocaleString('vi-VN') + ' đ' } function formatDateTime(iso: string): string { return new Date(iso).toLocaleString('vi-VN') } function formatSize(b: number): string { return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB` } /** Khung Section ĐÁNH SỐ — mirror khuôn Section của màn Duyệt NCC (đánh số + divide-y). */ function Section({ 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 KhkkDetailContent({ plan, readOnly, onChanged, }: { plan: KhkkDetailDto /** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */ readOnly: boolean onChanged?: () => void }) { const qc = useQueryClient() const id = plan.id const [editHeader, setEditHeader] = useState(false) const [ghiChu, setGhiChu] = useState('') const [hoSoLink, setHoSoLink] = useState('') const [itemForm, setItemForm] = useState(null) const phase = plan.phase as KhkkPhaseValue const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai // Sửa được = phiếu còn ở Nháp/Trả lại VÀ host không khoá (readOnly). const isDraft = draftPhase && !readOnly const invalidate = () => { qc.invalidateQueries({ queryKey: ['khkk-detail', id] }) qc.invalidateQueries({ queryKey: ['khkk-list'] }) onChanged?.() } 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)), }) // Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa). const catalogOptions = useQuery({ queryKey: ['contract-catalog', plan.approvalGroup], queryFn: async () => (await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', { params: { approvalGroup: plan.approvalGroup }, })).data, enabled: isDraft && plan.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ứ') const payload = { ...body, contractSigningPlanId: id, name: body.name.trim(), tvgsName: body.tvgsName?.trim() || null, note: body.note?.trim() || null, } // [F-4 S161] POST ép Id=null = LUÔN insert ⇒ "Sửa" phải đi PUT, không thì đẻ bản sao im lặng. 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)), }) 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 */ } } } const startEditHeader = () => { setGhiChu(plan.ghiChu ?? '') setHoSoLink(plan.hoSoLink ?? '') setEditHeader(true) } // File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu. const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment) // 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái // này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai // là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` / // `plan.workflowSteps` VẪN có trong DTO (panel 3 dùng) — chỉ bỏ chỗ VẼ ở đây. return (
{/* Dải 4 giai-đoạn — GĐ2 đang sáng. Cùng component với màn Duyệt NCC (không nhân bản). */}
{plan.maKeHoach ?? '(chưa cấp mã)'} {plan.peTenGoiThau ?? '—'} {plan.approvalGroup != null && ( N{plan.approvalGroup} )} {KHKK_PHASE_LABELS[phase]}
{/* ─────────────── 1 ─────────────── */}
} accent="var(--color-brand-500)" head="text-brand-800" chipBg="var(--color-brand-50)" chipFg="var(--color-brand-600)" actions={ isDraft && !editHeader ? ( ) : undefined } >
{plan.peMaPhieu ?? '—'} {plan.workflowCode ? ( {plan.workflowCode} {plan.workflowVersion != null && v{plan.workflowVersion}} {plan.workflowName ? ` · ${plan.workflowName}` : ''} ) : ( — Chưa chọn — )}
{editHeader ? (
setHoSoLink(e.target.value)} maxLength={1000} placeholder="\\\\nas\\du-an\\..." />