// [W2 KHKK — S161 2026-07-29] Danh sách "Kế hoạch ký kết HĐ" (GĐ2). // Khuôn `pages/office/ProposalsListPage.tsx` (PURO layout: ui/PageHeader + hàng // KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với // fe-admin counterpart. // // URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis): // Khkk_List → /khkk/list // Khkk_Pending → /khkk/list?filter=ChoDuyet // Khkk_Approved → /khkk/list?filter=DaDuyet // Khkk_Deleted → /khkk/list?view=deleted // 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng // nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc). import { useMemo, useState, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' import { Plus, Search, FileCheck, FileEdit, SendHorizontal, CheckCircle2, Undo2, XCircle, Layers, Inbox, Trash2, } from 'lucide-react' import { PageHeader } from '@/components/ui/PageHeader' import { KpiCard } from '@/components/ui/KpiCard' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KhkkPhase, type KhkkListItemDto, type KhkkPhaseValue, type PagedResult, } from '@/types/khkk' const PAGE_SIZE = 20 // Tên phase trong query `?filter=` → int (URL đọc được, không dính số ma thuật). const FILTER_TO_PHASE: Record = { DangSoanThao: KhkkPhase.DangSoanThao, ChoDuyet: KhkkPhase.ChoDuyet, DaDuyet: KhkkPhase.DaDuyet, TraLai: KhkkPhase.TraLai, TuChoi: KhkkPhase.TuChoi, } function formatVnd(n: number | null): string { if (n === null || n === undefined) return '—' return n.toLocaleString('vi-VN') + ' đ' } function formatDate(iso: string): string { const d = new Date(iso) return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' }) } export function KhkkListPage() { const navigate = useNavigate() const [searchParams] = useSearchParams() const initialFilter = searchParams.get('filter') const deletedView = searchParams.get('view') === 'deleted' const [phase, setPhase] = useState( initialFilter ? (FILTER_TO_PHASE[initialFilter] ?? null) : null, ) const [search, setSearch] = useState('') const [page, setPage] = useState(1) const list = useQuery({ queryKey: ['khkk-list', { phase, search, page, deletedView }], queryFn: async () => { // Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng // `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155). const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans' return (await api.get>(url, { params: { phase: deletedView ? undefined : (phase ?? undefined), search: search.trim() || undefined, page, pageSize: PAGE_SIZE, }, })).data }, }) const items = list.data?.items ?? [] const total = list.data?.total ?? 0 const totalPages = list.data?.totalPages ?? 1 // Presentation-only: đếm trên trang đang tải (không fetch thêm). Card đang active // hiện `total` thật từ server; card khác chỉ là gợi ý nhìn nhanh. const countByPhase = useMemo(() => { const acc: Record = { 1: 0, 2: 0, 3: 0, 98: 0, 99: 0 } for (const p of items) acc[p.phase] = (acc[p.phase] ?? 0) + 1 return acc }, [items]) const phaseCards: Array<{ value: KhkkPhaseValue | null label: string icon: ReactNode accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx' count: number }> = [ { value: null, label: 'Tất cả', icon: , accent: 'brand', count: phase === null ? total : items.length }, { value: KhkkPhase.DangSoanThao, label: KHKK_PHASE_LABELS[1], icon: , accent: 'violet', count: phase === 1 ? total : countByPhase[1] }, { value: KhkkPhase.ChoDuyet, label: KHKK_PHASE_LABELS[2], icon: , accent: 'amberx', count: phase === 2 ? total : countByPhase[2] }, { value: KhkkPhase.DaDuyet, label: KHKK_PHASE_LABELS[3], icon: , accent: 'greenx', count: phase === 3 ? total : countByPhase[3] }, { value: KhkkPhase.TraLai, label: KHKK_PHASE_LABELS[98], icon: , accent: 'violet', count: phase === 98 ? total : countByPhase[98] }, { value: KhkkPhase.TuChoi, label: KHKK_PHASE_LABELS[99], icon: , accent: 'amberx', count: phase === 99 ? total : countByPhase[99] }, ] return (
: } accent="brand" actions={ deletedView ? undefined : ( ) } /> {/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng 7 cột không thổi vỡ lưới ở 1366; `
{!deletedView && (
{phaseCards.map((c) => ( { setPhase(c.value) setPage(1) }} /> ))}
)}
{ setSearch(e.target.value) setPage(1) }} placeholder="Tìm mã kế hoạch, mã phiếu hoặc tên gói thầu..." className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0" />
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 7 cột cuộn ngang trong thẻ thay vì tràn ra ngoài lưới trên laptop 1366. */}
{list.isLoading && ( )} {!list.isLoading && items.length === 0 && ( )} {items.map((k) => ( !deletedView && navigate(`/khkk/${k.id}`)} className={cn( 'border-b border-slate-100 transition last:border-0', deletedView ? 'opacity-70' : 'cursor-pointer hover:bg-brand-50/50', )} > ))}
Mã kế hoạch Gói thầu Dự án Trạng thái Tổng đề xuất Người soạn Ngày tạo
Đang tải...
{deletedView ? 'Không có phiếu đã xóa.' : 'Chưa có kế hoạch ký kết nào.'}
{k.maKeHoach ?? '—'} {k.peTenGoiThau ?? '—'} {k.peMaPhieu && {k.peMaPhieu}} {k.projectName ?? '—'} {KHKK_PHASE_LABELS[k.phase as KhkkPhaseValue]} {k.currentApprovalLevelOrder && ( Cấp {k.currentApprovalLevelOrder} )} {formatVnd(k.totalProposedAmount)} {k.drafterFullName ?? '—'} {formatDate(k.createdAt)}
{totalPages > 1 && (
{total} kế hoạch — Trang {page} / {totalPages}
)}
) }