[CLAUDE] FE-Admin+Docs: PE workflow N-stage Designer + UsersPage cấp + Docs (Chunk F)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m0s

FE Admin:
- types/users.ts: User +positionLevel field + PositionLevel const +
  PositionLevelLabel/Short maps (NV/PP/TP).
- PeWorkflowsPage.tsx Designer extend: InnerStepDto + EditInnerStep types,
  copyFromDefinition include, departmentsList query, sub-section "Cấp duyệt
  nhỏ trong phòng" per step card với drag-list { Phòng × Cấp + required }
  + button "+ Thêm cấp duyệt" emerald + payload include (Order asc).
  Empty state hint fallback 2-cấp legacy.
- UsersPage.tsx: column "Cấp" badge NV/PP/TP emerald (— nếu null) +
  action button cycle null→1→2→3→null call PATCH /users/{id}/position-level.

KHÔNG đụng fe-user — admin-only feature (PeWorkflowsPage + UsersPage ở
fe-admin only).

Docs:
- STATUS.md Last updated + Phase summary count (17→19 mig, 83→89 test,
  55→56 bảng) + 1 row Recently Done Session 12 (KEEP narrative cũ).
- HANDOFF.md TL;DR Session 12 prepend + 8 cảnh báo Session 13+ + giữ
  Session phase 2 narrative.
- migration-todos.md Phase 9 + Session 12 block 6 chunk + 5 defer task.
- session log NEW `2026-05-07-2300-n-stage-workflow.md` đầy đủ rationale
  + per-chunk + bug log + plan hierarchy.

Defer cron audit 2026-06-01: schema-diagram §15 Mig 18 + §16 Mig 19,
skill ef-core-migration Mig 18+19 row, skill contract-workflow N-stage
cross-ref section.

Verify:
- npm run build fe-admin pass (✓ built, 0 TS error)
- dotnet test 89 pass (no regression)
- dotnet build 0 error

🎉 SESSION 12 COMPLETE: N-stage workflow approval Phòng × PositionLevel
PE-only. Backward compat 100% với 2-stage Mig 16. 6 commit per-chunk
A→F. Total 89 test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 18:32:56 +07:00
parent 83ffabd0b5
commit 5e5042d717
7 changed files with 558 additions and 9 deletions

View File

@ -15,12 +15,33 @@ import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { AVAILABLE_ROLES, RoleLabel } from '@/types/users'
import { AVAILABLE_ROLES, RoleLabel, PositionLevel, PositionLevelLabel } from '@/types/users'
import type { Department, Paged } from '@/types/master'
// ===== Types (mirror BE PeWorkflowAdminOverviewDto) =====
type ApproverDto = { kind: number; assignmentValue: string; displayName: string | null }
type StepDto = { id: string; order: number; phase: number; phaseLabel: string; name: string; slaDays: number | null; approvers: ApproverDto[] }
// Mig 18 — N-stage inner step DTO
type InnerStepDto = {
id: string
order: number
departmentId: string
departmentName: string | null
positionLevel: number // 1=NV, 2=PP, 3=TP
name: string | null
slaDays: number | null
isRequired: boolean
}
type StepDto = {
id: string
order: number
phase: number
phaseLabel: string
name: string
slaDays: number | null
approvers: ApproverDto[]
innerSteps: InnerStepDto[]
}
type DefinitionDto = {
id: string
code: string
@ -54,7 +75,22 @@ const PHASE_OPTIONS: { value: number; label: string }[] = [
]
type EditStepApprover = { kind: 1 | 2; assignmentValue: string }
type EditStep = { phase: number; name: string; slaDays: number | null; approvers: EditStepApprover[] }
// Mig 18 — Inner step level con
type EditInnerStep = {
order: number
departmentId: string
positionLevel: number // 1/2/3
name: string
slaDays: number | null
isRequired: boolean
}
type EditStep = {
phase: number
name: string
slaDays: number | null
approvers: EditStepApprover[]
innerSteps: EditInnerStep[]
}
function copyFromDefinition(d: DefinitionDto): EditStep[] {
return d.steps.map(s => ({
@ -62,6 +98,14 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
name: s.name,
slaDays: s.slaDays,
approvers: s.approvers.map(a => ({ kind: a.kind as 1 | 2, assignmentValue: a.assignmentValue })),
innerSteps: (s.innerSteps ?? []).map(i => ({
order: i.order,
departmentId: i.departmentId,
positionLevel: i.positionLevel,
name: i.name ?? '',
slaDays: i.slaDays,
isRequired: i.isRequired,
})),
}))
}
@ -272,7 +316,7 @@ function PeWorkflowDesigner({
() =>
cloneFrom
? copyFromDefinition(cloneFrom)
: [{ phase: 1, name: 'Soạn thảo', slaDays: 3, approvers: [] }],
: [{ phase: 1, name: 'Soạn thảo', slaDays: 3, approvers: [], innerSteps: [] }],
[cloneFrom],
)
@ -288,6 +332,12 @@ function PeWorkflowDesigner({
(await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
})
const departmentsList = useQuery({
queryKey: ['departments-for-inner-step'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const save = useMutation({
mutationFn: async () => {
await api.post('/pe-workflows', {
@ -301,6 +351,14 @@ function PeWorkflowDesigner({
name: s.name,
slaDays: s.slaDays,
approvers: s.approvers,
innerSteps: s.innerSteps.map((ii, ix) => ({
order: ix + 1,
departmentId: ii.departmentId,
positionLevel: ii.positionLevel,
name: ii.name || null,
slaDays: ii.slaDays,
isRequired: ii.isRequired,
})),
})),
})
},
@ -359,7 +417,7 @@ function PeWorkflowDesigner({
type="button"
size="sm"
variant="outline"
onClick={() => setSteps([...steps, { phase: 2, name: '', slaDays: 3, approvers: [] }])}
onClick={() => setSteps([...steps, { phase: 2, name: '', slaDays: 3, approvers: [], innerSteps: [] }])}
>
<Plus className="h-3.5 w-3.5" />
Thêm bước
@ -482,6 +540,104 @@ function PeWorkflowDesigner({
))}
</div>
</div>
{/* Inner Steps (Mig 18) — N-stage approval Phòng × Cấp chức danh */}
<div className="mt-2 border-t border-slate-200 pt-2">
<div className="mb-1 flex items-center justify-between">
<Label className="text-[11px]">
Cấp duyệt nhỏ trong phòng (sequential Order asc)
{s.innerSteps.length > 0 && <span className="ml-1 text-slate-400">· {s.innerSteps.length} cấp</span>}
</Label>
<button
type="button"
disabled={!departmentsList.data || departmentsList.data.length === 0}
onClick={() => {
const firstDeptId = departmentsList.data?.[0]?.id ?? ''
setSteps(steps.map((x, i) =>
i === idx ? {
...x,
innerSteps: [...x.innerSteps, {
order: x.innerSteps.length + 1,
departmentId: firstDeptId,
positionLevel: PositionLevel.NhanVien,
name: '',
slaDays: null,
isRequired: true,
}],
} : x,
))
}}
className="rounded bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 hover:bg-emerald-100 disabled:opacity-50"
>
+ Thêm cấp duyệt
</button>
</div>
{s.innerSteps.length === 0 && (
<div className="rounded bg-slate-100 px-2 py-1.5 text-[11px] italic text-slate-500">
Chưa cấu hình cấp con workflow fallback logic 2-cấp NV/TPB legacy.
</div>
)}
{s.innerSteps.length > 0 && (
<div className="space-y-1">
{s.innerSteps.map((ii, ix) => (
<div key={ix} className="flex items-center gap-1 rounded border border-slate-200 bg-white p-1">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-[10px] font-bold text-white">
{ix + 1}
</span>
<Select
value={ii.departmentId}
onChange={e =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, innerSteps: x.innerSteps.map((y, j) => (j === ix ? { ...y, departmentId: e.target.value } : y)) } : x,
))
}
className="h-7 flex-1 text-xs"
>
{departmentsList.data?.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
<Select
value={ii.positionLevel}
onChange={e =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, innerSteps: x.innerSteps.map((y, j) => (j === ix ? { ...y, positionLevel: Number(e.target.value) } : y)) } : x,
))
}
className="h-7 w-28 text-xs"
>
<option value={PositionLevel.NhanVien}>{PositionLevelLabel[1]}</option>
<option value={PositionLevel.PhoPhong}>{PositionLevelLabel[2]}</option>
<option value={PositionLevel.TruongPhong}>{PositionLevelLabel[3]}</option>
</Select>
<label className="flex shrink-0 items-center gap-1 text-[11px] text-slate-500">
<input
type="checkbox"
checked={ii.isRequired}
onChange={e =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, innerSteps: x.innerSteps.map((y, j) => (j === ix ? { ...y, isRequired: e.target.checked } : y)) } : x,
))
}
/>
required
</label>
<button
type="button"
onClick={() =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, innerSteps: x.innerSteps.filter((_, j) => j !== ix) } : x,
))
}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>

View File

@ -14,7 +14,7 @@ import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { MenuKeys } from '@/lib/menuKeys'
import type { Department, Paged } from '@/types/master'
import { AVAILABLE_ROLES, RoleShortName, RoleLabel, type User } from '@/types/users'
import { AVAILABLE_ROLES, RoleShortName, RoleLabel, PositionLevelShort, PositionLevelLabel, type User } from '@/types/users'
const fmtDate = (s: string) => new Date(s).toLocaleDateString('vi-VN')
@ -163,6 +163,25 @@ export function UsersPage() {
onError: err => toast.error(getErrorMessage(err)),
})
// N-stage workflow inner step (Mig 18): set cấp chức danh user (NV/PP/TP).
// Inline cycle qua 3 cấp + null mỗi click — admin tinh chỉnh sau qua API.
const positionLevelMut = useMutation({
mutationFn: (input: { id: string; positionLevel: number | null }) =>
api.patch(`/users/${input.id}/position-level`, { positionLevel: input.positionLevel }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] })
toast.success('Đã cập nhật cấp chức danh')
},
onError: err => toast.error(getErrorMessage(err)),
})
function nextPositionLevel(current: number | null): number | null {
// Cycle null → 1 (NV) → 2 (PP) → 3 (TP) → null
if (current == null) return 1
if (current === 3) return null
return current + 1
}
function openRoles(u: User) {
setRolesModal(u)
setRoleSelection([...u.roles])
@ -253,6 +272,23 @@ export function UsersPage() {
<span className="text-xs text-slate-400"></span>
),
},
{
key: 'positionLevel',
header: 'Cấp',
width: 'w-16',
align: 'center',
render: u =>
u.positionLevel != null ? (
<span
title={PositionLevelLabel[u.positionLevel]}
className="inline-flex items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700"
>
{PositionLevelShort[u.positionLevel]}
</span>
) : (
<span className="text-xs text-slate-400"></span>
),
},
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-24', render: u => fmtDate(u.createdAt) },
{
key: 'actions',
@ -284,6 +320,20 @@ export function UsersPage() {
>
<ShieldCheck className={`h-3.5 w-3.5 ${u.canBypassReview ? 'text-fuchsia-600' : 'text-slate-400'}`} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => positionLevelMut.mutate({ id: u.id, positionLevel: nextPositionLevel(u.positionLevel) })}
title={
u.positionLevel == null
? 'Chưa set cấp — click để set NV'
: `Cấp ${PositionLevelLabel[u.positionLevel]} — click để cycle (${u.positionLevel === 3 ? 'NV→PP→TP→clear' : 'next'})`
}
>
<span className={`text-[10px] font-bold ${u.positionLevel != null ? 'text-emerald-600' : 'text-slate-400'}`}>
{u.positionLevel != null ? PositionLevelShort[u.positionLevel] : '—'}
</span>
</Button>
<Button size="sm" variant="ghost" onClick={() => toggleActiveMut.mutate(u)} title={u.isActive ? 'Vô hiệu hóa' : 'Kích hoạt'}>
{u.isActive ? <XCircle className="h-3.5 w-3.5 text-red-500" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
</Button>

View File

@ -10,6 +10,26 @@ export type User = {
departmentName: string | null
position: string | null
canBypassReview: boolean
positionLevel: number | null // Mig 18 — 1=NV, 2=PP, 3=TP, null=admin/external
}
// Cấp chức danh trong phòng (Mig 18) — phục vụ N-stage workflow inner step.
export const PositionLevel = {
NhanVien: 1,
PhoPhong: 2,
TruongPhong: 3,
} as const
export type PositionLevelValue = typeof PositionLevel[keyof typeof PositionLevel]
export const PositionLevelLabel: Record<number, string> = {
1: 'NV (Nhân viên)',
2: 'PP (Phó phòng)',
3: 'TP (Trưởng phòng)',
}
export const PositionLevelShort: Record<number, string> = {
1: 'NV',
2: 'PP',
3: 'TP',
}
export type CreateUserInput = {