[CLAUDE] FE-Admin: Lock 3 cấp/bước + filter NV theo Phòng (V2 UAT iter 1)
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
User feedback Session 17 sau khi UAT Designer V2 lần đầu:
- "Chốt cứng 1 phòng 3 cấp đi nhé (Logic vẫn giữ như thế nhưng giới
hạn lại, không thay đổi Logic)"
- "Liên kết đúng Phòng A → Thì Select nhân viên phòng A thôi"
- "User có thể cùng cấp với nhau" (không bắt unique level name)
Files: fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx
- FIXED_LEVELS_PER_STEP = 3 const + makeEmptyLevels()/makeEmptyStep()
helpers. Initial state mỗi Step có sẵn 3 levels (C1/C2/C3).
- copyFromDefinition pad/truncate về đúng 3 cấp (defensive cho data
legacy >3 hoặc <3).
- Bỏ button "+ Thêm cấp" + nút Trash xóa cấp + chevron move cấp.
Vẫn giữ Add/Remove + reorder Step (Bước).
- Filter Select NV theo s.departmentId (usersForDept helper):
deptId=null → fallback all (chưa chọn phòng)
deptId set → chỉ NV.DepartmentId === deptId
- Đổi Phòng → reset 3 approver về '' (NV cũ có thể không thuộc Phòng
mới). User select lại 3 NV.
- Phòng required (* + required attr Select) — empty Phòng disable
Select NV với placeholder "Chọn Phòng trước".
- Empty filtered users → hint amber "Phòng chưa có NV, vào /system/users".
- Save validate: phải có Phòng + đúng 3 cấp + tất cả approverUserId
thuộc đúng deptId (defensive double-check).
- ApproverUser type +departmentId (đã có sẵn ở UserDto BE+FE types).
- pageSize 200→500 đảm bảo load đủ NV.
Logic BE KHÔNG đổi: Service iterate Levels OrderBy Order. UI giới hạn
3 cấp chỉ là quy ước, BE vẫn handle N cấp nếu DB có.
Verify: npm build fe-admin OK, 1924 modules, 0 TS error.
This commit is contained in:
@ -1,11 +1,16 @@
|
||||
// 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
|
||||
// Cấp 1 — NV X (1 user CỤ THỂ qua ApproverUserId)
|
||||
// Cấp 2 — NV Y
|
||||
// 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
|
||||
//
|
||||
// Khác Designer cũ (PE workflow): Levels match 1 NV chính xác (KHÔNG OR-of-many).
|
||||
// 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).
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@ -63,6 +68,12 @@ type TypeSummaryDto = {
|
||||
type EditLevel = { name: string; approverUserId: string }
|
||||
type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] }
|
||||
|
||||
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
|
||||
|
||||
// FE typeCode → BE int (giống MenuKeys ApplicableType)
|
||||
const TYPE_CODE_TO_INT: Record<string, number> = {
|
||||
DuyetNcc: 1,
|
||||
@ -75,14 +86,47 @@ const DEFAULT_CODE_BY_TYPE: Record<number, string> = {
|
||||
3: 'QT-HD-V2-001',
|
||||
}
|
||||
|
||||
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||
return d.steps.map(s => ({
|
||||
name: s.name,
|
||||
departmentId: s.departmentId,
|
||||
levels: s.levels.map(l => ({ name: l.name ?? '', approverUserId: l.approverUserId })),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
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 {
|
||||
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).
|
||||
function usersForDept(all: ApproverUser[] | undefined, deptId: string | null): ApproverUser[] {
|
||||
if (!all) return []
|
||||
if (!deptId) return all
|
||||
return all.filter(u => u.departmentId === deptId)
|
||||
}
|
||||
|
||||
export function ApprovalWorkflowsV2Page() {
|
||||
const qc = useQueryClient()
|
||||
const { typeCode } = useParams<{ typeCode?: string }>()
|
||||
@ -334,10 +378,7 @@ function Designer({
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const initialSteps: EditStep[] = useMemo(
|
||||
() =>
|
||||
cloneFrom
|
||||
? copyFromDefinition(cloneFrom)
|
||||
: [{ name: 'Phòng 1', departmentId: null, levels: [{ name: 'Cấp 1', approverUserId: '' }] }],
|
||||
() => (cloneFrom ? copyFromDefinition(cloneFrom) : [makeEmptyStep(1)]),
|
||||
[cloneFrom],
|
||||
)
|
||||
|
||||
@ -350,8 +391,8 @@ function Designer({
|
||||
const usersList = useQuery({
|
||||
queryKey: ['users-for-approver-v2'],
|
||||
queryFn: async () =>
|
||||
(await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', {
|
||||
params: { page: 1, pageSize: 200 },
|
||||
(await api.get<{ items: ApproverUser[] }>('/users', {
|
||||
params: { page: 1, pageSize: 500 },
|
||||
})).data.items,
|
||||
})
|
||||
|
||||
@ -363,11 +404,23 @@ function Designer({
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Validate có user trong tất cả Cấp
|
||||
// Validate: mỗi Bước phải có Phòng + 3 cấp đầy đủ + NV thuộc đúng Phòng.
|
||||
for (const s of steps) {
|
||||
if (s.levels.length === 0) throw new Error(`Bước "${s.name}" chưa có cấp duyệt nào.`)
|
||||
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.`)
|
||||
}
|
||||
for (const l of s.levels) {
|
||||
if (!l.approverUserId) throw new Error(`Bước "${s.name}" có cấp chưa chọn nhân viên duyệt.`)
|
||||
if (!l.approverUserId) {
|
||||
throw new Error(`Bước "${s.name}" còn cấp chưa chọn nhân viên duyệt.`)
|
||||
}
|
||||
// Defensive: Select đã filter, nhưng double-check để tránh bypass UI.
|
||||
const u = usersList.data?.find(x => x.id === l.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.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
await api.post('/approval-workflows-v2', {
|
||||
@ -411,15 +464,6 @@ function Designer({
|
||||
setSteps(next)
|
||||
}
|
||||
|
||||
function moveLevel(stepIdx: number, levelIdx: number, dir: -1 | 1) {
|
||||
const step = steps[stepIdx]
|
||||
const newIdx = levelIdx + dir
|
||||
if (newIdx < 0 || newIdx >= step.levels.length) return
|
||||
const next = [...step.levels]
|
||||
;[next[levelIdx], next[newIdx]] = [next[newIdx], next[levelIdx]]
|
||||
setSteps(steps.map((x, i) => (i === stepIdx ? { ...x, levels: next } : x)))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@ -454,16 +498,14 @@ 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 × N cấp NV ({steps.length} bước)</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)
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSteps([...steps, {
|
||||
name: `Phòng ${steps.length + 1}`,
|
||||
departmentId: departmentsList.data?.[0]?.id ?? null,
|
||||
levels: [{ name: 'Cấp 1', approverUserId: usersList.data?.[0]?.id ?? '' }],
|
||||
}])}
|
||||
onClick={() => setSteps([...steps, makeEmptyStep(steps.length + 1)])}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Thêm bước
|
||||
@ -486,14 +528,19 @@ function Designer({
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[11px]">Phòng (hint hiển thị)</Label>
|
||||
<Label className="text-[11px]">Phòng * (chọn để filter NV duyệt)</Label>
|
||||
<Select
|
||||
value={s.departmentId ?? ''}
|
||||
onChange={e =>
|
||||
setSteps(steps.map((x, i) => (i === idx ? { ...x, departmentId: e.target.value || null } : x)))
|
||||
}
|
||||
onChange={e => {
|
||||
// Đổi Phòng → reset 3 approver 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)))
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">— Không —</option>
|
||||
<option value="">— Chọn Phòng —</option>
|
||||
{departmentsList.data?.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
@ -530,94 +577,62 @@ function Designer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Levels */}
|
||||
{/* 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 ({s.levels.length})
|
||||
Cấp duyệt (cố định {FIXED_LEVELS_PER_STEP} cấp)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx
|
||||
? { ...x, levels: [...x.levels, { name: `Cấp ${x.levels.length + 1}`, approverUserId: usersList.data?.[0]?.id ?? '' }] }
|
||||
: x,
|
||||
))
|
||||
}
|
||||
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
||||
>
|
||||
+ Thêm cấp
|
||||
</button>
|
||||
</div>
|
||||
{s.levels.map((l, li) => (
|
||||
<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}
|
||||
{!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>
|
||||
<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 =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx
|
||||
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
|
||||
: x,
|
||||
))
|
||||
}
|
||||
className="h-7 flex-1 text-xs"
|
||||
>
|
||||
<option value="">— Chọn NV duyệt —</option>
|
||||
{usersList.data?.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.fullName} ({u.email})
|
||||
)}
|
||||
</div>
|
||||
{s.levels.map((l, li) => {
|
||||
const filteredUsers = usersForDept(usersList.data, s.departmentId)
|
||||
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}
|
||||
</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 =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx
|
||||
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
|
||||
: x,
|
||||
))
|
||||
}
|
||||
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>
|
||||
))}
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveLevel(idx, li, -1)}
|
||||
disabled={li === 0}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
title="Lên"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveLevel(idx, li, 1)}
|
||||
disabled={li === s.levels.length - 1}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
title="Xuống"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx ? { ...x, levels: x.levels.filter((_, j) => j !== li) } : 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 cấp"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{s.levels.length === 0 && (
|
||||
<div className="rounded border border-dashed border-slate-300 px-2 py-1.5 text-[11px] italic text-slate-400">
|
||||
Chưa có cấp. Bấm "+ Thêm cấp" để chỉ định NV duyệt.
|
||||
{filteredUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.fullName} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</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>
|
||||
@ -628,8 +643,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 duyệt: tuần tự trong cùng Bước (Cấp 1 → Cấp 2 → ...), hết Cấp thì sang Bước kế.
|
||||
Mỗi Cấp = 1 nhân viên cụ thể (KHÔNG OR-of-many). Hết tất cả Bước = Đã duyệt.
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user