diff --git a/fe-admin/src/components/khkk/KhkkDetailContent.tsx b/fe-admin/src/components/khkk/KhkkDetailContent.tsx index 072aa4f..81a0f3d 100644 --- a/fe-admin/src/components/khkk/KhkkDetailContent.tsx +++ b/fe-admin/src/components/khkk/KhkkDetailContent.tsx @@ -21,7 +21,7 @@ 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, + Check, ClipboardList, FileCheck, MessageSquare, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, } from 'lucide-react' import { toast } from 'sonner' import { PePipelineStrip } from '@/components/pe/PePipelineStrip' @@ -589,61 +589,85 @@ export function KhkkDetailContent({ Phiếu chưa pin quy trình duyệt — chưa có cấp nào để ghi ý kiến. ) : ( -
+
{steps.map((step) => { const signedCount = step.levels.filter(lv => opinions.some(o => o.approvalWorkflowLevelId === lv.id)).length return ( -
-
- Bước {step.order} — {step.name} +
+
+ + Bước {step.order} — {step.name} + {step.departmentName && ( - + {step.departmentName} )} - + {signedCount}/{step.levels.length} cấp đã duyệt · {step.levels.length} NV tham gia
-
- {step.levels.length === 0 && ( -

Bước này chưa cấu hình cấp duyệt.

- )} - {step.levels.map((lv) => { - const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id) - return ( -
-
- {lv.name || `Cấp ${lv.order}`} - · - {lv.approverFullName ?? '(chưa cấu hình)'} - {signed && ( - <> - · - {formatDateTime(signed.signedAt)} - {signed.approverUserId && signed.approverUserId !== signed.signedByUserId && ( - - duyệt thay ({signed.signedByFullName}) +
+ {step.levels.length === 0 ? ( +
— Bước này chưa cấu hình cấp duyệt.
+ ) : ( +
+ {step.levels.map((lv) => { + const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id) + return ( +
+
+
+

+ {lv.name || `Cấp ${lv.order}`} —{' '} + {lv.approverFullName ?? '(chưa cấu hình)'} +

+ {/* Không dùng `signed!` — thu hẹp bằng chính `signed &&` để + khỏi mượn null-forgiving trên dữ-liệu từ API (gotcha #80). */} + {signed && signed.approverUserId + && signed.approverUserId !== signed.signedByUserId && ( +
+ ⚠ Admin {signed.signedByFullName} duyệt thay +
+ )} +
+ {signed ? ( + + Đã duyệt + + ) : ( + + Chưa ký )} - - )} -
-
- {signed - ? (signed.comment?.trim() || '(duyệt — không ý kiến)') - : Chưa ký} -
-
- ) - })} +
+ {signed ? ( + <> +
+ {signed.comment?.trim() || '(duyệt — không ý kiến)'} +
+
+ {formatDateTime(signed.signedAt)} +
+ + ) : ( +
Chưa có ý kiến duyệt.
+ )} +
+ ) + })} +
+ )}
) diff --git a/fe-admin/src/components/khkk/KhkkListPanel.tsx b/fe-admin/src/components/khkk/KhkkListPanel.tsx index 3f52452..a01cdb7 100644 --- a/fe-admin/src/components/khkk/KhkkListPanel.tsx +++ b/fe-admin/src/components/khkk/KhkkListPanel.tsx @@ -31,11 +31,9 @@ import { usePipelineStages } from '@/hooks/usePipelineStages' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import { - KHKK_APPROVAL_GROUPS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KhkkPhase, - khkkGroupMenuLabel, type KhkkListItemDto, type KhkkPhaseValue, type PagedResult, @@ -172,7 +170,6 @@ export function KhkkListPanel({ phase, onPhaseChange, group, - onGroupChange, deletedView, page, onPageChange, @@ -184,8 +181,10 @@ export function KhkkListPanel({ onSearchChange: (v: string) => void phase: KhkkPhaseValue | null onPhaseChange: (v: KhkkPhaseValue | null) => void + // [S174 owner] CHỈ còn đọc — `Select` nhóm đã gỡ khỏi UI. Nhóm nay đến DUY NHẤT từ URL + // (`?group=n`, tức 8 leaf nhóm ở menu trái). Máy lọc giữ NGUYÊN (query + `stage2Content`); + // chỉ bỏ đường đổi nhóm bằng tay — vào từ leaf nhóm rồi thì ô đó là thừa. group: number | null - onGroupChange: (v: number | null) => void deletedView: boolean page: number onPageChange: (p: number) => void @@ -265,28 +264,16 @@ export function KhkkListPanel({ className="pl-8" />
-
- - -
+ {flatMode && !deletedView && canPe && (

Đang lọc — danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây). @@ -305,6 +292,7 @@ export function KhkkListPanel({ diff --git a/fe-admin/src/components/pe/PeKhkkSidePanel.tsx b/fe-admin/src/components/pe/PeKhkkSidePanel.tsx new file mode 100644 index 0000000..8952a81 --- /dev/null +++ b/fe-admin/src/components/pe/PeKhkkSidePanel.tsx @@ -0,0 +1,252 @@ +// [S174 owner] Cột PHẢI của màn "Duyệt NCC — Thao tác" — 1 cột, 2 TẦNG 50/50: +// · tầng TRÊN = phiếu Duyệt NCC ĐÃ DUYỆT (có NCC trúng thầu) mà CHƯA lập kế hoạch ký kết, +// gom theo DỰ ÁN — đây là "việc còn phải làm"; +// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — đây là "việc đã làm rồi". +// Anh chốt: "Thử ý tưởng 1 cột 2 tầng thử, 50% tầng trên là duyệt NCC, còn 50% tầng dưới +// là KHKK". File MIRROR SHA256 identical với fe-user counterpart. +// +// 🔴 0 ENDPOINT MỚI — dùng lại đúng 2 cửa đã sống: +// · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage` +// đang gọi) → `KhkkPeCandidateDto[]`; "chưa có kế hoạch" = `existingGroups` RỖNG. +// 🔸 Từ K2 S164 cửa này KHÔNG còn loại PE đã có kế hoạch (nó trả cả PE đã dùng vài +// nhóm, kèm `existingGroups`) ⇒ lọc rỗng là việc của FE, đừng tưởng BE lọc hộ. +// · `GET /contract-signing-plans` → `PagedResult`. +// +// 🔴 GATE QUYỀN (gotcha #85 — mặt đã cắn thật ở S162): màn đang đứng là *Duyệt NCC*, nhưng +// CẢ HAI cửa trên gác bằng policy `KeHoachKyKet.Read` (`ContractSigningPlansController.cs:36` +// + `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa cha, KHÔNG OR key +// con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key +// của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì". +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { FileCheck, FileSignature, Lock } from 'lucide-react' +import { usePermission } from '@/hooks/usePermission' +import { api } from '@/lib/api' +import { cn } from '@/lib/cn' +import { + KHKK_PHASE_BADGE, + KHKK_PHASE_LABELS, + type KhkkListItemDto, + type KhkkPeCandidateDto, + type PagedResult, +} from '@/types/khkk' + +const KHKK_TIER_PAGE_SIZE = 30 + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +/** Vỏ 1 tầng — `min-h-0 flex-1` ở CẢ HAI tầng là thứ tạo ra tỉ lệ 50/50 thật; thiếu + * `min-h-0` thì con cuộn được sẽ đẩy tầng phình theo nội dung, hỏng tỉ lệ. */ +function Tier({ + title, count, icon, accent, children, +}: { + title: string + count: number | null + icon: React.ReactNode + accent: string + children: React.ReactNode +}) { + return ( +

+
+ + {icon} + + + {title} + + {count !== null && ( + + {count} + + )} +
+
{children}
+
+ ) +} + +export function PeKhkkSidePanel() { + const navigate = useNavigate() + const { can } = usePermission() + // 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). + const canKhkk = can('KeHoachKyKet', 'Read') + + const candidates = useQuery({ + // Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì + // cột tự tươi, không phải nhớ invalidate thêm một key thứ hai. + queryKey: ['khkk-pe-candidates'], + queryFn: async () => + (await api.get('/contract-signing-plans/approved-pe-awaiting-plan')).data, + enabled: canKhkk, + }) + + const plans = useQuery({ + queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }], + queryFn: async () => + (await api.get>('/contract-signing-plans', { + params: { page: 1, pageSize: KHKK_TIER_PAGE_SIZE }, + })).data, + enabled: canKhkk, + }) + + // "Chưa được tạo phiếu KHKK" = chưa dùng nhóm duyệt NÀO. `?? []` phòng payload cũ còn + // trong cache React Query (DTO trước K2 không có field này) — thiếu guard thì `.length` nổ. + const awaiting = (candidates.data ?? []).filter((p) => (p.existingGroups ?? []).length === 0) + + // Gom theo DỰ ÁN — đúng chữ "theo dự án" của anh. `Map` giữ thứ tự chèn nên dự án nào có + // phiếu mới nhất (BE trả sẵn theo thứ tự) thì nổi lên trước. + const byProject = new Map() + for (const pe of awaiting) { + const key = pe.projectName ?? '(chưa gán dự án)' + const bucket = byProject.get(key) + if (bucket) bucket.push(pe) + else byProject.set(key, [pe]) + } + + const planItems = plans.data?.items ?? [] + + return ( + + ) +} diff --git a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx index 3714bb9..f387a11 100644 --- a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx +++ b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx @@ -88,6 +88,7 @@ export function PipelineTreePanel({ className, bodyMaxHClass, stage2Options, + hideHeader, }: { currentStage: PipelineStageNo className?: string @@ -100,6 +101,12 @@ export function PipelineTreePanel({ // hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau). // Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi. bodyMaxHClass?: string + // [S174 owner] Ẩn thanh tiêu-đề "Cây toàn trình · N gói". CHỈ dùng ở host đã có tiêu-đề + // riêng ngay trên (KHKK GĐ2: header trang `KhkkListPage.tsx:124`) — 2 tiêu-đề chồng nhau + // trong cùng một cột là thừa. 6 host còn lại KHÔNG truyền ⇒ giữ nguyên hình cũ. + // 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi + // ` -
- - - - Cây toàn trình - - {packageCount} gói - - -
+ {!hideHeader && ( +
+ + + + Cây toàn trình + + {packageCount} gói + + +
+ )} -
+ {/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `
{ @@ -121,6 +127,7 @@ export function KhkkCreatePage() { const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body) return res.data }, + // 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161. onSuccess: (data) => { toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') navigate(`/khkk/${data.id}`) @@ -142,6 +149,15 @@ export function KhkkCreatePage() { const takenGroups = selectedPe?.existingGroups ?? [] const groupTaken = takenGroups.includes(approvalGroup) + // [S174] Khuôn `PeWorkspaceCreateView.tsx:145-150`: liệt kê ĐÍCH DANH cái còn thiếu thay + // vì chỉ khoá nút — nút xám không nói được vì sao nó xám. + const missingCreate = [ + !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', + !approvalWorkflowId && 'Quy trình duyệt', + groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`, + ].filter(Boolean) as string[] + const canSubmit = missingCreate.length === 0 && !create.isPending + // Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã // bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. const selectPe = (pe: KhkkPeCandidateDto) => { @@ -154,52 +170,37 @@ export function KhkkCreatePage() { } return ( -
- } - accent="brand" - actions={ - - } - /> +
+ {/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */} +
+
+

Lập kế hoạch ký kết HĐ mới

+

+ Nhập Section 1 + 2 → bấm “Tạo kế hoạch” → sau đó dòng giá NCC / căn cứ hồ sơ / ý kiến mở khóa. +

+
+ +
- - {/* Section 1: Phiếu Duyệt NCC nguồn */} -
-
- - - -

- Phiếu Duyệt NCC nguồn * -

-
-
- {candidates.isLoading &&
Đang tải danh sách phiếu...
} - {!candidates.isLoading && list.length === 0 && ( -
- Không có phiếu Duyệt NCC nào đã duyệt và có NCC trúng thầu. -
- )} -
- {list.map((pe) => { - const selected = pe.id === purchaseEvaluationId - // [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO - // không có field này) — thiếu guard thì `.length` nổ trắng trang. - const taken = pe.existingGroups ?? [] - const free = firstFreeGroup(taken) - return ( -
+
+
+ {candidates.isLoading &&
Đang tải danh sách phiếu...
} + {!candidates.isLoading && list.length === 0 && ( +
+ Không có phiếu Duyệt NCC nào đã duyệt và có NCC trúng thầu. +
+ )} +
+ {list.map((pe) => { + const selected = pe.id === purchaseEvaluationId + // [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO + // không có field này) — thiếu guard thì `.length` nổ trắng trang. + const taken = pe.existingGroups ?? [] + const free = firstFreeGroup(taken) + return ( +
)} -
- ) - })} -
+
+ ) + })}
-
+ - {/* Section 2: Quy trình duyệt */} -
-
- - - -

Nhóm duyệt & quy trình

-
-
+
+
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP, user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable — rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */} -
- - setApprovalGroup(Number(e.target.value))} - className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm" > {KHKK_APPROVAL_GROUPS.map((g) => { const used = takenGroups.includes(g) @@ -307,83 +292,92 @@ export function KhkkCreatePage() { ) })} - + {groupTaken ? ( -
+

Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm N{approvalGroup} — chọn nhóm khác. -

+

) : ( -
+

Mỗi phiếu Duyệt NCC lập được 1 kế hoạch cho MỖI nhóm duyệt -

+

)}
-
- - setApprovalWorkflowId(e.target.value)} - className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm" > - + {workflows.data?.map((w) => ( ))} - -
- Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn -
+ + {workflows.data && workflows.data.length === 0 ? ( +

+ ⚠ Chưa có quy trình duyệt "Kế hoạch ký kết HĐ" nào được ghim cho user chọn. Liên hệ admin tạo trước. +

+ ) : ( +

+ Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn +

+ )}
-
+
- {/* Section 3: Ghi chú */} -
-
- - +
+ +