[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

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:
pqhuy1987
2026-05-08 13:04:13 +07:00
parent 12daa7f6b0
commit 9712778929

View File

@ -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ấ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 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>