Files
solution-erp/fe-user/src/components/pe/PeWorkflowPanel.tsx
pqhuy1987 40f64c6b32
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m29s
[CLAUDE] PE-Workflow: UAT S22+1 — disable cả 3 button khi không quyền + BE guard
User UAT feedback: "Nếu đã không được quyền thao tác thì ko được quyền thao tác
hết tất cả các hành động" — trước đây chỉ "Duyệt" disabled, "Trả lại" + "Từ chối"
vẫn enabled (design intent S17 cũ).

FE 2 app mirror (PeWorkflowPanel.tsx):
- `isDisabled = blockedByV2Level` (drop `isForwardApprove &&` qualifier)
- Tooltip update "mới thao tác được (Duyệt / Trả lại / Từ chối)"
- Comment refresh ghi UAT S22+1 spec + cross-ref BE EnsureCanRejectV2Async

BE defense-in-depth (PurchaseEvaluationWorkflowService.cs):
- Helper mới `EnsureCanRejectV2Async` mirror FE actorInV2Level logic:
  Skip silent khi admin/V1/non-ChoDuyet/no actor/no pointer. Throw
  ForbiddenException khi V2 + ChoDuyet + actor != currentLevel.ApproverUserId.
- Invoke ở top Reject branch (cover cả TuChoi + Trả lại sub-branches).
- Chặn request forge: non-approver gọi PATCH /transitions direct sẽ 403.

Test (test-before §7 — security guard critical algorithm):
- ReturnMode tests existing 7/7 vẫn PASS (a2.Id = currentLevel approver, guard accept)
- +1 NEW test `Reject_NonApprover_V2_Throws_ForbiddenException` — outsider
  Drafter role gọi Reject phiếu V2 → throw + Phase không mutate

Verify:
- dotnet test SolutionErp.slnx — 104/104 PASS (+1 guard regression)
  Δ: 103 → 104
- npm run build × 2 app — pass (482ms + 583ms)

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

499 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 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại). Null nếu
// V1 legacy hoặc pointer chưa init → fallback chỉ "Trả về Drafter".
const levelOptions = evaluation.currentLevelOptions
// List approvers đã ký (cho mode Assignee dropdown pick)
const signedApprovers = (evaluation.levelOpinions ?? [])
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
// Dedupe by userId (1 NV có thể ký nhiều cấp nếu workflow đặt như vậy)
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
// thao tác cấp hiện tại. UAT S22+1: disable cả 3 button (Duyệt + Trả lại
// + Từ chối) khi actor không match. BE mirror EnsureCanRejectV2Async.
// Admin bypass.
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorInV2Level = isAdmin
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
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>
)}
{!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 Bước/Cấp + danh sách NV duyệt */}
{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 + UAT S22+1 — disable cả 3 button khi actor không match
// currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động."
const isDisabled = 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 thao tác được (Duyệt / Trả lại / Từ chối).`
: 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 L205-207 + payload isReject L64-68
// (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). */}
{(levelOptions?.allowReturnOneLevel
|| levelOptions?.allowReturnOneStep
|| levelOptions?.allowReturnToAssignee
|| levelOptions?.allowReturnToDrafter
|| !levelOptions) && (
<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">
{(levelOptions?.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>
)}
{(levelOptions?.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>
)}
{(levelOptions?.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>
)}
{(levelOptions?.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' })
}