From 998ea559f385e02be10aeb155e25d16261c4918c Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 4 Aug 2026 19:48:38 +0700 Subject: [PATCH] [CLAUDE] Contract: KHKK 3-panel mirror Duyet NCC (GD2 het lech kien-truc) + 4 mitigation cuong-hanh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dua man Ke hoach Ky ket HD ve dung lop kien-truc ma GD1 (PE) va GD3 (HD) dang dung: 1 man 3-panel h-[calc(100vh-4rem)] grid [400px_1fr_360px], thay cho list 2-panel + detail trang rieng co workflow xep chong. GD2 la mat-xich lech duy nhat trong 4 giai-doan truoc dot nay. BE (7/7): - DetailDto +5 field: WorkflowVersion / EndedByLevelFinalize / FinalizeStepName / FinalizeLevelName / Approvals. 2 field Finalize* KHONG phai field append — ContractSigningPlan chi co co bool, phai DAN XUAT (lam in-memory tu cay workflow da nap, thay vi port 3 subquery cua PE). - ContractSigningPlanApprovalDto de moi (truoc do 0 hit toan repo). - StepDto +DepartmentName (nuoi chip phong panel 3 + header nhom Section 5). - GET {id}/changelogs — policy KeHoachKyKet.Read. - POST {id}/approval-attachments + enum +ApprovalAttachment=3 (append-only). - approvals[] loc bo Decision=Pending de so (N) cung nghia voi (N) cua PE. - KHONG chep khuon authz cua PurchaseEvaluationsController: file do la mot lo gotcha #82 (class [Authorize] tran + changelogs khong policy). FE (x2 app, SHA256 byte-identical 8/8): - components/khkk/{KhkkListPanel,KhkkDetailContent,KhkkWorkflowPanel}.tsx; KhkkWorkflowPanel doi tu pages/ sang components/. - Panel 2: 5 Section danh so, Section 5 "Y kien cap duyet" duoi cung — khoi nay DOI TU panel 3 sang panel 2 (owner khoanh do trong anh); luoi 2 cot + meta "x/y cap da duyet · N NV tham gia" thay list phang 1 cot. - Panel 3: banner ket-thuc-som, chip ten phong, badge Duyet thay CEO, lam mo buoc sau finalize, khoi File dinh kem khi duyet, Lich su duyet, Lich su thay doi. Chua chon phieu -> hop dashed y PE (QD owner). 4 mitigation cuong-hanh (owner chot panel 1 = PipelineTreePanel; chon HINH khong phai chap-nhan VO): - M-1 fallback !canPe -> danh-sach KHKK phang. Khong co no, user chi co quyen KHKK (vd nguoi duyet nhom N5) MAT TRANG danh-sach du API van cho doc — display-layer khoa authz-layer dang mo, mat con lai cua #82. - M-2 onOpen thanh callback tu host; mac-dinh giu navigate cu => 0 hoi-quy trang PE/GD3. - B1 stage2Content di CAP voi stage2Count qua conditional-spread: khong truyen => stage2 van nhan groups => trang PE GIU NGUYEN 8 ngan N1-N8. - R-11 search/loc di server-side, KHONG loc-tren-cay; giu dong canh-bao "Cay tai 200 phieu moi nhat". Chong hoi-quy da do: build 3/3 PASS · acceptance tinh 20/20 x 2 app · mirror SHA256 8/8 · 0 hit path cam (components|pages)/(pe|contracts) · KHONG ghi filter/view/group vao URL (chong tai-sinh bug S155 sidebar sang nham leaf) · giu set-state-in-render :97-101 (dung khuon React, refactor thanh useEffect se tai-hien va-5). Con no, khai thang: - E-7 bang "cot nao chet, chuyen di dau" (8 cot + 6 KpiCard -> card 400px) THIEU: lane FE chet o luot tra ve, ly-do bo cot mat theo. Phai soi diff bang mat truoc UAT, neu khong dung ca M-7 mat-chuc-nang-im-lang. - Test 643/644. 1 FLAKY Create_DifferentEvaluationType_DoesNotAffect_Other (full-suite 2->1->1, chay rieng 0/0/0; PeWorkflowAdminTests tao new SqliteDbFixture() rieng x6 test). Baseline tren HEAD sach CHUA do (git worktree fail duong-dan Dropbox) => CHUA duoc noi "flake co san". - 5 acceptance RUNTIME cho UAT: approvals co dong Tra lai + ten nguoi (DB dev 0 hang KHKK) · UAT fe-user:8080 an-danh · deep-link fe-admin:8082 (menu KHKK bi an trong ADMIN_HIDDEN_KEYS) · negative-control go fallback canPe · khoi file tu-an khi rong. - P3-12 4 return-mode = HOAN, khong phai buoc-khac: tien-de "KHKK 3 tram ngan" do lai la SAI (DbInitializer:554 = 3 Buoc x <=2 Cap, seed tu ghi "sao tu workflow Duyet NCC prod"). Spec + 6 artifact phan-bien: .claude/workflows/runs/2026-08-04-S171-khkk-ui-mirror-pe/ Co-Authored-By: Claude Opus 5 --- .../src/components/khkk/KhkkDetailContent.tsx | 709 +++++++++++++++ .../src/components/khkk/KhkkListPanel.tsx | 388 ++++++++ .../src/components/khkk/KhkkWorkflowPanel.tsx | 726 +++++++++++++++ .../components/pipeline/PipelineTreePanel.tsx | 9 +- fe-admin/src/hooks/usePipelineStages.ts | 33 +- fe-admin/src/pages/khkk/KhkkDetailPage.tsx | 845 ++---------------- fe-admin/src/pages/khkk/KhkkListPage.tsx | 454 ++++------ fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx | 491 ---------- fe-admin/src/types/khkk.ts | 100 ++- .../src/components/khkk/KhkkDetailContent.tsx | 709 +++++++++++++++ fe-user/src/components/khkk/KhkkListPanel.tsx | 388 ++++++++ .../src/components/khkk/KhkkWorkflowPanel.tsx | 726 +++++++++++++++ .../components/pipeline/PipelineTreePanel.tsx | 9 +- fe-user/src/hooks/usePipelineStages.ts | 33 +- fe-user/src/pages/khkk/KhkkDetailPage.tsx | 845 ++---------------- fe-user/src/pages/khkk/KhkkListPage.tsx | 454 ++++------ fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx | 491 ---------- fe-user/src/types/khkk.ts | 100 ++- .../ContractSigningPlansController.cs | 40 + .../ContractSigningPlanFeatures.cs | 206 ++++- .../ContractSigningPlanAttachment.cs | 4 + 21 files changed, 4581 insertions(+), 3179 deletions(-) create mode 100644 fe-admin/src/components/khkk/KhkkDetailContent.tsx create mode 100644 fe-admin/src/components/khkk/KhkkListPanel.tsx create mode 100644 fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx delete mode 100644 fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx create mode 100644 fe-user/src/components/khkk/KhkkDetailContent.tsx create mode 100644 fe-user/src/components/khkk/KhkkListPanel.tsx create mode 100644 fe-user/src/components/khkk/KhkkWorkflowPanel.tsx delete mode 100644 fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx 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\\..." /> +
+
+ +