[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s
- BE mega-file ContractSigningPlanFeatures (Create 4-rao + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller 15 action policy per-action 15/15 (KeHoachKyKet.*) - FE pages/khkk 3 page + types x2 app SHA-pair + 4-place (6 leaf Khkk_* het coming-soon; WfView: user=matrix?type=10 [whitelist +10] / admin=Designer deep-link); root KeHoachKyKet -> /khkk/list - Reviewer adversarial: 12 finding (3 CRITICAL dut-2-bo FE-BE: route picker + peId body + dossier planId; 2 MAJOR security: detail+download 0 rao -> EnsureCanViewAsync mirror PE S89 nhap-rieng-tu; PUT-vs-POST de-ban-sao; peTenGoiThau; winnerSupplierNames; Content-Disposition RFC6266; LEFT-join Projects) -> 11 FIXED + 1 GIU (F-10 ke thua khuon PE, bit dong bo sau) - tests: +8 (574 total 0 fail) — authz reflection dong + Create/Delete/guard/khong-dot-ma-khi-409 + fixture-co-interceptor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
265
fe-user/src/pages/khkk/KhkkListPage.tsx
Normal file
265
fe-user/src/pages/khkk/KhkkListPage.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
// [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 { 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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{!deletedView && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg: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)' }}>
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user