[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:
pqhuy1987
2026-04-23 16:56:26 +07:00
parent 4678d192e2
commit a737196b21
16 changed files with 2726 additions and 1 deletions

View File

@ -0,0 +1,124 @@
// Panel 3: workflow + transition buttons. Pulls nextPhases từ BE bundle
// (single source of truth) → render per-phase action button.
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
const qc = useQueryClient()
const transition = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target,
decision: target === PurchaseEvaluationPhase.TuChoi ? 2 : 1,
comment: comment || null,
}),
onSuccess: () => {
toast.success('Đã chuyển phase.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setTarget(null)
setComment('')
},
onError: e => toast.error(getErrorMessage(e)),
})
const next = evaluation.workflow.nextPhases
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình</h3>
<p className="mt-0.5 text-[11px] text-slate-500">{evaluation.workflow.policyDescription}</p>
</div>
<ol className="space-y-1.5">
{evaluation.workflow.activePhases
.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
.map(p => {
const isCurrent = evaluation.phase === p
const isPast = isPastPhase(evaluation.phase, p, evaluation.workflow.activePhases)
return (
<li key={p}>
<div
className={cn(
'flex items-center gap-2 rounded border px-2 py-1.5 text-xs',
isCurrent && 'border-brand-300 bg-brand-50 font-medium',
isPast && 'border-emerald-200 bg-emerald-50 text-emerald-700',
!isCurrent && !isPast && 'border-slate-200 text-slate-500',
)}
>
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', PurchaseEvaluationPhaseColor[p])}>
{p}
</span>
<span className="truncate">{PurchaseEvaluationPhaseLabel[p]}</span>
{isCurrent && <span className="ml-auto text-[10px] text-brand-700"> hiện tại</span>}
{isPast && <span className="ml-auto text-[10px] text-emerald-600"></span>}
</div>
</li>
)
})}
</ol>
{next.length > 0 && (
<div>
<Label className="text-xs">Chuyển tiếp:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{next.map(p => (
<button
key={p}
onClick={() => setTarget(p)}
className={cn(
'rounded border px-2 py-1 text-[11px] transition',
p === PurchaseEvaluationPhase.TuChoi
? 'border-red-200 text-red-700 hover:bg-red-50'
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
)}
>
{PurchaseEvaluationPhaseLabel[p]}
</button>
))}
</div>
</div>
)}
{target !== null && (
<Dialog
open
onClose={() => setTarget(null)}
title={`Chuyển → ${PurchaseEvaluationPhaseLabel[target]}`}
footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
</>}
>
<Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog>
)}
</div>
)
}
function isPastPhase(current: number, p: number, active: number[]): boolean {
const orderedIdx = active.indexOf(p)
const currentIdx = active.indexOf(current)
if (orderedIdx < 0 || currentIdx < 0) return false
return orderedIdx < currentIdx && p !== PurchaseEvaluationPhase.TuChoi
}