Files
solution-erp/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
pqhuy1987 83c9f7b45d
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m10s
[CLAUDE] PurchaseEvaluation FE-Admin FE-User: Chunk L5 — PE list UX: ngày tạo thay SLA countdown + sort UpdatedAt DESC
Bro UAT S23 t2 yêu cầu 2 UX changes PE list:

1. Đổi "Còn N ngày Mh" (SlaTimer countdown) → "DD/MM/YYYY HH:mm" (ngày giờ tạo phiếu).
2. Sort: phiếu vừa update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu, phiếu cũ phía dưới.

BE changes:
- PurchaseEvaluationListItemDto +UpdatedAt: DateTime? field (auto AuditingInterceptor refresh
  mọi SaveChanges — covers Insert/Update/Transition events natural).
- ListPurchaseEvaluationsQueryHandler sort: OrderByDescending(UpdatedAt ?? CreatedAt)
  (was: OrderByDescending(CreatedAt)).
- GetMyPurchaseEvaluationInboxQueryHandler sort: OrderByDescending(UpdatedAt ?? CreatedAt)
  (was: OrderBy(SlaDeadline ?? MaxValue) — SLA priority deprecated).
- CreateContractFromEvaluationFeatures.cs: +UpdatedAt arg trong DTO ctor (compile fix
  consumer downstream).
- Select projection 3 callsites populate UpdatedAt.

FE × 2 app (mirror rule §3.9):
- PeListItem type +updatedAt: string | null (optional — null khi phiếu chưa Update).
- PurchaseEvaluationsListPage: replace <SlaTimer deadline={p.slaDeadline} ... /> với
  Vietnamese date format "{DD/MM/YYYY HH:mm}" qua Intl.DateTimeFormat (vi-VN locale,
  full date+time options). title tooltip hiện full timestamp.
- Remove SlaTimer import (unused warning).

UpdatedAt sort logic insight: AuditingInterceptor (Infrastructure) auto-refresh
UpdatedAt mọi SaveChanges → mọi event tự nhiên (Drafter tạo / Gửi duyệt từ Workspace
/ Approver duyệt Cấp tiếp / Approver trả lại / Admin override) đều bump UpdatedAt
→ phiếu vừa action lên đầu list. Phiếu mới Insert UpdatedAt=null → fallback CreatedAt
→ vẫn lên đầu (vì CreatedAt vừa now).

Verify:
- dotnet build production projects clean (0 err, 2 pre-existing warn)
- dotnet test SolutionErp.slnx 104/104 PASS (DTO change KHÔNG impact test — tests
  don't construct ListItemDto)
- npm run build × 2 app pass clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:53:19 +07:00

314 lines
14 KiB
TypeScript

// List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history.
// URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck, Search, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { EmptyState } from '@/components/EmptyState'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
import {
PeDisplayStatus,
PeDisplayStatusColor,
PeDisplayStatusLabel,
PurchaseEvaluationPhase,
PurchaseEvaluationTypeLabel,
getPeDisplayStatus,
type PeDetailBundle,
type PeListItem,
} from '@/types/purchaseEvaluation'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
export function PurchaseEvaluationsListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
const pendingMe = sp.get('pendingMe') === '1'
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
const selectedId = sp.get('id')
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
const approvalWorkflows = useQuery({
queryKey: ['approval-workflows-v2-filter', typeFilter],
queryFn: async () => {
if (!typeFilter) return []
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean }[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: typeFilter } },
)
return res.data.types.find(t => t.applicableType === typeFilter)?.history ?? []
},
enabled: !!typeFilter,
})
const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
queryFn: async () => {
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
params: {
type: typeFilter ?? undefined,
approvalWorkflowId: approvalWorkflowId || undefined,
},
})
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
}
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations', {
params: {
pageSize: 50,
search: search || undefined,
type: typeFilter ?? undefined,
phase: phase || undefined,
approvalWorkflowId: approvalWorkflowId || undefined,
},
})
return res.data
},
})
const detail = useQuery({
queryKey: ['pe-detail', selectedId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa phiếu.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
if (key !== 'id') next.delete('page')
setSp(next, { replace: key === 'q' })
}
function selectRow(id: string) {
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setParam('id', id)
} else {
navigate(`/purchase-evaluations/${id}`)
}
}
const allRows = list.data?.items ?? []
// Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/
// Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp →
// FE filter để UX đúng kỳ vọng. Phân quyền strict V2 BE pending Session 18+.
const rows = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
: allRows
const headerTitle = typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{pendingMe ? rows.length : (list.data?.total ?? 0)}
</span>
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
{/* Panel 1: List */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="space-y-2 border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setParam('q', e.target.value)}
placeholder="Tìm mã / tên gói thầu / dự án…"
className="pl-8"
/>
</div>
{/* Mig 23 — Dropdown quy trình duyệt CHỈ ở "Duyệt" (pendingMe).
Danh sách giữ nguyên (chỉ filter trạng thái) — user feedback
muốn Danh sách show hết phiếu không filter quy trình. */}
{pendingMe && (
<Select value={approvalWorkflowId} onChange={e => setParam('awId', e.target.value)}>
<option value="">Tất cả quy trình duyệt</option>
{approvalWorkflows.data?.map(w => (
<option key={w.id} value={w.id}>
{w.code} v{String(w.version).padStart(2, '0')} {w.name}
</option>
))}
</Select>
)}
{/* Duyệt (pendingMe) → filter cứng "Đã gửi duyệt", ẩn dropdown trạng thái.
Danh sách (pendingMe=false) → giữ dropdown cho user filter mọi trạng thái. */}
{pendingMe ? (
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
Lọc cố đnh: <strong>Đã gửi duyệt</strong> (phiếu đang chờ duyệt)
</div>
) : (
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
<option value="">Tất cả trạng thái</option>
{Object.values(PeDisplayStatus).map(s => {
const phaseValue = s === PeDisplayStatus.Nhap
? String(PurchaseEvaluationPhase.DangSoanThao)
: s === PeDisplayStatus.DaDuyet
? String(PurchaseEvaluationPhase.DaDuyet)
: s === PeDisplayStatus.TraLai
? String(PurchaseEvaluationPhase.TraLai)
: s === PeDisplayStatus.TuChoi
? String(PurchaseEvaluationPhase.TuChoi)
: '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE)
return phaseValue ? (
<option key={s} value={phaseValue}>{PeDisplayStatusLabel[s]}</option>
) : null
})}
</Select>
)}
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!list.isLoading && rows.length === 0 && (
<div className="p-6">
<EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." />
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(p => (
<li key={p.id}>
<button
onClick={() => selectRow(p.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
<span>·</span>
<span className="truncate">{p.projectName}</span>
</div>
{p.selectedSupplierName && (
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
{p.selectedSupplierName}
</div>
)}
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
)}
>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
{PurchaseEvaluationTypeLabel[p.type]}
</span>
{/* S23 t2 UAT: bro yêu cầu đổi SLA countdown → ngày giờ tạo phiếu.
BE list sort theo UpdatedAt DESC (fallback CreatedAt) — phiếu vừa
update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu list. */}
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
</li>
))}
</ul>
</div>
</aside>
{/* Panel 2: Detail tabs */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && detail.data && (
<PeDetailTabs
evaluation={detail.data}
onBack={() => setParam('id', null)}
onDelete={() => del.mutate(detail.data!.id)}
readOnly={true}
/>
)}
</main>
{/* Panel 3: Workflow + history */}
{/* Danh sách (pendingMe=false) → readOnly=true → ẩn Chuyển tiếp transition.
Duyệt (pendingMe=true) → readOnly=false → cho approver chuyển phase. */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn phiếu.
</div>
)}
{selectedId && detail.data && <PeWorkflowPanel evaluation={detail.data} readOnly={!pendingMe} />}
</aside>
</div>
</div>
)
}
// Fullpage detail route cho mobile (/purchase-evaluations/:id)
export function PurchaseEvaluationDetailPage() {
const navigate = useNavigate()
const id = location.pathname.split('/').pop()!
const detail = useQuery({
queryKey: ['pe-detail', id],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${id}`)).data,
})
const del = useMutation({
mutationFn: async () => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa.')
navigate('/purchase-evaluations')
},
})
if (detail.isLoading) return <div className="p-6 text-sm text-slate-500">Đang tải</div>
if (!detail.data) return <div className="p-6 text-sm text-red-600">Không tìm thấy phiếu.</div>
return (
<div className="space-y-4 p-6">
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
<PeWorkflowPanel evaluation={detail.data} />
</div>
)
}