[CLAUDE] FE-Admin: PE Thao tác 2-panel workspace + Panel 1 read-only picker + Section 5 disabled
Chunk 1/3 — restructure leaf "Thao tác" (Pe_*_Create) từ page tạo header riêng
sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage:
Panel 1 (320px): list pure picker (KHÔNG inline edit/delete per Q1 user) +
sticky "+ Thêm mới" bottom button.
Panel 2 (1fr): empty state | mode=new <PeHeaderForm> | <PeDetailTabs
mode="workspace"> (5 section, Section 5 Ý kiến 4PB DISABLED
per Q5 user — nhập ở leaf "Duyệt").
Workflow Panel + Approvals + History KHÔNG render trong workspace (Q1) — chỉ
hiện ở leaf "Danh sách" + "Duyệt" giữ nguyên 3-panel hiện tại (Q3).
URL: /purchase-evaluations/workspace?type={1|2}[&id=...][&mode=new][&q=][&phase=]
Menu resolver Pe_*_Create: /purchase-evaluations/new?type=N → /workspace?type=N.
Route mới /workspace; route /new giữ tồn tại cho deep-link "Sửa header" button.
Files:
+ fe-admin/src/components/pe/PeListPanel.tsx (~180 LOC) — pure picker reuseable
+ fe-admin/src/components/pe/PeHeaderForm.tsx (~210 LOC) — extract header form
+ fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx (~120 LOC)
~ fe-admin/src/components/pe/PeDetailTabs.tsx — add mode prop + Section 5 hint
~ fe-admin/src/components/Layout.tsx — resolver Pe_*_Create map workspace
~ fe-admin/src/App.tsx — route /purchase-evaluations/workspace
Verify: npm run build pass · dotnet test 83 vẫn pass (54 Domain + 29 Infra).
fe-user mirror = Chunk 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
fe-admin/src/components/pe/PeListPanel.tsx
Normal file
184
fe-admin/src/components/pe/PeListPanel.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
// Pure picker panel cho workspace 2-panel "Thao tác" (Pe_*_Create leaf).
|
||||
// KHÔNG có inline Edit/Delete (per Q1 user 2026-05-07): chỉ click để pick, +
|
||||
// optional sticky bottom "+ Thêm mới" button khi showCreateButton=true.
|
||||
//
|
||||
// Reuse-able: caller quản URL state qua props (search/phase/typeFilter), panel
|
||||
// chỉ render + invoke callbacks. Pendingme vẫn truyền được nếu cần dùng cho
|
||||
// inbox view khác (hiện chỉ workspace dùng pendingMe=false).
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ClipboardCheck, Plus, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { Paged } from '@/types/master'
|
||||
import {
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeListItem,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
|
||||
export function PeListPanel({
|
||||
typeFilter,
|
||||
pendingMe = false,
|
||||
selectedId,
|
||||
search,
|
||||
phase,
|
||||
onSelect,
|
||||
onSearchChange,
|
||||
onPhaseChange,
|
||||
showCreateButton = false,
|
||||
onCreate,
|
||||
}: {
|
||||
typeFilter: number | null
|
||||
pendingMe?: boolean
|
||||
selectedId: string | null
|
||||
search: string
|
||||
phase: string
|
||||
onSelect: (id: string) => void
|
||||
onSearchChange: (q: string) => void
|
||||
onPhaseChange: (p: string) => void
|
||||
showCreateButton?: boolean
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
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 rows = list.data?.items ?? []
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||
{/* Header — count + filter */}
|
||||
<div className="space-y-2 border-b border-slate-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
Danh sách phiếu
|
||||
</div>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
{list.data?.total ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<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 => onSearchChange(e.target.value)}
|
||||
placeholder="Tìm mã / tên gói thầu / dự án…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={phase} onChange={e => onPhaseChange(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>
|
||||
|
||||
{/* List body */}
|
||||
<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={
|
||||
showCreateButton
|
||||
? 'Bấm + Thêm mới ở dưới để tạo phiếu đầu tiên.'
|
||||
: 'Chưa có phiếu nào khớp bộ lọc.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{rows.map(p => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
onClick={() => onSelect(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>
|
||||
|
||||
{/* Sticky bottom — "+ Thêm mới" button (mirror HĐ Thầu phụ pattern) */}
|
||||
{showCreateButton && (
|
||||
<div className="border-t border-slate-200 bg-white p-2">
|
||||
<Button
|
||||
onClick={() => onCreate?.()}
|
||||
className="w-full justify-center gap-1.5 text-xs"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Thêm mới
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user