Files
solution-erp/fe-admin/src/components/pe/PeWorkflowPanel.tsx
pqhuy1987 d27caafcf5 [CLAUDE] FE-PE: Chunk D — eOffice Trả lại modes + Skip CEO + Approver edit Section 2 (F1+F2+F3) mirror 2 app
Types (fe-{admin,user}/src/types/purchaseEvaluation.ts):
- ApprovalWorkflowOptions type (6 boolean Allow* flag)
- WorkflowReturnMode const-object {OneLevel,OneStep,Assignee,Drafter}
- PeDetailBundle +workflowOptions field (null nếu V1 legacy)

PeWorkflowPanel.tsx F1 (mirror 2 app):
- State returnMode + returnTargetUserId thêm vào transition mutation payload
- Dialog Trả lại render radio list 1-4 mode enabled theo workflowOptions:
  • Trả về 1 Cấp trước (lùi pointer trong cùng Bước, peer review)
  • Trả về 1 Bước trước (Cấp cuối Bước trước nhận lại)
  • Trả về Người chỉ định (pick từ dropdown NV đã ký levelOpinions)
  • Trả về Người soạn thảo (default Drafter S17 fallback)
- Banner amber rounded box dưới radio list mô tả hành vi mode chọn
- onSuccess reset returnMode về Drafter + returnTargetUserId null

PeDetailTabs.tsx F2 (mirror 2 app):
- State skipToFinal + allowSkipToFinal (từ workflowOptions)
- submitForApproval mutationFn accept opts.skipToFinal → POST body
- Workspace action bar: thêm checkbox violet "Gửi thẳng Cấp cuối (skip trung gian)"
  hiển thị conditional theo allowSkipToFinal + canSubmitForApproval
- Confirm dialog message dynamic: "Gửi thẳng" warning vs default tuần tự
- Button label dynamic: "Lưu & Gửi thẳng CẤP CUỐI →" vs "Lưu & Gửi Duyệt →"

PeDetailTabs.tsx F3 (mirror 2 app):
- useAuth import + compute approverEditMode (phase=ChoDuyet +
  workflow.AllowApproverEditDetails + actor match currentApproval.approvers)
- itemsReadOnly = readOnly && !approverEditMode → ItemsTab nhận
- Banner violet "ⓘ Bạn được phép chỉnh sửa Hạng mục/NCC/Báo giá" khi
  approverEditMode + readOnly (Duyệt menu) — UX nhắc về quyền extended

InfoTab + NccSelectorRow + BudgetFieldRow GIỮ strict isEditablePhase (KHÔNG
trong F3 scope — Header section + Section 3 winner KHÔNG cho Approver edit).

Verify:
- npm run build × 2 app pass (fe-user 7.52s, fe-admin 499ms cached)
- 0 TS6 err, warning chunk size pre-existing
- BE Chunk B đã accept skipToFinal + returnMode + returnTargetUserId trong
  TransitionPurchaseEvaluationCommand → wire E2E complete

Pending Chunk E: Docs schema-diagram §14 update + STATUS + HANDOFF + session log.

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

500 lines
26 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: 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 { useAuth } from '@/contexts/AuthContext'
import {
ApprovalStage,
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
WorkflowReturnMode,
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('')
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(null)
const qc = useQueryClient()
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// Mig 28 — F1 workflow options. Null nếu V1 legacy → fallback chỉ "Trả về Drafter".
const wfOptions = evaluation.workflowOptions
// List approvers đã ký (cho mode Assignee dropdown pick)
const signedApprovers = (evaluation.levelOpinions ?? [])
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
// duyệt cấp hiện tại. Nếu actor không khớp → disable nút "Duyệt forward"
// (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2
// hành động này — Approver có thể reject bất cứ lúc nào trong phiên).
// Admin bypass.
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorInV2Level = isAdmin
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
// V2 active = phiếu pin V2 + Phase=ChoDuyet + có currentApproval
const isV2Pending = !!evaluation.currentApproval
const blockedByV2Level = isV2Pending && !actorInV2Level
// 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 legacy Mig 16
// set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back)
// - target = TraLai (98) từ phase trung gian — Session 17 spec mới: Trả
// lại là Phase RIÊNG (gotcha #45 — thiếu nhánh này gây "Trả về nhưng
// hệ thống vẫn duyệt" do BE nhận decision=Approve → ApproveV2Async).
// BE có guard mirror trong PurchaseEvaluationWorkflowService.TransitionAsync
// throw ConflictException nếu payload mismatch — phải sync 2 phía.
const isReject = target === PurchaseEvaluationPhase.TuChoi
|| (target === PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|| (target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target,
decision: isReject ? 2 : 1,
comment: comment || null,
returnMode: isTraLaiAction ? returnMode : null,
returnTargetUserId: isTraLaiAction && returnMode === WorkflowReturnMode.Assignee
? returnTargetUserId : 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('')
setReturnMode(WorkflowReturnMode.Drafter)
setReturnTargetUserId(null)
},
onError: e => toast.error(getErrorMessage(e)),
})
const next = evaluation.workflow.nextPhases
const flow = evaluation.approvalFlow
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
{evaluation.approvalWorkflowCode && (
<p className="mt-0.5 font-mono text-[11px] text-slate-500">
{evaluation.approvalWorkflowCode} v{String(evaluation.approvalWorkflowVersion ?? 0).padStart(2, '0')}
{evaluation.approvalWorkflowName && <> · <span className="font-sans">{evaluation.approvalWorkflowName}</span></>}
</p>
)}
</div>
{/* Mig 24 V2 — Flow render Bước → Cấp → NV thay phase cards.
Status: Done (✓ emerald) / Current (● brand) / Pending (○ slate) */}
{flow && flow.steps.length > 0 && (
<ol className="space-y-2">
{flow.steps.map(step => {
const stepIcon = step.status === 'Done' ? '✓' : step.status === 'Current' ? '●' : '○'
return (
<li
key={step.order}
className={cn(
'rounded-md border p-2',
step.status === 'Current' && 'border-brand-300 bg-brand-50/50',
step.status === 'Done' && 'border-emerald-200 bg-emerald-50/40',
step.status === 'Pending' && 'border-slate-200 bg-white',
)}
>
<div className="flex items-center gap-2 text-xs font-medium">
<span className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold',
step.status === 'Done' && 'bg-emerald-500 text-white',
step.status === 'Current' && 'bg-brand-600 text-white',
step.status === 'Pending' && 'bg-slate-200 text-slate-500',
)}>
{stepIcon}
</span>
<span className="text-slate-800">Bước {step.order} {step.name}</span>
{step.departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName}
</span>
)}
</div>
{step.levels.length > 0 && (
<ul className="mt-1.5 ml-7 space-y-1 border-l-2 border-violet-200 pl-2">
{step.levels.map(lv => {
const lvIcon = lv.status === 'Done' ? '✓' : lv.status === 'Current' ? '●' : '○'
return (
<li key={lv.order} className="text-[11px]">
<div className="flex items-start gap-1.5">
<span className={cn(
'mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold',
lv.status === 'Done' && 'bg-emerald-500 text-white',
lv.status === 'Current' && 'bg-brand-600 text-white',
lv.status === 'Pending' && 'bg-slate-200 text-slate-500',
)}>
{lvIcon}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{lv.name || `Cấp ${lv.order}`}
{lv.status === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{lv.status === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
</div>
<div className="text-slate-500">
{lv.approvers.map(a => a.fullName).join(' / ') || '(chưa cấu hình)'}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
)}
{/* Phiếu V1 legacy không có flow → fallback hiển thị phase summary đơn giản */}
{!flow && (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Phiếu này dùng quy trình workflow chi tiết không khả dụng.
</div>
)}
{/* Mig 24 — V2 banner: hiển thị Bước/Cấp hiện tại + danh sách NV được duyệt.
Nếu actor không có trong list → banner amber + nút Duyệt sẽ disable. */}
{isV2Pending && evaluation.currentApproval && !readOnly && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInV2Level
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {evaluation.currentApproval.stepIndex + 1} ({evaluation.currentApproval.stepName})
{evaluation.currentApproval.stepDepartmentName && <> · {evaluation.currentApproval.stepDepartmentName}</>}
{' — '}Cấp {evaluation.currentApproval.levelOrder}
</div>
<div className="mt-0.5">
NV duyệt: {evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ') || '(chưa có)'}
</div>
{actorInV2Level
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ NV trên mới duyệt đưc cấp này</div>}
</div>
)}
{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 (3 màu khác nhau, in đậm):
// - Duyệt = forward phase tiếp theo — emerald (xanh lá positive)
// - Trả lại = về DangSoanThao/TraLai (từ phase trung gian) — amber (request changes)
// - Từ chối = TuChoi — red (terminal negative)
const isSendBack = (p === PurchaseEvaluationPhase.DangSoanThao || p === PurchaseEvaluationPhase.TraLai)
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
const isCancel = p === PurchaseEvaluationPhase.TuChoi
const isForwardApprove = !isSendBack && !isCancel
// Mig 24 — disable Duyệt forward nếu V2 pin + actor không trong cấp hiện tại
const isDisabled = isForwardApprove && blockedByV2Level
const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt'
const title = isDisabled && evaluation.currentApproval
? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới duyệt được.`
: isForwardApprove
? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
: undefined
return (
<button
key={p}
onClick={() => !isDisabled && setTarget(p)}
disabled={isDisabled}
title={title}
className={cn(
'rounded border px-2 py-1 text-[11px] font-bold transition',
isDisabled && 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400 hover:bg-slate-50',
!isDisabled && isForwardApprove && 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
!isDisabled && isSendBack && 'border-amber-300 text-amber-700 hover:bg-amber-50',
!isDisabled && isCancel && 'border-red-300 text-red-700 hover:bg-red-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 &ldquo;Duyệt&rdquo; đ chuyển phase.
</div>
)}
{target !== null && (() => {
const isCancel = target === PurchaseEvaluationPhase.TuChoi
// isSendBack sync với button label + payload isReject (gotcha #45).
// Include cả DangSoanThao (legacy Mig 16) lẫn TraLai (Session 17 spec)
// — cả 2 là Trả lại Drafter sửa.
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|| target === PurchaseEvaluationPhase.TraLai)
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
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 && (
<>
{/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes
enabled per workflow.options. Default Drafter (S17 fallback). */}
{(wfOptions?.allowReturnOneLevel
|| wfOptions?.allowReturnOneStep
|| wfOptions?.allowReturnToAssignee
|| wfOptions?.allowReturnToDrafter
|| !wfOptions) && (
<div className="mb-3 space-y-1.5">
<Label className="text-[12px]">Chọn cách Trả lại</Label>
<div className="space-y-1">
{(wfOptions?.allowReturnOneLevel) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.OneLevel}
onChange={() => setReturnMode(WorkflowReturnMode.OneLevel)}
/>
<span>
<span className="font-medium">Trả về 1 Cấp trước</span>
<span className="block text-[10px] text-slate-500">Lùi 1 Cấp trong cùng Bước. NV cấp trước nhận lại.</span>
</span>
</label>
)}
{(wfOptions?.allowReturnOneStep) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.OneStep}
onChange={() => setReturnMode(WorkflowReturnMode.OneStep)}
/>
<span>
<span className="font-medium">Trả về 1 Bước trước</span>
<span className="block text-[10px] text-slate-500">Lùi sang Bước trước, NV Cấp cuối Bước đó nhận lại.</span>
</span>
</label>
)}
{(wfOptions?.allowReturnToAssignee) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.Assignee}
onChange={() => setReturnMode(WorkflowReturnMode.Assignee)}
/>
<span className="flex-1">
<span className="font-medium">Trả về Người chỉ đnh</span>
<span className="block text-[10px] text-slate-500">Pick từ list NV đã duyệt trước đó. Workflow set Cấp/Bước của NV.</span>
{returnMode === WorkflowReturnMode.Assignee && (
<select
className="mt-1.5 w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
value={returnTargetUserId ?? ''}
onChange={e => setReturnTargetUserId(e.target.value || null)}
>
<option value=""> Chọn NV </option>
{signedApprovers.map(a => (
<option key={a.userId} value={a.userId}>{a.fullName}</option>
))}
</select>
)}
</span>
</label>
)}
{(wfOptions?.allowReturnToDrafter !== false) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.Drafter}
onChange={() => setReturnMode(WorkflowReturnMode.Drafter)}
/>
<span>
<span className="font-medium">Trả về Người soạn thảo (mặc đnh)</span>
<span className="block text-[10px] text-slate-500">Phase "Trả lại". Drafter sửa rồi gửi lại chạy từ Cấp 1 Bước 1.</span>
</span>
</label>
)}
</div>
</div>
)}
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{returnMode === WorkflowReturnMode.Drafter
? 'Phiếu sẽ về "Trả lại". Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1.'
: returnMode === WorkflowReturnMode.Assignee
? 'Phiếu sẽ về Cấp/Bước của NV đã chọn (vẫn "Đã gửi duyệt"). NV nhận lại để duyệt tiếp.'
: 'Phiếu sẽ lùi pointer (vẫn "Đã gửi duyệt"). NV trước nhận lại để duyệt tiếp.'}
</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 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' })
}