[CLAUDE] FE-User: Plan AA redesign v2 - Table layout rowSpan tận dụng full width + 7 label tiếng Việt + color coding 2 layer
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m14s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m14s
UAT feedback 2026-05-15 sau Run #212 deploy: bro request layout DẠNG TABLE tận dụng hết width thay vì stack vertical panel-per-NV (visual rộng theo chiều ngang). Refactor WorkflowCard structure → 4-col HTML table với rowSpan: Table cols: | Bước (Phòng) | Cấp | NV duyệt | Quyền duyệt | | rowSpan=N | rowSpan=M | per-NV | grid 2-col 7 checkbox | - Bước column: rowSpan = total NV trong Step. Header tone đậm Step palette. - Cấp column: rowSpan = N NV cùng Order (OR-of-N). Badge ring Cấp palette. Nếu N > 1: hint "N NV OR (chỉ cần 1 NV duyệt)". - NV duyệt column: 1 row per NV slot. Tên + email gray. - Quyền duyệt column: grid grid-cols-1 md:grid-cols-2 với 7 checkbox label: - 4 return mode (col-span-1): "Trả về 1 Cấp trước" / "Trả về 1 Bước trước" / "Trả về Người chỉ định" / "Trả về Drafter (mặc định)" - 3 long label (col-span-2): "Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt" / "Cho phép chỉnh sửa Section ngân sách lúc đang duyệt" / "Cho phép duyệt thẳng Cấp cuối khi đang duyệt" Color coding 2 layer preserved: - Step (Bước) bg + headerBg: blue/purple/emerald/amber/pink cycle (5 màu) - Cấp badge: violet/sky/teal/orange/rose cycle (5 màu) - NV + Quyền duyệt cell: bg-white/80 (lighten Step tone, vẫn show through) Helper extracted `buildStepRows(step)` build flat Row[] với rowSpan metadata (isFirstInStep + isFirstInCap + rowSpanStep + rowSpanCap). Drop StepBlock + NvPermissionPanel components (chuyển inline table cells). colgroup width hint: Bước=160px / Cấp=100px / NV=240px / Quyền duyệt=1fr (rest). Tại 1280-1366px viewport (laptop nhỏ Plan AA sidebar widen) Quyền duyệt cell ~400-500px → grid 2-col fit 7 label OK. Verify: - npm run build fe-user PASS clean 0 TS err, 522ms, 1907 modules - Bundle 1284.22 KB (+1.31 KB from baseline) Em main solo CSS/UX redesign (criteria #2 + #4 Implementer REFUSE — UX layout decision rowSpan grouping + cell distribution decision). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -2,14 +2,17 @@
|
|||||||
// V2 đã admin Designer ghim (`IsUserSelectable=true`, Mig 25). Hiển thị tất cả
|
// V2 đã admin Designer ghim (`IsUserSelectable=true`, Mig 25). Hiển thị tất cả
|
||||||
// version ghim cho ApplicableType (1=DuyetNcc, 2=DuyetNccPhuongAn).
|
// version ghim cho ApplicableType (1=DuyetNcc, 2=DuyetNccPhuongAn).
|
||||||
//
|
//
|
||||||
// [Plan AA redesign S24 t1] Bro UAT request: drop table 11 cột compact symbol →
|
// [Plan AA redesign v2 S24 t1] Bro UAT request: dạng TABLE tận dụng full width
|
||||||
// switch sang panel-per-NV layout mirror admin Designer read-only. 7 label rõ
|
// thay vì stack vertical panel-per-NV. Cấu trúc 4 cột:
|
||||||
// tiếng Việt + color coding 2 layer (Step/Phòng + Cấp). Group levels theo
|
// Bước (Phòng) | Cấp | NV duyệt | Quyền duyệt (grid 2-col 7 label tiếng Việt)
|
||||||
// `level.order` (OR-of-N approvers cùng Cấp render N panel song song).
|
// 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
|
// URL: /purchase-evaluations/workflow-matrix?type=1|2
|
||||||
// Mirror layout admin `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx`
|
// Mirror layout admin `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx`
|
||||||
// line 853-949 (read-only — drop input onChange).
|
// line 853-949 cho 7 checkbox label (read-only — drop input onChange).
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { Network, CheckCircle2, Pin } from 'lucide-react'
|
import { Network, CheckCircle2, Pin } from 'lucide-react'
|
||||||
@ -102,11 +105,51 @@ export function WorkflowMatrixViewPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 }) {
|
function WorkflowCard({ wf }: { wf: AwDefinitionDto }) {
|
||||||
const totalLevels = wf.steps.reduce((sum, s) => sum + s.levels.length, 0)
|
const totalLevels = wf.steps.reduce((sum, s) => sum + s.levels.length, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<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">
|
<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="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@ -135,17 +178,115 @@ function WorkflowCard({ wf }: { wf: AwDefinitionDto }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="space-y-3 px-3 py-3">
|
|
||||||
{totalLevels === 0 ? (
|
{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">
|
<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.
|
Quy trình chưa cấu hình bước duyệt nào.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
wf.steps.map((step, sIdx) => (
|
|
||||||
<StepBlock key={step.id} step={step} sIdx={sIdx} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</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 />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer className="border-t border-slate-100 px-4 py-2.5">
|
<footer className="border-t border-slate-100 px-4 py-2.5">
|
||||||
<small className="text-[11px] text-slate-400">
|
<small className="text-[11px] text-slate-400">
|
||||||
@ -156,88 +297,6 @@ function WorkflowCard({ wf }: { wf: AwDefinitionDto }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepBlock({ step, sIdx }: { step: AwDefinitionDto['steps'][number]; sIdx: number }) {
|
|
||||||
// Step (Phòng) → unique color cycle 5 màu để phân biệt Bước.
|
|
||||||
const stepColor = STEP_PALETTE[sIdx % STEP_PALETTE.length]
|
|
||||||
|
|
||||||
// Group levels theo level.order — OR-of-N approvers cùng Cấp = N row Level
|
|
||||||
// cùng Order (Mig 29 schema). User view: 1 Cấp group → N NV panel song song.
|
|
||||||
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)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`overflow-hidden rounded-lg border ${stepColor.border} ${stepColor.bg}`}>
|
|
||||||
<div className={`flex items-center gap-2 border-b ${stepColor.border} ${stepColor.headerBg} px-3 py-2`}>
|
|
||||||
<span className={`text-[13px] font-semibold ${stepColor.headerText}`}>
|
|
||||||
Bước {sIdx + 1} — {step.departmentName ?? '(Không gắn phòng)'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 p-3">
|
|
||||||
{sortedOrders.length === 0 ? (
|
|
||||||
<div className="text-[12px] italic text-slate-400">Chưa có cấp duyệt</div>
|
|
||||||
) : (
|
|
||||||
sortedOrders.map(order => {
|
|
||||||
const levelsOfOrder = byOrder.get(order)!
|
|
||||||
// Cấp (Level order) → unique color cycle 5 màu trong Step.
|
|
||||||
const levelColor = LEVEL_PALETTE[(order - 1) % LEVEL_PALETTE.length]
|
|
||||||
return (
|
|
||||||
<div key={order} className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-bold ring-1 ${levelColor}`}>
|
|
||||||
Cấp {order}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-slate-500">
|
|
||||||
{levelsOfOrder.length === 1
|
|
||||||
? '1 NV duyệt'
|
|
||||||
: `${levelsOfOrder.length} NV (OR-of-${levelsOfOrder.length} — chỉ cần 1 NV duyệt là qua Cấp)`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{levelsOfOrder.map(level => (
|
|
||||||
<NvPermissionPanel key={level.id} level={level} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NvPermissionPanel({ level }: { level: AwLevelDto }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-amber-200 bg-white/90 px-3 py-2 shadow-sm">
|
|
||||||
<div className="mb-2 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b border-amber-100 pb-1.5">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-amber-700">
|
|
||||||
Quyền duyệt {level.approverUserName ?? level.approverUserId}
|
|
||||||
</span>
|
|
||||||
{level.approverEmail && (
|
|
||||||
<span className="text-[11px] text-slate-400">
|
|
||||||
{level.approverEmail}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-x-3 gap-y-1.5 sm:grid-cols-2">
|
|
||||||
<FlagRow active={level.allowReturnOneLevel} label="Trả về 1 Cấp trước" />
|
|
||||||
<FlagRow active={level.allowReturnOneStep} label="Trả về 1 Bước trước" />
|
|
||||||
<FlagRow active={level.allowReturnToAssignee} label="Trả về Người chỉ định" />
|
|
||||||
<FlagRow active={level.allowReturnToDrafter} label="Trả về Drafter (mặc định)" />
|
|
||||||
<FlagRow active={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={level.allowApproverEditBudget} label="Cho phép chỉnh sửa Section ngân sách lúc đang duyệt" colSpan2 />
|
|
||||||
<FlagRow active={level.allowApproverSkipToFinal} label="Cho phép duyệt thẳng Cấp cuối khi đang duyệt" colSpan2 />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlagRow read-only checkbox mirror admin Designer line 885-948. `disabled`
|
// 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
|
// 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.
|
// (Edit Section 2 / Edit Budget / Skip Final) chiếm full 2 col.
|
||||||
|
|||||||
Reference in New Issue
Block a user