diff --git a/fe-admin/src/components/khkk/KhkkDetailContent.tsx b/fe-admin/src/components/khkk/KhkkDetailContent.tsx new file mode 100644 index 0000000..072aa4f --- /dev/null +++ b/fe-admin/src/components/khkk/KhkkDetailContent.tsx @@ -0,0 +1,709 @@ +// [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, MessageSquare, 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) + + // ── Section 5: ý-kiến cấp duyệt, gom theo BƯỚC (Phòng) → CẤP → NV ────────────────── + const opinions = plan.levelOpinions ?? [] + const steps = plan.workflowSteps ?? [] + + 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\\..." /> +
+
+ +