[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).
|
// 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:
|
// Schema riêng UAT trước khi drop legacy. Cấu trúc:
|
||||||
// Quy trình (Mã + Tên + ApplicableType)
|
// Quy trình (Mã + Tên + ApplicableType)
|
||||||
// Bước 1 — Phòng A (FIXED 3 cấp)
|
// Bước 1 — Phòng A
|
||||||
// Cấp 1 — NV X thuộc Phòng A
|
// Cấp 1 (N NV duyệt)
|
||||||
// Cấp 2 — NV Y thuộc Phòng A
|
// Cấp 2 (N NV duyệt)
|
||||||
// Cấp 3 — NV Z thuộc Phòng A
|
// Cấp 3 (N NV duyệt)
|
||||||
//
|
//
|
||||||
// Iteration 2 (UAT feedback 2026-05-08):
|
// Iteration 3 (UAT feedback 2026-05-08):
|
||||||
// - LOCK CỨNG 3 cấp/bước (không Add/Remove/Move). Logic backend giữ nguyên
|
// - TỐI ĐA 3 cấp/bước (không có cấp 4). 1/2/3 cấp đều OK — quy trình
|
||||||
// iterate Levels OrderBy Order — chỉ giới hạn UI.
|
// chạy theo số cấp thật sự có.
|
||||||
// - Cùng 1 phòng có thể chọn 3 NV cùng cấp (không bắt khác cấp).
|
// - MỖI CẤP CÓ N NV: convention multiple Level rows cùng Order = same Cấp.
|
||||||
// - Select NV CHỈ filter theo Phòng đã chọn (đổi Phòng → reset 3 approver).
|
// 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 { useMemo, useState, type FormEvent } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@ -65,14 +70,16 @@ type TypeSummaryDto = {
|
|||||||
history: DefinitionDto[]
|
history: DefinitionDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditLevel = { name: string; approverUserId: string }
|
type LevelOrder = 1 | 2 | 3
|
||||||
type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] }
|
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 }
|
type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null }
|
||||||
|
|
||||||
// Lock cứng 3 cấp/bước per UAT 2026-05-08.
|
// Tối đa 3 cấp/bước per UAT iter 3 (2026-05-08). Quy trình chạy theo số cấp
|
||||||
// Logic BE iterate Levels OrderBy Order vẫn cho phép N cấp — đây chỉ là giới hạn UI.
|
// thật sự có (1 / 2 / 3). Mỗi cấp có N NV (multiple Level rows cùng Order).
|
||||||
const FIXED_LEVELS_PER_STEP = 3
|
const MAX_LEVELS_PER_STEP = 3
|
||||||
|
const LEVEL_ORDERS: LevelOrder[] = [1, 2, 3]
|
||||||
|
|
||||||
// FE typeCode → BE int (giống MenuKeys ApplicableType)
|
// FE typeCode → BE int (giống MenuKeys ApplicableType)
|
||||||
const TYPE_CODE_TO_INT: Record<string, number> = {
|
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',
|
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 {
|
function makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep {
|
||||||
return {
|
return {
|
||||||
name: `Phòng ${stepNo}`,
|
name: `Phòng ${stepNo}`,
|
||||||
departmentId: deptId,
|
departmentId: deptId,
|
||||||
levels: makeEmptyLevels(),
|
levelEntries: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pad/truncate về đúng FIXED_LEVELS_PER_STEP cấp khi clone version cũ
|
// Clone existing definition: filter Order ∈ {1,2,3}, drop entries vượt giới hạn.
|
||||||
// (phòng khi DB có data legacy >3 hoặc <3 levels).
|
|
||||||
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||||
return d.steps.map(s => {
|
return d.steps.map(s => ({
|
||||||
const levels = s.levels.slice(0, FIXED_LEVELS_PER_STEP).map((l, i) => ({
|
name: s.name,
|
||||||
name: l.name ?? `Cấp ${i + 1}`,
|
departmentId: s.departmentId,
|
||||||
approverUserId: l.approverUserId,
|
levelEntries: s.levels
|
||||||
}))
|
.filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP)
|
||||||
while (levels.length < FIXED_LEVELS_PER_STEP) {
|
.map(l => ({ order: l.order as LevelOrder, approverUserId: l.approverUserId })),
|
||||||
levels.push({ name: `Cấp ${levels.length + 1}`, approverUserId: '' })
|
}))
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: s.name,
|
|
||||||
departmentId: s.departmentId,
|
|
||||||
levels,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter NV theo Phòng. Nếu Phòng = null → fallback all (chưa chọn phòng).
|
// 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)
|
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() {
|
export function ApprovalWorkflowsV2Page() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { typeCode } = useParams<{ typeCode?: string }>()
|
const { typeCode } = useParams<{ typeCode?: string }>()
|
||||||
@ -322,25 +324,39 @@ function DefinitionCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{s.levels.length === 0 ? (
|
||||||
<li className="text-[11px] italic text-slate-400">Chưa có cấp duyệt</li>
|
<li className="text-[11px] italic text-slate-400">Chưa có cấp duyệt</li>
|
||||||
) : (
|
) : (
|
||||||
s.levels.map(l => (
|
Array.from(
|
||||||
<li key={l.id} className="flex items-center gap-2 text-xs">
|
s.levels.reduce((map, l) => {
|
||||||
<span className="rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
|
const arr = map.get(l.order) ?? []
|
||||||
C{l.order}
|
arr.push(l)
|
||||||
</span>
|
map.set(l.order, arr)
|
||||||
<span className="text-slate-700">{l.name || `Cấp ${l.order}`}</span>
|
return map
|
||||||
<span className="text-slate-400">—</span>
|
}, new Map<number, LevelDto[]>()).entries(),
|
||||||
<span className="font-medium text-slate-800">
|
)
|
||||||
{l.approverUserName ?? l.approverUserId}
|
.sort(([a], [b]) => a - b)
|
||||||
</span>
|
.map(([order, group]) => (
|
||||||
{l.approverEmail && (
|
<li key={order} className="flex items-start gap-2 text-xs">
|
||||||
<span className="text-[10px] text-slate-400">({l.approverEmail})</span>
|
<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}
|
||||||
</li>
|
</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>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@ -404,20 +420,26 @@ function Designer({
|
|||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
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) {
|
for (const s of steps) {
|
||||||
if (!s.departmentId) {
|
if (!s.departmentId) {
|
||||||
throw new Error(`Bước "${s.name}" chưa chọn Phòng.`)
|
throw new Error(`Bước "${s.name}" chưa chọn Phòng.`)
|
||||||
}
|
}
|
||||||
if (s.levels.length !== FIXED_LEVELS_PER_STEP) {
|
if (entriesAtLevel(s, 1).length === 0) {
|
||||||
throw new Error(`Bước "${s.name}" phải có đúng ${FIXED_LEVELS_PER_STEP} cấp.`)
|
throw new Error(`Bước "${s.name}" chưa có NV ở Cấp 1.`)
|
||||||
}
|
}
|
||||||
for (const l of s.levels) {
|
// Sequential gating đã enforce ở UI nhưng kiểm tra lại
|
||||||
if (!l.approverUserId) {
|
if (entriesAtLevel(s, 2).length === 0 && entriesAtLevel(s, 3).length > 0) {
|
||||||
throw new Error(`Bước "${s.name}" còn cấp chưa chọn nhân viên duyệt.`)
|
throw new Error(`Bước "${s.name}": Cấp 3 chỉ thao tác được khi Cấp 2 có NV.`)
|
||||||
|
}
|
||||||
|
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.`)
|
||||||
}
|
}
|
||||||
// Defensive: Select đã filter, nhưng double-check để tránh bypass UI.
|
const u = usersList.data?.find(x => x.id === e.approverUserId)
|
||||||
const u = usersList.data?.find(x => x.id === l.approverUserId)
|
|
||||||
if (u && u.departmentId !== s.departmentId) {
|
if (u && u.departmentId !== s.departmentId) {
|
||||||
throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`)
|
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,
|
order: i + 1,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
departmentId: s.departmentId,
|
departmentId: s.departmentId,
|
||||||
levels: s.levels.map((l, j) => ({
|
// Mỗi entry → 1 Level row. Multiple rows cùng Order = same Cấp với
|
||||||
order: j + 1,
|
// N approvers (BE iterate group by Order).
|
||||||
name: l.name || null,
|
levels: s.levelEntries.map(e => ({
|
||||||
approverUserId: l.approverUserId,
|
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="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>
|
<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>
|
</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -532,10 +556,10 @@ function Designer({
|
|||||||
<Select
|
<Select
|
||||||
value={s.departmentId ?? ''}
|
value={s.departmentId ?? ''}
|
||||||
onChange={e => {
|
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
|
const newDeptId = e.target.value || null
|
||||||
setSteps(steps.map((x, i) => (i === idx
|
setSteps(steps.map((x, i) => (i === idx
|
||||||
? { ...x, departmentId: newDeptId, levels: x.levels.map(l => ({ ...l, approverUserId: '' })) }
|
? { ...x, departmentId: newDeptId, levelEntries: [] }
|
||||||
: x)))
|
: x)))
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
@ -577,64 +601,147 @@ function Designer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Levels — LOCK 3 cấp/bước. NV filter theo Phòng đã chọn. */}
|
{/* Levels — 3 section cố định C1/C2/C3. Mỗi cấp có N NV.
|
||||||
<div className="mt-2 ml-9 space-y-1.5 border-l-2 border-violet-200 pl-3">
|
Sequential gating: Cấp k chỉ active khi Cấp k-1 có ≥1 NV. */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="mt-2 ml-9 space-y-2 border-l-2 border-violet-200 pl-3">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">
|
{!s.departmentId && (
|
||||||
Cấp duyệt (cố định {FIXED_LEVELS_PER_STEP} cấp)
|
<div className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-700">
|
||||||
</span>
|
⚠ Chọn Phòng để bắt đầu cấu hình cấp duyệt.
|
||||||
{!s.departmentId && (
|
</div>
|
||||||
<span className="rounded bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
)}
|
||||||
⚠ Chọn Phòng để load NV
|
{s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && (
|
||||||
</span>
|
<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>
|
</div>
|
||||||
{s.levels.map((l, li) => {
|
)}
|
||||||
|
|
||||||
|
{LEVEL_ORDERS.map(order => {
|
||||||
|
const entries = entriesAtLevel(s, order)
|
||||||
|
const enabled = isLevelEnabled(s, order)
|
||||||
const filteredUsers = usersForDept(usersList.data, s.departmentId)
|
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 (
|
return (
|
||||||
<div key={li} className="flex items-center gap-1.5">
|
<div
|
||||||
<span className="rounded-full bg-violet-100 px-2 py-1 font-mono text-[10px] font-bold text-violet-700">
|
key={order}
|
||||||
C{li + 1}
|
className={`rounded-md border p-2 ${
|
||||||
</span>
|
enabled
|
||||||
<Input
|
? 'border-violet-200 bg-white'
|
||||||
value={l.name}
|
: 'border-slate-200 bg-slate-50/50 opacity-60'
|
||||||
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,
|
<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">
|
||||||
placeholder={`Cấp ${li + 1}`}
|
Cấp {order}
|
||||||
className="h-7 max-w-[120px] text-xs"
|
</span>
|
||||||
/>
|
<span className="text-[11px] text-slate-500">
|
||||||
<Select
|
{entries.length === 0
|
||||||
value={l.approverUserId}
|
? (enabled ? 'Chưa có NV' : `Hoàn tất Cấp ${order - 1} trước`)
|
||||||
onChange={e =>
|
: `${entries.length} NV duyệt`}
|
||||||
setSteps(steps.map((x, i) =>
|
</span>
|
||||||
i === idx
|
</div>
|
||||||
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
|
<button
|
||||||
: x,
|
type="button"
|
||||||
))
|
disabled={addDisabled}
|
||||||
}
|
onClick={() => {
|
||||||
className="h-7 flex-1 text-xs"
|
if (availableUsers.length === 0) return
|
||||||
disabled={!s.departmentId}
|
const firstUser = availableUsers[0]
|
||||||
>
|
setSteps(steps.map((x, i) =>
|
||||||
<option value="">
|
i === idx
|
||||||
{s.departmentId ? '— Chọn NV duyệt —' : '— Chọn Phòng trước —'}
|
? { ...x, levelEntries: [...x.levelEntries, { order, approverUserId: firstUser.id }] }
|
||||||
</option>
|
: x,
|
||||||
{filteredUsers.map(u => (
|
))
|
||||||
<option key={u.id} value={u.id}>
|
}}
|
||||||
{u.fullName} ({u.email})
|
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"
|
||||||
</option>
|
title={
|
||||||
))}
|
!enabled
|
||||||
</Select>
|
? `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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ 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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<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" />
|
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<div>
|
<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ự,
|
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ó
|
||||||
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ế.
|
(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
|
||||||
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.
|
(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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -180,6 +180,13 @@ public record CreateAwDefinitionCommand(
|
|||||||
|
|
||||||
public class CreateAwDefinitionCommandValidator : AbstractValidator<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()
|
public CreateAwDefinitionCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.ApplicableType).Must(t => Enum.IsDefined(typeof(ApprovalWorkflowApplicableType), t))
|
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.");
|
.WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
|
||||||
step.RuleForEach(s => s.Levels).ChildRules(level =>
|
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.Name).MaximumLength(200);
|
||||||
level.RuleFor(l => l.ApproverUserId).NotEmpty()
|
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)
|
public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
|
||||||
|
|||||||
Reference in New Issue
Block a user