// Plan AA Chunk B (S24, 2026-05-15) — User read-only matrix view của workflow // V2 đã admin Designer ghim (`IsUserSelectable=true`, Mig 25). Hiển thị tất cả // version ghim cho ApplicableType (1=DuyetNcc, 2=DuyetNccPhuongAn). // // [Plan AA redesign v2 S24 t1] Bro UAT request: dạng TABLE tận dụng full width // thay vì stack vertical panel-per-NV. Cấu trúc 4 cột: // Bước (Phòng) | Cấp | NV duyệt | Quyền duyệt (grid 2-col 7 label tiếng Việt) // rowSpan cho Bước (Step) + Cấp (Level order). Color coding 2 layer: // - Step bg + headerBg cycle 5 màu (blue/purple/emerald/amber/pink) // - Cấp badge ring cycle 5 màu (violet/sky/teal/orange/rose) // Mỗi cell Quyền duyệt = 7 checkbox read-only label nguyên văn admin Designer. // // URL: /purchase-evaluations/workflow-matrix?type=1|2 // Mirror layout admin `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` // line 853-949 cho 7 checkbox label (read-only — drop input onChange). import { useQuery } from '@tanstack/react-query' import { useSearchParams } from 'react-router-dom' import { Network, CheckCircle2, Pin } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { api } from '@/lib/api' import type { AwAdminOverviewDto, AwDefinitionDto, AwLevelDto } from '@/types/approvalWorkflowV2' // Mig 23 ApplicableType enum mirror (BE Domain/ApprovalWorkflowsV2) const TYPE_LABEL: Record = { 1: 'Duyệt NCC', 2: 'Duyệt NCC + Giải pháp', } // Color palette per Step (Phòng) — cycle 5 màu để phân biệt Bước. Tailwind JIT // yêu cầu full class strings (không dynamic interpolation). const STEP_PALETTE = [ { bg: 'bg-blue-50/40', border: 'border-blue-200', headerBg: 'bg-blue-100', headerText: 'text-blue-800' }, { bg: 'bg-purple-50/40', border: 'border-purple-200', headerBg: 'bg-purple-100', headerText: 'text-purple-800' }, { bg: 'bg-emerald-50/40', border: 'border-emerald-200', headerBg: 'bg-emerald-100', headerText: 'text-emerald-800' }, { bg: 'bg-amber-50/40', border: 'border-amber-200', headerBg: 'bg-amber-100', headerText: 'text-amber-800' }, { bg: 'bg-pink-50/40', border: 'border-pink-200', headerBg: 'bg-pink-100', headerText: 'text-pink-800' }, ] as const // Color palette per Cấp (Level order) — cycle 5 màu để phân biệt Cấp trong cùng // Bước. Badge ring + tone đậm. const LEVEL_PALETTE = [ 'bg-violet-100 text-violet-800 ring-violet-300', 'bg-sky-100 text-sky-800 ring-sky-300', 'bg-teal-100 text-teal-800 ring-teal-300', 'bg-orange-100 text-orange-800 ring-orange-300', 'bg-rose-100 text-rose-800 ring-rose-300', ] as const export function WorkflowMatrixViewPage() { const [searchParams] = useSearchParams() const rawType = Number(searchParams.get('type')) const typeInt = rawType === 1 || rawType === 2 ? rawType : 1 const { data, isLoading, isError } = useQuery({ queryKey: ['workflow-matrix', typeInt], queryFn: async () => { const res = await api.get('/approval-workflows-v2', { params: { applicableType: typeInt, isUserSelectable: true }, }) return res.data }, }) // Em chỉ render type được chọn — BE đã filter applicableType. const summary = data?.types.find(t => t.applicableType === typeInt) const workflows: AwDefinitionDto[] = summary?.history ?? [] const typeLabel = summary?.applicableTypeLabel ?? TYPE_LABEL[typeInt] ?? 'Quy trình duyệt' return ( // [Plan AA hotfix S24 t1] Bro UAT request: dịch hết content sang trái. // px-6 → px-2 (24px → 8px) cho title + table sát sidebar.
Luồng duyệt — {typeLabel} } description="Cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin." /> {isLoading && (
Đang tải cấu hình...
)} {isError && !isLoading && (
Không thể tải cấu hình quy trình. Thử lại sau.
)} {!isLoading && !isError && workflows.length === 0 && (
Chưa có quy trình nào được Admin ghim cho loại phiếu này. Liên hệ Admin.
)} {!isLoading && !isError && workflows.map(wf => ( ))}
) } // Build flat row list per Step với rowSpan metadata cho table layout. // Mỗi Level row = 1 NV slot (Mig 29 OR-of-N split). Group theo level.order. type Row = { level: AwLevelDto order: number isFirstInStep: boolean isFirstInCap: boolean rowSpanStep: number rowSpanCap: number } function buildStepRows(step: AwDefinitionDto['steps'][number]): Row[] { const byOrder = new Map() for (const lvl of step.levels) { const arr = byOrder.get(lvl.order) ?? [] arr.push(lvl) byOrder.set(lvl.order, arr) } const sortedOrders = [...byOrder.keys()].sort((a, b) => a - b) const totalInStep = step.levels.length const rows: Row[] = [] let stepCounter = 0 for (const order of sortedOrders) { const levelsOfOrder = byOrder.get(order)! levelsOfOrder.forEach((lvl, idx) => { rows.push({ level: lvl, order, isFirstInStep: stepCounter === 0, isFirstInCap: idx === 0, rowSpanStep: totalInStep, rowSpanCap: levelsOfOrder.length, }) stepCounter++ }) } return rows } function WorkflowCard({ wf }: { wf: AwDefinitionDto }) { const totalLevels = wf.steps.reduce((sum, s) => sum + s.levels.length, 0) return (

{wf.name}

{wf.code} v{String(wf.version).padStart(2, '0')} {wf.isActive && ( Đang dùng )} {wf.isUserSelectable && ( Được ghim )}
{wf.description && (

{wf.description}

)}
{totalLevels === 0 ? (
Quy trình chưa cấu hình bước duyệt nào.
) : (
{wf.steps.map((step, sIdx) => { const stepColor = STEP_PALETTE[sIdx % STEP_PALETTE.length] const rows = buildStepRows(step) if (rows.length === 0) { return ( ) } return rows.map(r => { const levelColor = LEVEL_PALETTE[(r.order - 1) % LEVEL_PALETTE.length] return ( {r.isFirstInStep && ( )} {r.isFirstInCap && ( )} ) }) })}
Bước (Phòng) Cấp NV duyệt Quyền duyệt
Bước {sIdx + 1}
{step.departmentName ?? '(Không gắn phòng)'}
Chưa có cấp duyệt
Bước {sIdx + 1}
{step.departmentName ?? '(Không gắn phòng)'}
Cấp {r.order} {r.rowSpanCap > 1 && (
{r.rowSpanCap} NV OR
(chỉ cần 1 NV duyệt)
)}
{r.level.approverUserName ?? r.level.approverUserId}
{r.level.approverEmail && (
{r.level.approverEmail}
)}
)}
Cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin.
) } // FlagRow read-only checkbox mirror admin Designer line 885-948. `disabled` // giữ tick visible nhưng disable interaction. colSpan2 cho 3 Allow* label dài // (Edit Section 2 / Edit Budget / Skip Final) chiếm full 2 col. function FlagRow({ active, label, colSpan2 }: { active: boolean; label: string; colSpan2?: boolean }) { return ( ) }