Files
solution-erp/fe-admin/src/components/pe/PeListPanel.tsx
pqhuy1987 ff21120c8c
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m17s
[CLAUDE] Workflow: State machine 5 trạng thái — Trả lại = Phase riêng
Session 17 spec: chốt 5 trạng thái phiếu PE/HĐ/Budget theo state diagram:
  Nháp ─trình──► Đã gửi duyệt ─approve cấp cuối──► Đã duyệt (terminal)
                              ├─ Trả lại ────────► Trả lại
                              └─ Từ chối ────────► Từ chối (terminal)
  Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu)

Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại = Phase
RIÊNG (TraLai=98), không revert về DangSoanThao + không jump-back step.
Drafter từ TraLai gửi lại như case Nháp — workflow chạy lại từ Cấp 1
Bước 1 (Option A diagram chốt với user).

BE Domain:
- ContractPhase + TraLai = 98
- BudgetPhase + TraLai = 98
- PurchaseEvaluationPhase: TraLai=98 đổi từ [LEGACY deprecated] thành
  primary state. Comment update enum docs cho cả 3.

BE Policy (PE/HĐ/Budget):
- Reject transitions trỏ về TraLai (thay DangSoanThao)
- Mirror entry transitions: TraLai → next phase (cho Drafter resubmit)
- ActivePhases thêm TraLai
- FromDefinition mirror: TraLai → step.Phase + reject → TraLai
- DefaultSla cho TraLai = same as DangSoanThao

BE Service (PE + Contract):
- Reject branch: target=TuChoi giữ; else set Phase=TraLai, clear
  CurrentWorkflowStepIndex=null
- Bỏ ResumeAfterReject branch + RejectedAtStepIndex/RejectedFromPhase
  assignment (DB column giữ deprecated cho data cũ)
- Drafter trình branch: từ DangSoanThao HOẶC TraLai → ChoDuyet, init
  CurrentWorkflowStepIndex=0 (cùng entry point, chạy lại từ đầu)
- Notification: TraLai when fromPhase=ChoDuyet → "bị trả lại"
- Budget Handler: simplify reject → TraLai, bỏ smart-reject + isResuming

BE Tests update:
- WorkflowPolicyTests: Standard_RejectFromCCM → TraLai (rename + assert)
  + Standard_TraLai_To_DangGopY_Allowed_For_Drafter (new)
- PurchaseEvaluationPolicyTests: BothPolicies_RejectFromCCM → TraLai
  + BothPolicies_TraLai_To_ChoPurchasing_AllowedForDrafter (new theory)
- BudgetPolicyTests: Default_CostControl_ChoCCM_To_TraLai (rename)
  + ActivePhases All6States (was All5) + NextPhasesFrom_TraLai (new)
  + NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_TraLai (rename)
- 77 → 81 test pass (+4 tests TraLai entry point)

FE rename "Bản nháp" → "Nháp" (cả 2 app + types):
- types/purchaseEvaluation.ts: PurchaseEvaluationPhaseLabel 1=Nháp,
  10=Đã gửi duyệt. PeDisplayStatus.BanNhap → Nhap (key + value).
  PhaseLabel/Color cho TraLai update active.
- types/contracts.ts: +ChoDuyet=10, +TraLai=98 const + label/color.
  Phase 2 'Đang soạn thảo' → 'Nháp'.
- types/budget.ts: +TraLai=98 const + label/color. Phase 1 → 'Nháp'.
- PeListPanel + PurchaseEvaluationsListPage filter dropdown: Nhap +
  TraLai map đúng phase value.

BE label maps update consistent:
- ContractExcelExporter PhaseLabel: DangSoanThao → "Nháp" + add ChoDuyet/
  TraLai entries.
- PeWorkflowAdminFeatures + WorkflowAdminFeatures PhaseLabels: same.

Verify: dotnet test 81 pass · npm build × 2 app pass · BE 0 error.

Field RejectedAtStepIndex/RejectedFromPhase giữ DB column (nullable,
không set value mới). Cleanup migration sau.
2026-05-08 14:12:38 +07:00

252 lines
10 KiB
TypeScript

// 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, Pencil, 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 {
PeDisplayStatus,
PeDisplayStatusColor,
PeDisplayStatusLabel,
PurchaseEvaluationPhase,
PurchaseEvaluationTypeLabel,
getPeDisplayStatus,
isEditablePhase,
type PeListItem,
} from '@/types/purchaseEvaluation'
export function PeListPanel({
typeFilter,
pendingMe = false,
selectedId,
search,
phase,
onSelect,
onSearchChange,
onPhaseChange,
showCreateButton = false,
onCreate,
onEditClick,
editableOnly = false,
editingRowId = null,
}: {
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
/** Pencil edit icon hover next-to-row — click → select + auto-open Section 1 edit mode (URL ?editHeader=1). */
onEditClick?: (id: string) => void
/** Workspace mode: chỉ list phiếu editable (DangSoanThao + TraLai). Filter
* client-side sau khi fetch — BE chưa hỗ trợ multi-phase param. */
editableOnly?: boolean
/** Row đang được edit (URL editHeader=1) — pencil icon "sáng lên" active state.
* User 2026-05-07: visual feedback khi click pencil. */
editingRowId?: string | null
}) {
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 allRows = list.data?.items ?? []
const rows = editableOnly
? allRows.filter(p => isEditablePhase(p.phase))
: allRows
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">
{rows.length}
</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>
{editableOnly ? (
<div className="rounded border border-slate-200 bg-slate-50 px-2 py-1.5 text-[11px] text-slate-600">
Lọc cố đnh: <strong>Bản nháp + Trả lại</strong> (chỉ phiếu sửa đưc)
</div>
) : (
<Select value={phase} onChange={e => onPhaseChange(e.target.value)}>
<option value="">Tất cả trạng thái</option>
{Object.values(PeDisplayStatus).map(s => (
<option key={s} value={statusToPhaseValue(s)}>{PeDisplayStatusLabel[s]}</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} className="group relative">
<button
onClick={() => onSelect(p.id)}
className={cn(
'block w-full px-3 py-2.5 pr-9 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>
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
{/* Edit pencil — LUÔN visible (user 2026-05-07).
Bright/active khi phase editable (DangSoanThao + TraLai).
Dim/disabled khi phase không edit được (Đã gửi duyệt / Đã duyệt
/ Từ chối) — click không có tác dụng.
"Sáng lên" active state khi row.id === editingRowId (user
vừa click pencil + đang edit) — bg-brand-100 + ring. */}
{onEditClick && (() => {
const editable = isEditablePhase(p.phase)
const isEditingThis = editable && editingRowId === p.id
return (
<button
onClick={() => editable && onEditClick(p.id)}
disabled={!editable}
className={cn(
'absolute right-2 top-2 rounded p-1.5 transition',
isEditingThis
? 'bg-brand-100 text-brand-700 shadow-sm ring-1 ring-brand-300 cursor-pointer'
: editable
? 'text-brand-600 hover:bg-brand-50 hover:shadow-sm cursor-pointer'
: 'text-slate-300 cursor-not-allowed',
)}
title={isEditingThis
? '✎ Đang sửa phiếu này — click để toggle / xem khác'
: editable
? 'Sửa phiếu (header + chi tiết)'
: 'Phiếu đã gửi duyệt / đã duyệt / từ chối — không sửa được'}
>
<Pencil className="h-3.5 w-3.5" />
</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>
)
}
// Map display status → phase enum cho filter dropdown.
// Bản nháp = DangSoanThao (1), Đã duyệt = DaDuyet (7), Từ chối = TuChoi (99).
// Đã gửi duyệt = không filter exact phase (cần BE hỗ trợ multi-phase filter
// hoặc filter client-side). Tạm thời: trả về '' (không filter) → list show
// hết, user vẫn thấy được phiếu Đã gửi duyệt cùng với tất cả khác. Trade-off
// chấp nhận tới khi BE thêm multi-phase param.
function statusToPhaseValue(status: PeDisplayStatus): string {
if (status === PeDisplayStatus.Nhap) return String(PurchaseEvaluationPhase.DangSoanThao)
if (status === PeDisplayStatus.DaDuyet) return String(PurchaseEvaluationPhase.DaDuyet)
if (status === PeDisplayStatus.TuChoi) return String(PurchaseEvaluationPhase.TuChoi)
return '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE add support)
}