All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m17s
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.
252 lines
10 KiB
TypeScript
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 HĐ</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)
|
|
}
|