Files
solution-erp/fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx
pqhuy1987 b1bae77c9c
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s
[CLAUDE] Workflow: sua quy trinh duyet TAI CHO + co F6 AllowApproverDelete (dot 1)
Nen cho tinh nang "xoa phieu PE o man duyet" (UAT: phieu bam sai goi thau dang
an luy ke). Dot 1 lam NEN, dot 2 moi lam nut xoa.

Vi sao phai lam nen truoc: phieu dang chay pin ApprovalWorkflowId va doc workflow
SONG theo Id do. Moi POST tao workflow moi = Id MOI, nen tick co tren ban moi
KHONG BAO GIO toi duoc phieu dang treo. Phai co lenh sua-tai-cho giu nguyen Id.

- Domain: ApprovalWorkflowLevel + AllowApproverDelete (F6, per-NGUOI) + Mig
  20260727033522_AddPeAllowApproverDelete (AddColumn bit default false, no new table)
- Application: UpdateAwDefinitionCommand + Validator + Handler
  - 2 phep thu: (T1) khung khong doi - Order tung row bat bien
                (T2) luat choi khong doi - cam CeoApprovalThreshold, AllowApproverFinalize,
                     Code, ApplicableType => 409 bao tao ban moi
  - diff theo Level.Id (CAM delete-then-insert: no FK Restrict + mo coi LevelOpinion)
  - guard 2 tang chu ky: tang 1 chu ky tren ho so CON SONG; tang 2 chu ky CON LUU
    (cha xoa mem hoac opinion xoa mem) - ca hai deu CHAN, khong xoa cung
  - purge chi khi ho so cha KHONG CON ROW; boc transaction (ExecuteDelete tu commit)
  - KHONG tang Version, KHONG dung IsActive, KHONG doi Id
- Api: PUT /api/approval-workflows-v2/{id} + [Authorize(Policy="Workflows.Create")]
- Infra: message 403 in TEN NGUOI thay GUID tran
- FE: Designer tach "Sua tai cho" (PUT) vs "Tao phien ban moi" (POST) + o tick F6
- Tests: +21 (551 tong, 0 fail) - gom F12 chung minh phieu dang chay nhan thay doi ngay

Review: 14 FLAG, 3 HIGH da va (ExecuteDelete ngoai transaction / purge xoa cung
chu ky phieu xoa mem / endpoint khong co day FE goi toi).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:36:59 +07:00

324 lines
15 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 { 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<number, string> = {
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<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 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.
<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}
</span>
}
description="Cấu hình do Admin quản trị. Có thắc mắc liên hệ Admin."
/>
{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">
Chưa 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ấ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ị. 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>
)
}