From 971277892951aec6f86a0ed5952db52c1cc47807 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 8 May 2026 13:04:13 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-Admin:=20Lock=203=20c=E1=BA=A5p/b?= =?UTF-8?q?=C6=B0=E1=BB=9Bc=20+=20filter=20NV=20theo=20Ph=C3=B2ng=20(V2=20?= =?UTF-8?q?UAT=20iter=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pages/system/ApprovalWorkflowsV2Page.tsx | 260 ++++++++++-------- 1 file changed, 138 insertions(+), 122 deletions(-) diff --git a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx index 9c77b2e..aacff6d 100644 --- a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx +++ b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx @@ -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 = { DuyetNcc: 1, @@ -75,14 +86,47 @@ const DEFAULT_CODE_BY_TYPE: Record = { 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 (
- +
- + - 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" - /> - + 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" + /> + - - - -
- ))} - {s.levels.length === 0 && ( -
- Chưa có cấp. Bấm "+ Thêm cấp" để chỉ định NV duyệt. + {filteredUsers.map(u => ( + + ))} + +
+ ) + })} + {s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && ( +
+ ⚠ 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.
)} @@ -628,8 +643,9 @@ function Designer({
- 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.