[CLAUDE] FE-Admin+FE-User: PurchaseEvaluation pages (3-panel list + tabs detail)
Types + pages + components cho module Duyệt NCC ở cả 2 FE (copy-share). Pages: - PurchaseEvaluationsListPage: 3-panel lg:grid-cols-[340px_1fr_360px] * Panel 1: list filter theo type/phase/search + pendingMe inbox mode * Panel 2: PeDetailTabs (Thông tin/NCC/Hạng mục/Duyệt/Lịch sử) * Panel 3: PeWorkflowPanel với timeline + nextPhase buttons * Mobile fallback fullpage /purchase-evaluations/:id - PurchaseEvaluationCreatePage: form create/edit header (Type / Tên gói thầu / Dự án / Địa điểm / Mô tả / PaymentTerms JSON). Suppliers+Details+Quotes thêm sau khi save ở Detail tabs. Components: - PeDetailTabs: 5 tab + dialogs (AddSupplier/EditSupplier/DetailDialog/ QuoteDialog) + matrix N NCC × M hạng mục clickable cells + select winner - PeWorkflowPanel: policy timeline từ BE workflow.activePhases + transition confirmation dialog với comment Routes (cả 2 app): - /purchase-evaluations (+ ?type=1|2&pendingMe=1&id=...) - /purchase-evaluations/new (+ ?type / ?id để edit) - /purchase-evaluations/:id (mobile fullpage) Menu resolver: - Pe_<Code>_List → /purchase-evaluations?type=N - Pe_<Code>_Create → /purchase-evaluations/new?type=N - Pe_<Code>_Pending → /purchase-evaluations?type=N&pendingMe=1 - PeWf_<Code> (fe-admin only) → /system/pe-workflows/<code> Skip MVP: PE Workflow admin designer UI, PE Attachments. TS build pass cả 2 app.
This commit is contained in:
250
fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx
Normal file
250
fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
// 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, Plus, Search, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { Paged } from '@/types/master'
|
||||
import {
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
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 selectedId = sp.get('id')
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
|
||||
queryFn: async () => {
|
||||
if (pendingMe) {
|
||||
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
||||
params: { type: typeFilter ?? 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,
|
||||
},
|
||||
})
|
||||
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 rows = list.data?.items ?? []
|
||||
const createHref =
|
||||
typeFilter != null ? `/purchase-evaluations/new?type=${typeFilter}` : '/purchase-evaluations/new'
|
||||
|
||||
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">
|
||||
{list.data?.total ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => navigate(createHref)} className="gap-1.5 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" /> Tạo phiếu mới
|
||||
</Button>
|
||||
</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>
|
||||
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
|
||||
<option value="">Tất cả phase</option>
|
||||
{Object.values(PurchaseEvaluationPhase).map(p => (
|
||||
<option key={p} value={p}>{PurchaseEvaluationPhaseLabel[p]}</option>
|
||||
))}
|
||||
</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',
|
||||
PurchaseEvaluationPhaseColor[p.phase],
|
||||
)}
|
||||
>
|
||||
{PurchaseEvaluationPhaseLabel[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>
|
||||
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
|
||||
</div>
|
||||
{p.contractId && (
|
||||
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</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)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Panel 3: Workflow + history */}
|
||||
<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} />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user