// [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 { Fragment, useState, type ReactNode } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ClipboardList, FileCheck, Lock, 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') + ' đ' } // [S175 · B2 · P0] Ô RỖNG phải MỜ HƠN dữ-liệu thật. Trước đây em-dash thừa-hưởng đúng // màu chữ của ô (`text-slate-600/700`) ⇒ lướt bảng thì "—" trông y như một GIÁ-TRỊ. // Gom về một chỗ để các ô rỗng không mỗi nơi một kiểu. const DASH = /** Chuỗi có thể rỗng → em-dash mờ. */ function orDash(v: string | null | undefined): ReactNode { return v == null || v === '' ? DASH : v } /** Tiền: `formatVnd` trả CHUỖI "—" khi rỗng nên không tô mờ được — tách nhánh rỗng ra. */ function moneyOrDash(n: number | null | undefined): ReactNode { return n == null ? DASH : formatVnd(n) } 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}
{/* [S175 · C3] VALUE về `text-slate-900`, LABEL giữ `text-brand-600`. Trước đây MỌI value đều tô brand ⇒ màu thương-hiệu mất hết sức nặng: khi cả trang đều là màu nhấn thì không còn gì là nhấn, và không chừa được màu cho thứ THẬT SỰ cần nổi (NCC trúng thầu, cảnh báo vượt ngân sách). PE giữ slate cho value đúng vì lý do này. */}
{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)), }) // [S175 · O4] +`dossierItemId`: gắn file vào ĐÚNG một căn cứ hồ sơ (shopdrawing, mẫu vật // liệu…) thay vì đổ chung một rổ. 🔴 0 BE MỚI — rail `dossierItemId` đã đủ 6 tầng từ Mig 69 // (`KhkkAttachmentDto:241` ⟵ `POST /attachments` multipart `dossierItemId?`); trước đây FE // đơn giản là KHÔNG BAO GIỜ gửi trường đó lên, nên mọi file rơi về mức phiếu. const upload = useMutation({ mutationFn: async ({ file, dossierItemId }: { file: File; dossierItemId?: string }) => { const fd = new FormData() fd.append('file', file) fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan)) if (dossierItemId) fd.append('dossierItemId', dossierItemId) 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, dossierItemId?: string) { const files = Array.from(e.target.files ?? []) e.target.value = '' for (const f of files) { try { await upload.mutateAsync({ file: f, dossierItemId }) } 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. // [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4 // chỉ còn file mức PHIẾU. 🔴 Không chia thì file gắn căn cứ hiện ở CẢ HAI chỗ — người dùng // đếm 2 lần rồi tưởng đã tải nhầm lên hai bận. const dossierAttachments = new Map() const planAttachments: KhkkAttachmentDto[] = [] for (const a of plan.attachments) { if (a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) continue if (a.dossierItemId) { const bucket = dossierAttachments.get(a.dossierItemId) if (bucket) bucket.push(a) else dossierAttachments.set(a.dossierItemId, [a]) } else { planAttachments.push(a) } } // 🧊 [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\\..." />