[CLAUDE] Workflow: Max 3 cấp/bước + N NV/cấp + sequential gating (V2 UAT iter 2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m16s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m16s
User feedback: "tối đa 3 cấp (không có cấp 4)" — không phải bắt buộc 3.
Mỗi cấp = N NV (add bao nhiêu cũng được). Quy trình chạy theo số cấp
thật sự cấu hình (1/2/3). C2 chưa thao tác được khi C1 chưa có NV.
Convention DB: nhiều `ApprovalWorkflowLevel` row cùng Order = same Cấp,
mỗi row = 1 NV. Service iterate group by Order; trong cùng cấp =
OR-of-N (1 NV duyệt → cấp pass).
BE — Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs:
- Validator strict:
- Order ∈ {1, 2, 3} (`MaxLevelsPerStep`)
- Sequential gating: HaveSequentialOrders → 1 / 1+2 / 1+2+3, KHÔNG
cho 2 (thiếu 1) hoặc 1+3 (thiếu 2)
- HaveNoDuplicateApproverInSameLevel: 1 NV không thêm 2 lần cùng cấp
- Schema KHÔNG đổi (giữ ApprovalWorkflowLevel.ApproverUserId 1-1).
- Handler không đổi — auto handle multiple rows cùng Order.
FE — ApprovalWorkflowsV2Page.tsx rewrite Levels section:
- Type EditStep.levels → levelEntries: { order: 1|2|3; approverUserId }[]
flat list (group by order trong render).
- 3 SECTION CỐ ĐỊNH C1/C2/C3 trong Designer:
- Mỗi section: header "Cấp N" + count NV + nút "+ Thêm NV"
- List rows mỗi NV với Select dropdown filtered theo Phòng + Trash
- C2 disabled (opacity-60) khi C1 empty. C3 disabled khi C2 empty.
- Tooltip "+ Thêm NV": "Cấp k-1 phải có ≥1 NV trước"
- Add NV: dropdown chỉ NV thuộc Phòng + chưa được thêm cùng cấp
(no duplicate same level).
- Xóa NV: chặn xóa NV cuối Cấp k nếu Cấp k+1 còn entries (toast error
"Hãy xóa hết NV ở Cấp k+1 trước khi rỗng Cấp k").
- Đổi Phòng → clear toàn bộ levelEntries (NV cũ không thuộc Phòng mới).
- DefinitionCard read-only: group s.levels by Order → render mỗi cấp
là 1 row với badge "Cấp N" + list NV bên dưới.
- Save validate: Phòng required + Cấp 1 ≥1 NV + sequential + NV thuộc
đúng Phòng (defensive double-check).
Verify: dotnet build BE OK · 77 test pass · npm build fe-admin OK.
Logic Service PE/Contract chưa wire schema mới — vẫn pin Mig 21 legacy.
This commit is contained in:
@ -1,16 +1,21 @@
|
||||
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
|
||||
// Schema riêng UAT trước khi drop legacy. Cấu trúc:
|
||||
// Quy trình (Mã + Tên + ApplicableType)
|
||||
// Bước 1 — Phòng A (FIXED 3 cấp)
|
||||
// Cấp 1 — NV X thuộc Phòng A
|
||||
// Cấp 2 — NV Y thuộc Phòng A
|
||||
// Cấp 3 — NV Z thuộc Phòng A
|
||||
// Bước 1 — Phòng A
|
||||
// Cấp 1 (N NV duyệt)
|
||||
// Cấp 2 (N NV duyệt)
|
||||
// Cấp 3 (N NV duyệt)
|
||||
//
|
||||
// Iteration 2 (UAT feedback 2026-05-08):
|
||||
// - LOCK CỨNG 3 cấp/bước (không Add/Remove/Move). Logic backend giữ nguyên
|
||||
// iterate Levels OrderBy Order — chỉ giới hạn UI.
|
||||
// - Cùng 1 phòng có thể chọn 3 NV cùng cấp (không bắt khác cấp).
|
||||
// - Select NV CHỈ filter theo Phòng đã chọn (đổi Phòng → reset 3 approver).
|
||||
// Iteration 3 (UAT feedback 2026-05-08):
|
||||
// - TỐI ĐA 3 cấp/bước (không có cấp 4). 1/2/3 cấp đều OK — quy trình
|
||||
// chạy theo số cấp thật sự có.
|
||||
// - MỖI CẤP CÓ N NV: convention multiple Level rows cùng Order = same Cấp.
|
||||
// BE iterate group by Order; trong cùng cấp = OR-of-N approvers.
|
||||
// - 3 section cố định C1/C2/C3 trong UI. Mỗi section có nút "+ Thêm NV"
|
||||
// + Trash xóa từng NV.
|
||||
// - Sequential gating: C2 disabled khi C1 chưa có NV. C3 disabled khi C2
|
||||
// chưa có NV. Chặn xóa NV cuối C1 khi C2/C3 còn entries.
|
||||
// - Select NV CHỈ filter theo Phòng đã chọn (đổi Phòng → clear approvers).
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@ -65,14 +70,16 @@ type TypeSummaryDto = {
|
||||
history: DefinitionDto[]
|
||||
}
|
||||
|
||||
type EditLevel = { name: string; approverUserId: string }
|
||||
type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] }
|
||||
type LevelOrder = 1 | 2 | 3
|
||||
type EditLevelEntry = { order: LevelOrder; approverUserId: string }
|
||||
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
|
||||
|
||||
type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null }
|
||||
|
||||
// Lock cứng 3 cấp/bước per UAT 2026-05-08.
|
||||
// Logic BE iterate Levels OrderBy Order vẫn cho phép N cấp — đây chỉ là giới hạn UI.
|
||||
const FIXED_LEVELS_PER_STEP = 3
|
||||
// Tối đa 3 cấp/bước per UAT iter 3 (2026-05-08). Quy trình chạy theo số cấp
|
||||
// thật sự có (1 / 2 / 3). Mỗi cấp có N NV (multiple Level rows cùng Order).
|
||||
const MAX_LEVELS_PER_STEP = 3
|
||||
const LEVEL_ORDERS: LevelOrder[] = [1, 2, 3]
|
||||
|
||||
// FE typeCode → BE int (giống MenuKeys ApplicableType)
|
||||
const TYPE_CODE_TO_INT: Record<string, number> = {
|
||||
@ -86,38 +93,23 @@ const DEFAULT_CODE_BY_TYPE: Record<number, string> = {
|
||||
3: 'QT-HD-V2-001',
|
||||
}
|
||||
|
||||
function makeEmptyLevels(): EditLevel[] {
|
||||
return Array.from({ length: FIXED_LEVELS_PER_STEP }, (_, i) => ({
|
||||
name: `Cấp ${i + 1}`,
|
||||
approverUserId: '',
|
||||
}))
|
||||
}
|
||||
|
||||
function makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep {
|
||||
return {
|
||||
name: `Phòng ${stepNo}`,
|
||||
departmentId: deptId,
|
||||
levels: makeEmptyLevels(),
|
||||
levelEntries: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Pad/truncate về đúng FIXED_LEVELS_PER_STEP cấp khi clone version cũ
|
||||
// (phòng khi DB có data legacy >3 hoặc <3 levels).
|
||||
// Clone existing definition: filter Order ∈ {1,2,3}, drop entries vượt giới hạn.
|
||||
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||
return d.steps.map(s => {
|
||||
const levels = s.levels.slice(0, FIXED_LEVELS_PER_STEP).map((l, i) => ({
|
||||
name: l.name ?? `Cấp ${i + 1}`,
|
||||
approverUserId: l.approverUserId,
|
||||
}))
|
||||
while (levels.length < FIXED_LEVELS_PER_STEP) {
|
||||
levels.push({ name: `Cấp ${levels.length + 1}`, approverUserId: '' })
|
||||
}
|
||||
return {
|
||||
return d.steps.map(s => ({
|
||||
name: s.name,
|
||||
departmentId: s.departmentId,
|
||||
levels,
|
||||
}
|
||||
})
|
||||
levelEntries: s.levels
|
||||
.filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP)
|
||||
.map(l => ({ order: l.order as LevelOrder, approverUserId: l.approverUserId })),
|
||||
}))
|
||||
}
|
||||
|
||||
// Filter NV theo Phòng. Nếu Phòng = null → fallback all (chưa chọn phòng).
|
||||
@ -127,6 +119,16 @@ function usersForDept(all: ApproverUser[] | undefined, deptId: string | null): A
|
||||
return all.filter(u => u.departmentId === deptId)
|
||||
}
|
||||
|
||||
function entriesAtLevel(step: EditStep, order: LevelOrder): EditLevelEntry[] {
|
||||
return step.levelEntries.filter(e => e.order === order)
|
||||
}
|
||||
|
||||
// Cấp k được phép thao tác khi Cấp k-1 có ≥1 NV (gating sequential).
|
||||
function isLevelEnabled(step: EditStep, order: LevelOrder): boolean {
|
||||
if (order === 1) return step.departmentId !== null
|
||||
return entriesAtLevel(step, (order - 1) as LevelOrder).length > 0
|
||||
}
|
||||
|
||||
export function ApprovalWorkflowsV2Page() {
|
||||
const qc = useQueryClient()
|
||||
const { typeCode } = useParams<{ typeCode?: string }>()
|
||||
@ -322,23 +324,37 @@ function DefinitionCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-2 ml-9 space-y-1">
|
||||
{/* Group by Order — 1 cấp có N NV */}
|
||||
<ul className="mt-2 ml-9 space-y-1.5">
|
||||
{s.levels.length === 0 ? (
|
||||
<li className="text-[11px] italic text-slate-400">Chưa có cấp duyệt</li>
|
||||
) : (
|
||||
s.levels.map(l => (
|
||||
<li key={l.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
|
||||
C{l.order}
|
||||
Array.from(
|
||||
s.levels.reduce((map, l) => {
|
||||
const arr = map.get(l.order) ?? []
|
||||
arr.push(l)
|
||||
map.set(l.order, arr)
|
||||
return map
|
||||
}, new Map<number, LevelDto[]>()).entries(),
|
||||
)
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([order, group]) => (
|
||||
<li key={order} className="flex items-start gap-2 text-xs">
|
||||
<span className="mt-0.5 shrink-0 rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
|
||||
Cấp {order}
|
||||
</span>
|
||||
<span className="text-slate-700">{l.name || `Cấp ${l.order}`}</span>
|
||||
<span className="text-slate-400">—</span>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
{group.map(l => (
|
||||
<div key={l.id} className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-slate-800">
|
||||
{l.approverUserName ?? l.approverUserId}
|
||||
</span>
|
||||
{l.approverEmail && (
|
||||
<span className="text-[10px] text-slate-400">({l.approverEmail})</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
@ -404,20 +420,26 @@ function Designer({
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Validate: mỗi Bước phải có Phòng + 3 cấp đầy đủ + NV thuộc đúng Phòng.
|
||||
// Validate per Bước:
|
||||
// - Phòng required
|
||||
// - Cấp 1 phải có ≥1 NV (sequential gating đảm bảo nếu C1 empty thì C2/C3 cũng empty)
|
||||
// - All approver thuộc đúng Phòng (defensive double-check)
|
||||
for (const s of steps) {
|
||||
if (!s.departmentId) {
|
||||
throw new Error(`Bước "${s.name}" chưa chọn Phòng.`)
|
||||
}
|
||||
if (s.levels.length !== FIXED_LEVELS_PER_STEP) {
|
||||
throw new Error(`Bước "${s.name}" phải có đúng ${FIXED_LEVELS_PER_STEP} cấp.`)
|
||||
if (entriesAtLevel(s, 1).length === 0) {
|
||||
throw new Error(`Bước "${s.name}" chưa có NV ở Cấp 1.`)
|
||||
}
|
||||
for (const l of s.levels) {
|
||||
if (!l.approverUserId) {
|
||||
throw new Error(`Bước "${s.name}" còn cấp chưa chọn nhân viên duyệt.`)
|
||||
// Sequential gating đã enforce ở UI nhưng kiểm tra lại
|
||||
if (entriesAtLevel(s, 2).length === 0 && entriesAtLevel(s, 3).length > 0) {
|
||||
throw new Error(`Bước "${s.name}": Cấp 3 chỉ thao tác được khi Cấp 2 có NV.`)
|
||||
}
|
||||
// Defensive: Select đã filter, nhưng double-check để tránh bypass UI.
|
||||
const u = usersList.data?.find(x => x.id === l.approverUserId)
|
||||
for (const e of s.levelEntries) {
|
||||
if (!e.approverUserId) {
|
||||
throw new Error(`Bước "${s.name}": có dòng cấp chưa chọn NV.`)
|
||||
}
|
||||
const u = usersList.data?.find(x => x.id === e.approverUserId)
|
||||
if (u && u.departmentId !== s.departmentId) {
|
||||
throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`)
|
||||
}
|
||||
@ -432,10 +454,12 @@ function Designer({
|
||||
order: i + 1,
|
||||
name: s.name,
|
||||
departmentId: s.departmentId,
|
||||
levels: s.levels.map((l, j) => ({
|
||||
order: j + 1,
|
||||
name: l.name || null,
|
||||
approverUserId: l.approverUserId,
|
||||
// Mỗi entry → 1 Level row. Multiple rows cùng Order = same Cấp với
|
||||
// N approvers (BE iterate group by Order).
|
||||
levels: s.levelEntries.map(e => ({
|
||||
order: e.order,
|
||||
name: `Cấp ${e.order}`,
|
||||
approverUserId: e.approverUserId,
|
||||
})),
|
||||
})),
|
||||
})
|
||||
@ -499,7 +523,7 @@ function Designer({
|
||||
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Các bước duyệt — mỗi bước = 1 Phòng × <span className="font-bold text-violet-700">cố định 3 cấp NV</span> ({steps.length} bước)
|
||||
Các bước duyệt — mỗi bước = 1 Phòng × <span className="font-bold text-violet-700">tối đa {MAX_LEVELS_PER_STEP} cấp NV</span> ({steps.length} bước)
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
@ -532,10 +556,10 @@ function Designer({
|
||||
<Select
|
||||
value={s.departmentId ?? ''}
|
||||
onChange={e => {
|
||||
// Đổi Phòng → reset 3 approver vì NV cũ có thể không thuộc Phòng mới.
|
||||
// Đổi Phòng → clear hết approvers vì NV cũ có thể không thuộc Phòng mới.
|
||||
const newDeptId = e.target.value || null
|
||||
setSteps(steps.map((x, i) => (i === idx
|
||||
? { ...x, departmentId: newDeptId, levels: x.levels.map(l => ({ ...l, approverUserId: '' })) }
|
||||
? { ...x, departmentId: newDeptId, levelEntries: [] }
|
||||
: x)))
|
||||
}}
|
||||
required
|
||||
@ -577,65 +601,148 @@ function Designer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Levels — LOCK 3 cấp/bước. NV filter theo Phòng đã chọn. */}
|
||||
<div className="mt-2 ml-9 space-y-1.5 border-l-2 border-violet-200 pl-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">
|
||||
Cấp duyệt (cố định {FIXED_LEVELS_PER_STEP} cấp)
|
||||
</span>
|
||||
{/* Levels — 3 section cố định C1/C2/C3. Mỗi cấp có N NV.
|
||||
Sequential gating: Cấp k chỉ active khi Cấp k-1 có ≥1 NV. */}
|
||||
<div className="mt-2 ml-9 space-y-2 border-l-2 border-violet-200 pl-3">
|
||||
{!s.departmentId && (
|
||||
<span className="rounded bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
⚠ Chọn Phòng để load NV
|
||||
</span>
|
||||
)}
|
||||
<div className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-700">
|
||||
⚠ Chọn Phòng để bắt đầu cấu hình cấp duyệt.
|
||||
</div>
|
||||
{s.levels.map((l, li) => {
|
||||
)}
|
||||
{s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && (
|
||||
<div className="rounded border border-dashed border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] italic text-amber-700">
|
||||
⚠ Phòng này chưa có nhân viên. Vào /system/users gán NV vào Phòng trước, sau đó quay lại.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{LEVEL_ORDERS.map(order => {
|
||||
const entries = entriesAtLevel(s, order)
|
||||
const enabled = isLevelEnabled(s, order)
|
||||
const filteredUsers = usersForDept(usersList.data, s.departmentId)
|
||||
// NV còn khả dụng (chưa được dùng ở cấp này — cùng cấp không trùng NV)
|
||||
const usedInThisLevel = new Set(entries.map(e => e.approverUserId))
|
||||
const availableUsers = filteredUsers.filter(u => !usedInThisLevel.has(u.id))
|
||||
|
||||
// Disable Add: Phòng chưa chọn / cấp trước chưa có NV / hết NV available
|
||||
const addDisabled = !enabled || availableUsers.length === 0
|
||||
|
||||
return (
|
||||
<div key={li} className="flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-violet-100 px-2 py-1 font-mono text-[10px] font-bold text-violet-700">
|
||||
C{li + 1}
|
||||
<div
|
||||
key={order}
|
||||
className={`rounded-md border p-2 ${
|
||||
enabled
|
||||
? 'border-violet-200 bg-white'
|
||||
: 'border-slate-200 bg-slate-50/50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
|
||||
Cấp {order}
|
||||
</span>
|
||||
<Input
|
||||
value={l.name}
|
||||
onChange={e =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx ? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, name: e.target.value } : y)) } : x,
|
||||
))
|
||||
}
|
||||
placeholder={`Cấp ${li + 1}`}
|
||||
className="h-7 max-w-[120px] text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={l.approverUserId}
|
||||
onChange={e =>
|
||||
<span className="text-[11px] text-slate-500">
|
||||
{entries.length === 0
|
||||
? (enabled ? 'Chưa có NV' : `Hoàn tất Cấp ${order - 1} trước`)
|
||||
: `${entries.length} NV duyệt`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={addDisabled}
|
||||
onClick={() => {
|
||||
if (availableUsers.length === 0) return
|
||||
const firstUser = availableUsers[0]
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx
|
||||
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
|
||||
? { ...x, levelEntries: [...x.levelEntries, { order, approverUserId: firstUser.id }] }
|
||||
: x,
|
||||
))
|
||||
}}
|
||||
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={
|
||||
!enabled
|
||||
? `Cấp ${order - 1} phải có ≥1 NV trước`
|
||||
: availableUsers.length === 0
|
||||
? 'Hết NV khả dụng (đã thêm hết hoặc Phòng không còn NV)'
|
||||
: 'Thêm NV duyệt vào cấp này'
|
||||
}
|
||||
className="h-7 flex-1 text-xs"
|
||||
disabled={!s.departmentId}
|
||||
>
|
||||
<option value="">
|
||||
{s.departmentId ? '— Chọn NV duyệt —' : '— Chọn Phòng trước —'}
|
||||
</option>
|
||||
{filteredUsers.map(u => (
|
||||
+ Thêm NV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entries.length > 0 && (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{entries.map((entry, ei) => {
|
||||
// Tính index trong levelEntries gốc để update
|
||||
const globalIdx = s.levelEntries.findIndex(
|
||||
x => x === entry,
|
||||
)
|
||||
// NV available cho dropdown: filtered + chính NV đang chọn (giữ option hiện tại)
|
||||
const dropdownUsers = filteredUsers.filter(
|
||||
u => u.id === entry.approverUserId || !usedInThisLevel.has(u.id),
|
||||
)
|
||||
return (
|
||||
<div key={ei} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-slate-400">#{ei + 1}</span>
|
||||
<Select
|
||||
value={entry.approverUserId}
|
||||
onChange={e => {
|
||||
const newId = e.target.value
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx
|
||||
? {
|
||||
...x,
|
||||
levelEntries: x.levelEntries.map((y, j) =>
|
||||
j === globalIdx ? { ...y, approverUserId: newId } : y,
|
||||
),
|
||||
}
|
||||
: x,
|
||||
))
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
>
|
||||
{dropdownUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.fullName} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Chặn xóa NV cuối Cấp 1 nếu Cấp 2/3 còn entries (gating)
|
||||
if (entries.length === 1) {
|
||||
const nextLevelHasEntries =
|
||||
order < MAX_LEVELS_PER_STEP &&
|
||||
entriesAtLevel(s, (order + 1) as LevelOrder).length > 0
|
||||
if (nextLevelHasEntries) {
|
||||
toast.error(
|
||||
`Hãy xóa hết NV ở Cấp ${order + 1} trước khi rỗng Cấp ${order}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx
|
||||
? { ...x, levelEntries: x.levelEntries.filter((_, j) => j !== globalIdx) }
|
||||
: x,
|
||||
))
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
|
||||
title="Xóa NV khỏi cấp này"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && (
|
||||
<div className="rounded border border-dashed border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] italic text-amber-700">
|
||||
⚠ Phòng này chưa có nhân viên. Vào /system/users gán NV vào Phòng trước, sau đó quay lại chọn duyệt.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -643,9 +750,9 @@ function Designer({
|
||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div>
|
||||
Quy tắc: mỗi Bước cố định {FIXED_LEVELS_PER_STEP} Cấp duyệt thuộc cùng Phòng (Cấp 1 → Cấp 2 → Cấp 3 tuần tự,
|
||||
hoặc cùng cấp tùy tên gọi — logic vẫn iterate Order asc). Hết Cấp ở Bước hiện tại → sang Bước kế.
|
||||
Mỗi Cấp = 1 NV cụ thể trong Phòng (KHÔNG OR-of-many). Hết tất cả Bước = Đã duyệt.
|
||||
Quy tắc: mỗi Bước có 1 Phòng + tối đa {MAX_LEVELS_PER_STEP} Cấp. Quy trình chạy theo số cấp thật sự có
|
||||
(1 / 2 / 3 cấp đều OK). Trong cùng 1 Cấp có thể có nhiều NV — chỉ cần 1 NV duyệt là cấp đó pass
|
||||
(OR-of-N). Tuần tự: Cấp 1 → Cấp 2 → Cấp 3 → Bước kế. Hết tất cả Bước = Đã duyệt.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -180,6 +180,13 @@ public record CreateAwDefinitionCommand(
|
||||
|
||||
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
|
||||
{
|
||||
// Convention Mig 22 + UAT iter 2 (2026-05-08):
|
||||
// Tối đa 3 Cấp/Bước (Order ∈ {1,2,3}). Mỗi Cấp có N approver (multiple
|
||||
// Level rows cùng Order = same Cấp, mỗi row = 1 NV). Sequential gating:
|
||||
// có Cấp k yêu cầu Cấp k-1 đã có ≥1 NV. Không duplicate (Order, UserId)
|
||||
// cùng 1 Bước.
|
||||
public const int MaxLevelsPerStep = 3;
|
||||
|
||||
public CreateAwDefinitionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ApplicableType).Must(t => Enum.IsDefined(typeof(ApprovalWorkflowApplicableType), t))
|
||||
@ -199,13 +206,39 @@ public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefi
|
||||
.WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
|
||||
step.RuleForEach(s => s.Levels).ChildRules(level =>
|
||||
{
|
||||
level.RuleFor(l => l.Order).GreaterThanOrEqualTo(1);
|
||||
level.RuleFor(l => l.Order).InclusiveBetween(1, MaxLevelsPerStep)
|
||||
.WithMessage($"Cấp duyệt chỉ trong khoảng 1..{MaxLevelsPerStep}.");
|
||||
level.RuleFor(l => l.Name).MaximumLength(200);
|
||||
level.RuleFor(l => l.ApproverUserId).NotEmpty()
|
||||
.WithMessage("Cấp duyệt phải chỉ định 1 nhân viên cụ thể.");
|
||||
.WithMessage("Mỗi dòng cấp phải chỉ định 1 NV duyệt.");
|
||||
});
|
||||
// Sequential gating + no-duplicate (Order, UserId).
|
||||
step.RuleFor(s => s.Levels).Must(HaveSequentialOrders)
|
||||
.WithMessage($"Cấp duyệt phải tuần tự từ 1 (có Cấp k cần Cấp k-1). Tối đa {MaxLevelsPerStep} cấp.");
|
||||
step.RuleFor(s => s.Levels).Must(HaveNoDuplicateApproverInSameLevel)
|
||||
.WithMessage("Một NV không được duyệt hai lần trong cùng một Cấp.");
|
||||
});
|
||||
}
|
||||
|
||||
// Cho phép 1 / 1+2 / 1+2+3 — KHÔNG cho 2 (thiếu 1) hoặc 1+3 (thiếu 2).
|
||||
private static bool HaveSequentialOrders(List<CreateAwLevelInput> levels)
|
||||
{
|
||||
if (levels.Count == 0) return false;
|
||||
var orders = levels.Select(l => l.Order).Distinct().OrderBy(o => o).ToList();
|
||||
if (orders.Count == 0 || orders.Count > MaxLevelsPerStep) return false;
|
||||
for (int i = 0; i < orders.Count; i++)
|
||||
{
|
||||
if (orders[i] != i + 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HaveNoDuplicateApproverInSameLevel(List<CreateAwLevelInput> levels)
|
||||
{
|
||||
return levels
|
||||
.GroupBy(l => new { l.Order, l.ApproverUserId })
|
||||
.All(g => g.Count() == 1);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
|
||||
|
||||
Reference in New Issue
Block a user