[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
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:
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user