All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m17s
Gate PASS 0-blocker (sub-reviewer-gate-k4c.md): WorkflowMatrixViewPage doc ?group= (guard
/^[1-8]$/ chan ca "03") -> loc wf.code === KHKK-N{n} client-side co-chu-dich (payload da
tai tron theo type — khong ro cache, BE khong nhan truc group) + badge nhom + link xem-tat-ca
+ rong-vi-loc tach cau; type≠10 = 0 doi hanh vi (PE matrix regression chung-la-khong).
fe-admin 0-diff (lech co-y 2026-07-27). +1 Fact ListDeleted_FiltersByApprovalGroup (5 ve +
nhanh-Admin-cung-loc, assert Total, RED->GREEN 2 chieu do that — gate-K4b MAJOR-A dong).
Suite 622/0 (45D+577I) do tuoi ca gate lan lead. Ghi mo: 1 test cu
DeleteContractSigningPlan_SoftDeletes flaky 1/12 luot quan sat (gate truy 3 ve khong ra
duong diff — moc cho CI neu tai). No con: gate-K4c 3-MINOR (helper-dispose · link-mat-query
· highlight) + K5-MINOR-3 chu SAP + MAJOR-C da dong o wave nay.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
391 lines
19 KiB
TypeScript
391 lines
19 KiB
TypeScript
// 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 = 8 checkbox read-only label nguyên văn admin Designer
|
|
// (7 cũ Mig 29/30/31 + flag thứ 8 "Duyệt là KẾT THÚC" Mig 58 — S89).
|
|
//
|
|
// 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 { Link, useLocation, useSearchParams } from 'react-router-dom'
|
|
import { Network, CheckCircle2, Pin, Layers } from 'lucide-react'
|
|
import { PageHeader } from '@/components/PageHeader'
|
|
import { api } from '@/lib/api'
|
|
import { khkkGroupMenuLabel, khkkGroupWorkflowCode } from '@/types/khkk'
|
|
import type { AwAdminOverviewDto, AwDefinitionDto, AwLevelDto } from '@/types/approvalWorkflowV2'
|
|
|
|
// Mig 23 ApplicableType enum mirror (BE Domain/ApprovalWorkflowsV2)
|
|
const TYPE_LABEL: Record<number, string> = {
|
|
1: 'Duyệt NCC',
|
|
2: 'Duyệt NCC + Giải pháp',
|
|
3: 'Hợp đồng (chung mọi loại)', // [S159-đợt5]
|
|
}
|
|
|
|
// 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()
|
|
// Trang này gắn ở 3 route (/purchase-evaluations|/contracts|/khkk + /workflow-matrix)
|
|
// ⇒ link "xem tất cả" phải bám đường dẫn ĐANG đứng, không hardcode một cái.
|
|
const { pathname } = useLocation()
|
|
const rawType = Number(searchParams.get('type'))
|
|
// [S159-đợt5] +3 = Contract (ApplicableType.Contract — HĐ dùng CHUNG 1 bộ workflow V2
|
|
// cho mọi ContractType ⇒ 7 leaf 'Luồng duyệt' của 7 nhóm HĐ đều trỏ về matrix này).
|
|
// [W2 KHKK S161] +10 (ContractSigningPlan) — leaf Khkk_WfView đi alias /khkk/workflow-matrix?type=10.
|
|
const typeInt = rawType === 1 || rawType === 2 || rawType === 3 || rawType === 10 ? rawType : 1
|
|
|
|
const { data, isLoading, isError } = useQuery({
|
|
queryKey: ['workflow-matrix', typeInt],
|
|
queryFn: async () => {
|
|
const res = await api.get<AwAdminOverviewDto>('/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 allWorkflows: AwDefinitionDto[] = summary?.history ?? []
|
|
const typeLabel = summary?.applicableTypeLabel ?? TYPE_LABEL[typeInt] ?? 'Quy trình duyệt'
|
|
|
|
// ── [K4c S166] Lọc theo NHÓM DUYỆT KHKK ────────────────────────────────────
|
|
// 8 leaf "Luồng duyệt" của 8 nhóm (K4b) đều trỏ về CÙNG trang này, chỉ khác
|
|
// `?group=n`. Không lọc thì cả 8 leaf hiện y hệt nhau — mục menu nói dối tên nó.
|
|
// 🔴 Chỉ áp cho type 10 (Kế hoạch ký kết): `group` là khái niệm của riêng KHKK.
|
|
// Type khác, hoặc `group` rác/thiếu ⇒ BỎ QUA hoàn toàn, hành vi cũ giữ nguyên
|
|
// (thà hiện đủ còn hơn lọc theo một con số vô nghĩa rồi ra trang trắng).
|
|
const rawGroup = searchParams.get('group')
|
|
const khkkGroup =
|
|
typeInt === 10 && rawGroup !== null && /^[1-8]$/.test(rawGroup) ? Number(rawGroup) : null
|
|
|
|
// 🔴 LỌC CLIENT-SIDE, CÓ CHỦ ĐÍCH: query trên kia đã tải TRỌN bộ quy trình của
|
|
// type này rồi (`/approval-workflows-v2?applicableType=10`), nhóm chỉ là lát cắt
|
|
// của đúng bộ đó ⇒ KHÔNG thêm trục `group` vào `queryKey`. Thêm vào sẽ đẻ 8 lần
|
|
// gọi mạng cho cùng một payload, và 8 ô cache cùng nội dung.
|
|
// Khớp bằng `code` (`AwDefinitionDto.code`) chứ không bằng tên/thứ tự: BE seed
|
|
// `KHKK-N{n}` (`DbInitializer.cs:609`) và cũng chính `code` này là thứ BE dùng để
|
|
// gác phiếu-pin-đúng-nhóm (`ContractSigningPlanFeatures.cs:420` regex `^KHKK-N([1-8])$`).
|
|
// Một quy trình có thể có nhiều VERSION cùng `code` ⇒ lọc giữ đủ các version.
|
|
const workflows =
|
|
khkkGroup !== null
|
|
? allWorkflows.filter(wf => wf.code === khkkGroupWorkflowCode(khkkGroup))
|
|
: allWorkflows
|
|
|
|
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.
|
|
<div className="space-y-4 px-2 py-5">
|
|
<PageHeader
|
|
title={
|
|
<span className="flex items-center gap-2">
|
|
<Network className="h-5 w-5 text-slate-500" />
|
|
Luồng duyệt — {typeLabel}
|
|
{khkkGroup !== null && (
|
|
<span className="inline-flex items-center gap-1 rounded-md border border-violet-300 bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-800">
|
|
<Layers className="h-3.5 w-3.5" />
|
|
Nhóm N{khkkGroup}
|
|
</span>
|
|
)}
|
|
</span>
|
|
}
|
|
description={
|
|
// Đang xem lát cắt của một nhóm thì phải nói RÕ nhóm nào — nếu không,
|
|
// 8 leaf menu mở ra 8 trang trông hệt nhau và người dùng không biết
|
|
// mình đang đứng ở đâu.
|
|
khkkGroup !== null
|
|
? `${khkkGroupMenuLabel(khkkGroup)} — cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin.`
|
|
: 'Cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin.'
|
|
}
|
|
actions={
|
|
khkkGroup !== null ? (
|
|
// Giữ nguyên đường dẫn hiện tại (trang này gắn ở 3 route), chỉ bỏ
|
|
// `group` ⇒ về lại chế độ xem cả 8 nhóm.
|
|
<Link
|
|
to={`${pathname}?type=${typeInt}`}
|
|
className="rounded-md border border-slate-300 px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50"
|
|
>
|
|
Xem tất cả nhóm
|
|
</Link>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
{isLoading && (
|
|
<div className="rounded-xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">
|
|
Đang tải cấu hình...
|
|
</div>
|
|
)}
|
|
|
|
{isError && !isLoading && (
|
|
<div className="rounded-xl border border-dashed border-rose-300 bg-rose-50/40 p-8 text-center text-sm text-rose-700">
|
|
Không thể tải cấu hình quy trình. Thử lại sau.
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !isError && workflows.length === 0 && (
|
|
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50/40 p-8 text-center text-sm text-slate-500">
|
|
{/* 🔴 Rỗng-vì-lọc ⟂ rỗng-vì-chưa-có-gì là HAI chuyện khác nhau. Dùng
|
|
chung một câu thì người dùng nhóm N5 tưởng cả module chưa cấu hình,
|
|
trong khi 7 nhóm kia đang chạy bình thường. */}
|
|
{khkkGroup !== null ? (
|
|
<>
|
|
Chưa có quy trình nào được Admin ghim cho nhóm{' '}
|
|
<span className="font-medium text-slate-700">N{khkkGroup}</span>
|
|
{allWorkflows.length > 0 && <> (loại phiếu này có {allWorkflows.length} quy trình ở nhóm khác)</>}. Liên hệ Admin.
|
|
</>
|
|
) : (
|
|
<>Chưa có quy trình nào được Admin ghim cho loại phiếu này. Liên hệ Admin.</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !isError && workflows.map(wf => (
|
|
<WorkflowCard key={wf.id} wf={wf} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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<number, AwLevelDto[]>()
|
|
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 (
|
|
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
<header className="flex items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="text-[15px] font-semibold text-slate-900">
|
|
{wf.name}
|
|
</h3>
|
|
<span className="rounded bg-slate-100 px-2 py-0.5 font-mono text-[11px] text-slate-600">
|
|
{wf.code} v{String(wf.version).padStart(2, '0')}
|
|
</span>
|
|
{wf.isActive && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-0.5 text-[11px] font-medium text-emerald-700">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
Đang dùng
|
|
</span>
|
|
)}
|
|
{wf.isUserSelectable && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-0.5 text-[11px] font-medium text-amber-700">
|
|
<Pin className="h-3 w-3" />
|
|
Được ghim
|
|
</span>
|
|
)}
|
|
</div>
|
|
{wf.description && (
|
|
<p className="mt-1.5 text-[12px] leading-relaxed text-slate-500">{wf.description}</p>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{totalLevels === 0 ? (
|
|
<div className="px-3 py-3">
|
|
<div className="rounded-md border border-dashed border-slate-200 bg-slate-50/40 px-3 py-4 text-center text-[12px] italic text-slate-400">
|
|
Quy trình chưa cấu hình bước duyệt nào.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-[12px]">
|
|
<colgroup>
|
|
<col className="w-[160px]" />
|
|
<col className="w-[100px]" />
|
|
<col className="w-[240px]" />
|
|
<col />
|
|
</colgroup>
|
|
<thead>
|
|
<tr className="bg-slate-50 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
|
|
<th className="border border-slate-200 px-3 py-2 text-left">Bước (Phòng)</th>
|
|
<th className="border border-slate-200 px-3 py-2 text-left">Cấp</th>
|
|
<th className="border border-slate-200 px-3 py-2 text-left">NV duyệt</th>
|
|
<th className="border border-slate-200 px-3 py-2 text-left">Quyền duyệt</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{wf.steps.map((step, sIdx) => {
|
|
const stepColor = STEP_PALETTE[sIdx % STEP_PALETTE.length]
|
|
const rows = buildStepRows(step)
|
|
|
|
if (rows.length === 0) {
|
|
return (
|
|
<tr key={step.id} className={stepColor.bg}>
|
|
<td className={`border ${stepColor.border} ${stepColor.headerBg} px-3 py-2 align-top`}>
|
|
<div className={`text-[13px] font-semibold ${stepColor.headerText}`}>
|
|
Bước {sIdx + 1}
|
|
</div>
|
|
<div className={`mt-0.5 text-[11px] ${stepColor.headerText} opacity-80`}>
|
|
{step.departmentName ?? '(Không gắn phòng)'}
|
|
</div>
|
|
</td>
|
|
<td colSpan={3} className={`border ${stepColor.border} px-3 py-2 text-[12px] italic text-slate-400`}>
|
|
Chưa có cấp duyệt
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
return rows.map(r => {
|
|
const levelColor = LEVEL_PALETTE[(r.order - 1) % LEVEL_PALETTE.length]
|
|
return (
|
|
<tr key={r.level.id} className={stepColor.bg}>
|
|
{r.isFirstInStep && (
|
|
<td
|
|
rowSpan={r.rowSpanStep}
|
|
className={`border ${stepColor.border} ${stepColor.headerBg} px-3 py-2 align-top`}
|
|
>
|
|
<div className={`text-[13px] font-semibold ${stepColor.headerText}`}>
|
|
Bước {sIdx + 1}
|
|
</div>
|
|
<div className={`mt-0.5 text-[11px] ${stepColor.headerText} opacity-80`}>
|
|
{step.departmentName ?? '(Không gắn phòng)'}
|
|
</div>
|
|
</td>
|
|
)}
|
|
{r.isFirstInCap && (
|
|
<td
|
|
rowSpan={r.rowSpanCap}
|
|
className={`border ${stepColor.border} bg-white/60 px-3 py-2 align-top`}
|
|
>
|
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-bold ring-1 ${levelColor}`}>
|
|
Cấp {r.order}
|
|
</span>
|
|
{r.rowSpanCap > 1 && (
|
|
<div className="mt-1 text-[10px] leading-tight text-slate-500">
|
|
{r.rowSpanCap} NV OR
|
|
<br />
|
|
(chỉ cần 1 NV duyệt)
|
|
</div>
|
|
)}
|
|
</td>
|
|
)}
|
|
<td className={`border ${stepColor.border} bg-white/80 px-3 py-2 align-top`}>
|
|
<div className="font-medium text-slate-800">
|
|
{r.level.approverUserName ?? r.level.approverUserId}
|
|
</div>
|
|
{r.level.approverEmail && (
|
|
<div className="mt-0.5 text-[11px] text-slate-400">
|
|
{r.level.approverEmail}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className={`border ${stepColor.border} bg-white/80 px-3 py-2 align-top`}>
|
|
<div className="grid grid-cols-1 gap-x-4 gap-y-1.5 md:grid-cols-2">
|
|
<FlagRow active={r.level.allowReturnOneLevel} label="Trả về 1 Cấp trước" />
|
|
<FlagRow active={r.level.allowReturnOneStep} label="Trả về 1 Bước trước" />
|
|
<FlagRow active={r.level.allowReturnToAssignee} label="Trả về Người chỉ định" />
|
|
<FlagRow active={r.level.allowReturnToDrafter} label="Trả về Drafter (mặc định)" />
|
|
<FlagRow active={r.level.allowApproverEditDetails} label="Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt" colSpan2 />
|
|
<FlagRow active={r.level.allowApproverEditBudget} label="Cho phép chỉnh sửa Section ngân sách lúc đang duyệt" colSpan2 />
|
|
<FlagRow active={r.level.allowApproverSkipToFinal} label="Cho phép duyệt thẳng Cấp cuối khi đang duyệt" colSpan2 />
|
|
{/* [Mig 58 — anh Kiệt FDC, S89] Flag thứ 8: cấp này Duyệt = KẾT THÚC (không trình CEO) — trước đây matrix thiếu nên "chưa hiển thị chính xác". */}
|
|
<FlagRow active={r.level.allowApproverFinalize} label="Duyệt là KẾT THÚC quy trình (không trình tiếp CEO)" colSpan2 />
|
|
{/* [F6 S155] Flag thứ 9 — quyền xóa phiếu đang duyệt, PER-NGƯỜI của đúng dòng NV này (không phải cả Cấp). */}
|
|
<FlagRow active={r.level.allowApproverDelete} label="Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình" colSpan2 />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<footer className="border-t border-slate-100 px-4 py-2.5">
|
|
<small className="text-[11px] text-slate-400">
|
|
Cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin.
|
|
</small>
|
|
</footer>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<label className={`flex items-start gap-1.5 text-[12px] ${colSpan2 ? 'sm:col-span-2' : ''}`}>
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-emerald-600"
|
|
checked={active}
|
|
disabled
|
|
readOnly
|
|
/>
|
|
<span className={active ? 'font-medium text-slate-800' : 'text-slate-400'}>
|
|
{label}
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|