[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

- 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:
pqhuy1987
2026-07-29 21:19:41 +07:00
parent 914c5a40f2
commit 6cbc6ad937
20 changed files with 5020 additions and 23 deletions

View 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"> 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>
)
}