All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m6s
User chỉ thị thay 2-button hiện tại bằng 3 hành động rõ ràng:
- Duyệt = forward phase tiếp theo
- Trả lại = về DangSoanThao + Drafter sửa → workflow tự jump tới phase
đã reject (smart reject Mig 16 pattern + clear N-stage rows)
- Từ chối = phiếu khoá hoàn toàn (Phase=TuChoi → 17 handler Mig 16 lock
edit). Drafter phải tạo phiếu mới.
Domain (PurchaseEvaluationPolicy.cs):
- NccOnly + NccWithPlan: thêm (X → TuChoi) transition cho mọi phase
trung gian (ChoPurchasing/ChoCCM/ChoCEODuyetNCC/ChoDuAn/ChoCEODuyetPA)
với roles của phase đó. Trước đây chỉ DangSoanThao → TuChoi (Drafter).
- FromDefinition expand: mỗi step (trừ DangSoanThao) thêm
(step.Phase → TuChoi) với roles của step.
Service (PurchaseEvaluationWorkflowService.cs):
- Reject branch tách 2 case:
* target=TuChoi → giữ nguyên (KHÔNG override + KHÔNG set
RejectedFromPhase + KHÔNG clear N-stage rows). Phiếu khoá vĩnh viễn.
* target khác (thường DangSoanThao) → smart reject (set
RejectedFromPhase + force DangSoanThao + clear N-stage rows).
FE (PeWorkflowPanel.tsx, fe-admin + fe-user mirror):
- next.phases render 3 button rõ ràng:
* "✓ Duyệt → <label>" brand (forward)
* "← Trả lại (về Drafter sửa)" red (target=DangSoanThao + isSendBack)
* "✗ Hủy / Từ chối" red (target=TuChoi)
- Decision logic: target=TuChoi || isSendBack → Reject (2), else Approve (1)
- Dialog confirm:
* Title rõ theo loại hành động
* Cancel case: warning red "Phiếu sẽ bị khoá hoàn toàn"
* SendBack case: hint amber "Phiếu sẽ về Đang soạn thảo, Drafter sửa
rồi trình lại — workflow tự jump tới phase này"
Tests update + add 1 test mới:
- Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao →
Reject_To_DangSoanThao_Sets_RejectedFromPhase_TraLai (rename + change
target từ TuChoi → DangSoanThao để test Trả lại pattern)
- + Reject_To_TuChoi_Locks_Permanently_No_RejectedFromPhase (NEW test
Từ chối — phase=TuChoi + RejectedFromPhase null)
- NStage_Reject_Clears_InnerStep_Rows_At_Phase: target TuChoi →
DangSoanThao (test Trả lại + clear N-stage rows pattern)
Verify:
- dotnet build 0 error
- dotnet test 95 → **96 pass** (+1 test mới Từ chối)
- npm build fe-admin + fe-user pass
Pending Task 2: Sample data seed N-stage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
// Panel 3: workflow timeline + transition buttons + approval history + changelog.
|
||
// Pulls nextPhases từ BE bundle (single source of truth) → render per-phase
|
||
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
||
// dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items).
|
||
import { useState } from 'react'
|
||
import { useMutation, useQuery, 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 {
|
||
ApprovalStage,
|
||
PurchaseEvaluationPhase,
|
||
PurchaseEvaluationPhaseColor,
|
||
PurchaseEvaluationPhaseLabel,
|
||
type PeDepartmentApproval,
|
||
type PeDetailBundle,
|
||
} from '@/types/purchaseEvaluation'
|
||
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
||
|
||
export function PeWorkflowPanel({
|
||
evaluation,
|
||
readOnly = false,
|
||
}: {
|
||
evaluation: PeDetailBundle
|
||
/** true = ẩn Chuyển tiếp + Dialog transition (dùng cho Danh sách, không dùng Duyệt). */
|
||
readOnly?: boolean
|
||
}) {
|
||
const [target, setTarget] = useState<number | null>(null)
|
||
const [comment, setComment] = useState('')
|
||
const qc = useQueryClient()
|
||
|
||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
|
||
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
|
||
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({
|
||
queryKey: ['pe-dept-approvals', evaluation.id],
|
||
queryFn: async () => {
|
||
const r = await api.get(`/purchase-evaluations/${evaluation.id}/department-approvals`)
|
||
return r.data
|
||
},
|
||
})
|
||
|
||
const transition = useMutation({
|
||
mutationFn: async () => {
|
||
// Decision = Reject (2) khi:
|
||
// - target = TuChoi (huỷ phiếu)
|
||
// - target = DangSoanThao từ phase trung gian (= Trả lại — smart reject Mig 16
|
||
// set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back)
|
||
const isReject = target === PurchaseEvaluationPhase.TuChoi
|
||
|| (target === PurchaseEvaluationPhase.DangSoanThao
|
||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||
targetPhase: target,
|
||
decision: isReject ? 2 : 1,
|
||
comment: comment || null,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('Đã chuyển phase.')
|
||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
||
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 && !readOnly && (
|
||
<div>
|
||
<Label className="text-xs">Hành động:</Label>
|
||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||
{next.map(p => {
|
||
// Phân loại button theo hành động:
|
||
// - Trả lại = về DangSoanThao (từ phase trung gian) — red
|
||
// - Hủy/Từ chối = TuChoi (chỉ ở phase DangSoanThao đầu) — red
|
||
// - Duyệt = forward phase tiếp theo — brand
|
||
const isSendBack = p === PurchaseEvaluationPhase.DangSoanThao
|
||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
||
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
||
const isDanger = isSendBack || isCancel
|
||
const label = isSendBack
|
||
? '← Trả lại (về Drafter sửa)'
|
||
: isCancel
|
||
? '✗ Hủy / Từ chối'
|
||
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
|
||
return (
|
||
<button
|
||
key={p}
|
||
onClick={() => setTarget(p)}
|
||
className={cn(
|
||
'rounded border px-2 py-1 text-[11px] font-medium transition',
|
||
isDanger
|
||
? 'border-red-200 text-red-700 hover:bg-red-50'
|
||
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{readOnly && next.length > 0 && (
|
||
<div className="rounded border border-dashed border-slate-200 px-3 py-2 text-[11px] text-slate-500">
|
||
Vào menu “Duyệt” để chuyển phase.
|
||
</div>
|
||
)}
|
||
|
||
{target !== null && (() => {
|
||
const isCancel = target === PurchaseEvaluationPhase.TuChoi
|
||
const isSendBack = target === PurchaseEvaluationPhase.DangSoanThao
|
||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
||
const dialogTitle = isCancel
|
||
? '✗ Từ chối phiếu (khoá hoàn toàn)'
|
||
: isSendBack
|
||
? '← Trả lại Drafter sửa'
|
||
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
|
||
return (
|
||
<Dialog
|
||
open
|
||
onClose={() => setTarget(null)}
|
||
title={dialogTitle}
|
||
footer={<>
|
||
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
|
||
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
|
||
</>}
|
||
>
|
||
{isCancel && (
|
||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||
⚠ Phiếu sẽ bị khoá hoàn toàn (không edit/transition được nữa). Drafter cần tạo phiếu mới nếu muốn làm lại.
|
||
</div>
|
||
)}
|
||
{isSendBack && (
|
||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||
Phiếu sẽ về “Đang soạn thảo”. Drafter có thể sửa rồi trình lại — workflow tự jump tới phase này.
|
||
</div>
|
||
)}
|
||
<Label>Ghi chú (tùy chọn)</Label>
|
||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||
</Dialog>
|
||
)
|
||
})()}
|
||
|
||
{deptApprovals.length > 0 && (
|
||
<div className="border-t border-slate-200 pt-4">
|
||
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />
|
||
</div>
|
||
)}
|
||
|
||
<div className="border-t border-slate-200 pt-4">
|
||
<PeApprovalsSection ev={evaluation} />
|
||
</div>
|
||
|
||
<div className="border-t border-slate-200 pt-4">
|
||
<PeHistorySection ev={evaluation} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 2-stage dept approval timeline (Migration 16). Group by phase × dept,
|
||
// show Review NV row + Confirm TPB row. Highlight pending khi current phase
|
||
// có Review nhưng chưa Confirm → user biết "đang chờ TPB confirm".
|
||
function DeptApprovalsSection({
|
||
rows,
|
||
currentPhase,
|
||
}: {
|
||
rows: PeDepartmentApproval[]
|
||
currentPhase: number
|
||
}) {
|
||
// Group: phase → dept → stages
|
||
const grouped = new Map<number, Map<string, PeDepartmentApproval[]>>()
|
||
for (const r of rows) {
|
||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||
const byDept = grouped.get(r.phaseAtApproval)!
|
||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||
byDept.get(r.departmentId)!.push(r)
|
||
}
|
||
// Order: phase asc
|
||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||
|
||
return (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
|
||
<p className="mt-0.5 text-[11px] text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||
<div className="mt-2 space-y-3">
|
||
{phaseOrder.map(phase => {
|
||
const byDept = grouped.get(phase)!
|
||
return (
|
||
<div key={phase}>
|
||
<div className={cn(
|
||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||
PurchaseEvaluationPhaseColor[phase],
|
||
)}>
|
||
{PurchaseEvaluationPhaseLabel[phase]}
|
||
</div>
|
||
<div className="mt-1 space-y-1.5">
|
||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||
const isPending = phase === currentPhase && review && !confirm
|
||
return (
|
||
<div key={deptId} className={cn(
|
||
'rounded border px-2 py-1.5 text-[11px]',
|
||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||
)}>
|
||
<div className="font-medium text-slate-700">{deptName}</div>
|
||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||
<span className="text-slate-500">Review:</span>
|
||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||
{review
|
||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||
: '— chưa có'}
|
||
</span>
|
||
<span className="text-slate-500">Confirm:</span>
|
||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||
{confirm
|
||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||
: '⏳ chờ TPB confirm'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function fmtTime(iso: string): string {
|
||
const d = new Date(iso)
|
||
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
|
||
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
|
||
}
|