// [S171 KHKK 3-panel] PANEL 1 của màn "Kế hoạch ký kết HĐ" (GĐ2) — mirror bố-cục
// Duyệt NCC (GĐ1) / Hợp đồng (GĐ3): khối filter trên cùng + thân danh sách cuộn riêng.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 KHỐI Ý-KIẾN / SƠ-ĐỒ DUYỆT 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 = đẻ `if (module === …)`.
//
// ══ OWNER CHỐT 2026-08-04 (QĐ-1): panel 1 = CÂY TOÀN TRÌNH `PipelineTreePanel currentStage={2}`.
// Chọn HÌNH ≠ chấp-nhận VỠ ⇒ 4 mitigation dưới đây là BẮT BUỘC, không phải tuỳ chọn:
// M-1 `canPe` (`usePipelineStages.ts:83`) khoá cây bằng 3 key của Duyệt NCC. Người CHỈ có
// quyền KHKK (vd người duyệt nhóm N5) sẽ thấy cây trống trong khi API
// `GET /contract-signing-plans` (policy `KeHoachKyKet.Read`) VẪN cho họ đọc — đúng mặt
// còn lại của gotcha #82. ⇒ `!canPe` FALLBACK sang DANH-SÁCH PHẲNG cùng ô này.
// Cây là lớp LÀM GIÀU, KHÔNG phải điều-kiện để thấy phiếu.
// M-2 Leaf/card KHKK trên cây KHÔNG `navigate('/khkk/:id')` nữa mà gọi `onSelect` của host
// (`usePipelineStages.ts` `onOpenKhkk`) — không thì bấm 1 cái là văng khỏi màn 3-panel.
// B1 Truyền `stage2Content` PHẢI kèm `stage2Count` (`PipelineStageFolders.tsx:95-97`).
// Trade-off khai thẳng: trong cây của trang này, folder GĐ2 hiện CARD PHIẾU thay cho
// 8 ngăn N1-N8; lối vào nhóm giữ ở `Select` nhóm ngay bên trên.
// R-11 Cây chỉ tải 200 phiếu PE mới nhất (`PipelineTreePanel.tsx:22`) và KHÔNG có "trang 2".
// ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans`
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
import { useQuery } from '@tanstack/react-query'
import { FileCheck, Inbox, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
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,
} from '@/types/khkk'
const PAGE_SIZE = 20
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', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
/**
* [F-2] Chip "⚑ Kết thúc · {Bước}" — khuôn `components/pe/PeFinalizeChip.tsx` (S89/S97),
* dựng lại ở đây thay vì import vì nguồn cờ khác module (KHKK đọc
* `endedByLevelFinalize` + `finalizeStepName`, PE đọc `endsBeforeCeo`).
*/
export function KhkkFinalizeChip({
endedByLevelFinalize,
finalizeStepName,
phase,
}: {
endedByLevelFinalize: boolean
finalizeStepName: string | null
phase?: number
}) {
if (!endedByLevelFinalize) {
if (phase === KhkkPhase.DaDuyet) {
return (
✅ Duyệt hết quy trình
)
}
return null
}
return (
{finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Kết thúc sớm'}
)
}
/**
* Card 3 hàng — thay cho bảng 8 cột + 6 KpiCard của bản cũ (bảng "cột nào chết, chuyển
* đi đâu" nằm ở `sub-implementer-frontend-s171.md` §4).
* [M-8] Σ tiền in MỘT số, suy từ `phase === DaDuyet` (đúng như BE suy `budgetFrozen`) —
* KHÔNG thêm field vào `KhkkListItemDto`, vì `budgetFrozen` chỉ có ở DetailDto.
*/
export function KhkkPlanCard({
plan,
selected,
onSelect,
finalize,
}: {
plan: KhkkListItemDto
selected: boolean
onSelect: (id: string) => void
/** Chỉ có khi phiếu ĐANG được chọn (detail đã tải) — list payload không mang cờ này. */
finalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
}) {
const frozen = plan.phase === KhkkPhase.DaDuyet
const amount = frozen ? (plan.totalApprovedAmount ?? plan.totalProposedAmount) : plan.totalProposedAmount
return (
)
}
export function KhkkListPanel({
selectedId,
onSelect,
search,
onSearchChange,
phase,
onPhaseChange,
group,
onGroupChange,
deletedView,
page,
onPageChange,
selectedFinalize,
}: {
selectedId: string | null
onSelect: (id: string) => void
search: string
onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void
group: number | null
onGroupChange: (v: number | null) => void
deletedView: boolean
page: number
onPageChange: (p: number) => void
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
}) {
const { canPe } = usePipelineStages()
const term = search.trim()
// [R-11] Lọc đi SERVER-SIDE, KHÔNG lọc trên cây: có từ khoá / có trạng-thái / xem thùng
// rác ⇒ chuyển sang danh-sách phẳng có phân-trang thật (`total` từ server) nên phiếu nằm
// ngoài 200 phiếu mới nhất VẪN tới được.
// [M-1] `!canPe` ⇒ cũng dùng đúng danh-sách phẳng này — cây khoá không được phép làm
// người dùng mất trắng phiếu của chính họ.
const flatMode = !canPe || deletedView || term.length > 0 || phase !== null
const list = useQuery({
queryKey: ['khkk-list', { group, phase, search: term, page, deletedView }],
queryFn: async () => {
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
return (await api.get>(url, {
params: {
phase: deletedView ? undefined : (phase ?? undefined),
group: group ?? undefined,
search: term || undefined,
page,
pageSize: PAGE_SIZE,
},
})).data
},
enabled: flatMode,
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
// [B1] Thân folder GĐ2 của cây = CARD phiếu của chính trang này; `Select` nhóm lọc luôn
// trên cây (client-side, an toàn: đây chỉ là chia lại đúng tập phiếu cây đã có).
const stage2Content = (plans: KhkkListItemDto[]) => {
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) {
return (
{group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`}