Files
solution-erp/fe-admin/src/components/contracts/WorkflowHistoryPanel.tsx
pqhuy1987 b6f5a16420 [CLAUDE] Infra+App+Api+FE: Chunk E4 — HĐ 2-stage dept approval (mirror PE)
Mở rộng 2-stage logic từ PE sang Contract workflow (Migration 16 đã có schema):

BE Service:
- ContractWorkflowService thêm UserManager<User> DI
- Mirror logic 2-stage từ PurchaseEvaluationWorkflowService.TransitionAsync
  Sau policy guard, trước gen mã HĐ:
  - User.DepartmentId != null + actor không admin/system + KHÔNG resume
    - DeptManager (TPB) → Stage=Confirm trực tiếp
    - CanBypassReview=true → Stage=Confirm + IsBypassed=true
    - Else (NV) → Stage=Review only, BLOCK transition
  - Insert ContractDepartmentApproval row (UPSERT theo UNIQUE)
  - Block transition khi chưa có Stage=Confirm:
    - Insert ContractApproval (FromPhase=ToPhase=fromPhase, [Review NV] comment)
    - Insert ContractChangelog "đã review, chờ TPB confirm"
    - Notify TPB cùng dept (UserManager filter DeptManager role)
    - Return early — phase KHÔNG đổi

App + Api:
- ContractDepartmentApprovalFeatures.cs (List query mirror PE)
- ContractsController endpoint GET /contracts/{id}/department-approvals

FE (cả fe-admin + fe-user):
- types/contracts.ts thêm ApprovalStage const + ContractDepartmentApproval type
- WorkflowHistoryPanel section "Tiến trình duyệt 2-cấp phòng ban":
  - Group by phase × dept, show Review NV + Confirm TPB
  - Highlight amber "chờ TPB confirm" khi current phase có Review chưa Confirm
  - Badge fuchsia "bypass" khi NV.CanBypassReview=true
  - Insert giữa WorkflowSummaryCard và Lịch sử duyệt
- Mirror cả 2 app (rule §3.9)

Use case mirror PE: HĐ ở phase DangGopY (P.CCM) — nv.cao (NV) duyệt thì
phase KHÔNG đổi (Review only), chờ ccm.tran (TPB) confirm mới sang DangXetDuyet.

Build: BE pass + FE pass cả 2 + 77 test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:43:05 +07:00

152 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Panel 3 cho 3-panel layout — gom WorkflowSummaryCard (timeline policy) +
// approval history (ai/khi/quyết định gì) + ContractChangelogsTab (mọi thay
// đổi header/detail/comment/attachment/transition) vào 1 stack. Dùng cho cả
// MyContracts 3-panel và ContractDetailPage fullpage.
import { ArrowRight, Clock, History, Users2 } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { PhaseBadge } from '@/components/PhaseBadge'
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
import { ContractChangelogsTab } from '@/components/contracts/ContractChangelogsTab'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
ApprovalStage,
ContractPhaseColor,
ContractPhaseLabel,
type ContractDepartmentApproval,
type ContractDetail,
} from '@/types/contracts'
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
const fmtTime = (iso: string) =>
new Date(iso).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail }) {
const { data: deptApprovals = [] } = useQuery<ContractDepartmentApproval[]>({
queryKey: ['contract-dept-approvals', c.id],
queryFn: async () => (await api.get(`/contracts/${c.id}/department-approvals`)).data,
})
return (
<div className="space-y-4">
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
{deptApprovals.length > 0 && (
<DeptApprovalsSection rows={deptApprovals} currentPhase={c.phase} />
)}
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<Clock className="h-4 w-4" />
Lịch sử duyệt ({c.approvals.length})
</h2>
<ol className="space-y-3">
{c.approvals.length === 0 && <li className="text-sm text-slate-400">Chưa lịch sử.</li>}
{c.approvals.map(a => (
<li key={a.id} className="flex gap-3">
<div className="mt-1 h-2 w-2 rounded-full bg-brand-500" />
<div className="flex-1 space-y-0.5 text-sm">
<div className="flex items-center gap-1">
<PhaseBadge phase={a.fromPhase} className="text-[10px]" />
<ArrowRight className="h-3 w-3 text-slate-400" />
<PhaseBadge phase={a.toPhase} className="text-[10px]" />
</div>
<div className="text-slate-700">{a.approverName ?? 'Hệ thống'}</div>
<div className="text-xs text-slate-500">{fmt(a.approvedAt)}</div>
{a.comment && (
<div className="mt-1 rounded bg-slate-50 px-2 py-1 text-xs text-slate-600">
{a.comment}
</div>
)}
</div>
</li>
))}
</ol>
</section>
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<History className="h-4 w-4" />
Lịch sử điều chỉnh
</h2>
<ContractChangelogsTab contractId={c.id} />
</section>
</div>
)
}
// 2-stage dept approval timeline (Migration 16). Group by phase × dept. Same
// pattern PE — block phase transition khi current phase có Review nhưng chưa
// Confirm → highlight amber để user biết "đang chờ TPB confirm".
function DeptApprovalsSection({
rows,
currentPhase,
}: {
rows: ContractDepartmentApproval[]
currentPhase: number
}) {
const grouped = new Map<number, Map<string, ContractDepartmentApproval[]>>()
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)
}
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
return (
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
<Users2 className="h-4 w-4" />
Tiến trình duyệt 2-cấp phòng ban
</h2>
<p className="mb-3 text-xs text-slate-500">NV Review TPB Confirm. Phase chỉ chuyển khi Confirm.</p>
<div className="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',
ContractPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
)}>
{ContractPhaseLabel[phase] ?? `Phase ${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>
</section>
)
}