All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
280 lines
12 KiB
TypeScript
280 lines
12 KiB
TypeScript
// [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<string, KhkkPhaseValue> = {
|
|
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<KhkkPhaseValue | null>(
|
|
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<PagedResult<KhkkListItemDto>>(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<number, number> = { 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: <Layers className="h-4 w-4" />, accent: 'brand', count: phase === null ? total : items.length },
|
|
{ value: KhkkPhase.DangSoanThao, label: KHKK_PHASE_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: phase === 1 ? total : countByPhase[1] },
|
|
{ value: KhkkPhase.ChoDuyet, label: KHKK_PHASE_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: phase === 2 ? total : countByPhase[2] },
|
|
{ value: KhkkPhase.DaDuyet, label: KHKK_PHASE_LABELS[3], icon: <CheckCircle2 className="h-4 w-4" />, accent: 'greenx', count: phase === 3 ? total : countByPhase[3] },
|
|
{ value: KhkkPhase.TraLai, label: KHKK_PHASE_LABELS[98], icon: <Undo2 className="h-4 w-4" />, accent: 'violet', count: phase === 98 ? total : countByPhase[98] },
|
|
{ value: KhkkPhase.TuChoi, label: KHKK_PHASE_LABELS[99], icon: <XCircle className="h-4 w-4" />, accent: 'amberx', count: phase === 99 ? total : countByPhase[99] },
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<PageHeader
|
|
eyebrow="Giai đoạn 2"
|
|
title={deletedView ? 'Kế hoạch ký kết HĐ — đã xóa' : 'Kế hoạch ký kết HĐ'}
|
|
subtitle={
|
|
deletedView
|
|
? 'Phiếu đã xóa mềm — chỉ xem danh sách'
|
|
: 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng'
|
|
}
|
|
icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />}
|
|
accent="brand"
|
|
actions={
|
|
deletedView ? undefined : (
|
|
<Button onClick={() => navigate('/khkk/create')}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Lập kế hoạch
|
|
</Button>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{/* [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; `<lg` cây xếp trên và tự thu gọn. */}
|
|
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
|
<PipelineTreePanel currentStage={2} />
|
|
|
|
<div className="min-w-0 space-y-5">
|
|
{!deletedView && (
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
|
{phaseCards.map((c) => (
|
|
<KpiCard
|
|
key={c.value ?? 'all'}
|
|
label={c.label}
|
|
value={c.count}
|
|
icon={c.icon}
|
|
accent={c.accent}
|
|
active={phase === c.value}
|
|
onClick={() => {
|
|
setPhase(c.value)
|
|
setPage(1)
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
|
<Search className="h-4 w-4 shrink-0 text-slate-400" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => {
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
|
{/* [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. */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50/70">
|
|
<tr>
|
|
<th className="label-eyebrow px-4 py-2.5 text-left">Mã kế hoạch</th>
|
|
<th className="label-eyebrow px-4 py-2.5 text-left">Gói thầu</th>
|
|
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
|
|
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
|
|
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đề xuất</th>
|
|
<th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
|
|
<th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{list.isLoading && (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
|
Đang tải...
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{!list.isLoading && items.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
|
|
<span
|
|
className="icon-chip mx-auto mb-2 flex"
|
|
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
|
|
aria-hidden
|
|
>
|
|
<Inbox className="h-4 w-4" />
|
|
</span>
|
|
{deletedView ? 'Không có phiếu đã xóa.' : 'Chưa có kế hoạch ký kết nào.'}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{items.map((k) => (
|
|
<tr
|
|
key={k.id}
|
|
onClick={() => !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',
|
|
)}
|
|
>
|
|
<td className="px-4 py-2.5">
|
|
<span className="inline-flex items-center gap-2">
|
|
<span
|
|
className="icon-chip h-7! w-7!"
|
|
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
|
aria-hidden
|
|
>
|
|
<FileCheck className="h-3.5 w-3.5" />
|
|
</span>
|
|
<span className="font-mono text-xs text-brand-800">{k.maKeHoach ?? '—'}</span>
|
|
</span>
|
|
</td>
|
|
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-brand-800">
|
|
{k.peTenGoiThau ?? '—'}
|
|
{k.peMaPhieu && <span className="ml-2 font-mono text-[11px] text-slate-400">{k.peMaPhieu}</span>}
|
|
</td>
|
|
<td className="max-w-[14rem] truncate px-4 py-2.5 text-xs text-slate-600">{k.projectName ?? '—'}</td>
|
|
<td className="px-4 py-2.5">
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
|
KHKK_PHASE_BADGE[k.phase as KhkkPhaseValue],
|
|
)}
|
|
>
|
|
{KHKK_PHASE_LABELS[k.phase as KhkkPhaseValue]}
|
|
</span>
|
|
{k.currentApprovalLevelOrder && (
|
|
<span className="ml-2 text-xs text-slate-500">Cấp {k.currentApprovalLevelOrder}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(k.totalProposedAmount)}</td>
|
|
<td className="px-4 py-2.5 text-xs text-slate-600">{k.drafterFullName ?? '—'}</td>
|
|
<td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(k.createdAt)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
|
<div className="text-slate-500">
|
|
{total} kế hoạch — Trang {page} / {totalPages}
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
|
Trước
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
|
Sau
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|