[CLAUDE] FE-Admin+Docs: Contract workflow N-stage Designer mirror PE + Docs (Chunk F)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m3s

FE Admin WorkflowsPage Designer extend mirror PeWorkflowsPage:
- Type InnerStepDto + extend StepDto +innerSteps
- Type EditInnerStep + extend EditStep +innerSteps
- copyFromDefinition include innerSteps map
- Default new step +innerSteps:[]
- departmentsList useQuery
- Save mutation payload include innerSteps Order asc
- UI sub-section "Cấp duyệt nhỏ trong phòng" drag-list per step card với
  Phòng × Cấp + required checkbox + button "+ Thêm cấp duyệt" emerald
- Empty state hint fallback 2-cấp legacy

KHÔNG đụng fe-user — WorkflowsPage admin-only.

Reuse PositionLevel const + Label maps từ Session 12 types/users.ts.

Docs:
- STATUS.md Last updated + Phase summary (19→20 mig, 89→95 test, 56→57
  bảng) + 1 row Recently Done Session 13 (KEEP narrative cũ)
- HANDOFF.md TL;DR Session 13 prepend + 7 cảnh báo Session 14+
- migration-todos.md Phase 9 + Session 13 block 5 chunk
- Session log NEW `2026-05-07-2400-n-stage-contract-mirror.md` đầy đủ
  rationale + per-chunk + bug log

Defer cron audit 2026-06-01: schema-diagram §17 Mig 20, skill
ef-core-migration row, skill contract-workflow N-stage cross-ref.

🎉 SESSION 13 COMPLETE: Mirror N-stage Contract module (Mig 20). 5
commit per-chunk + skip Chunk E auto-bind. Total 95 test pass.
Backward compat 100% với 2-stage Mig 16 legacy.

Pending Task 4: Wire BE TraLai PE transition + Task 2: Sample data seed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 19:13:27 +07:00
parent 7c0772acca
commit b06bdce694
5 changed files with 428 additions and 8 deletions

View File

@ -12,12 +12,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 =====
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 20 — N-stage inner step DTO mirror PE Mig 18
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
@ -51,7 +72,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 20 — 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 => ({
@ -59,6 +95,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,
})),
}))
}
@ -279,7 +323,7 @@ function WorkflowDesigner({
() =>
cloneFrom
? copyFromDefinition(cloneFrom)
: [{ phase: 2, name: 'Soạn thảo', slaDays: 7, approvers: [] }],
: [{ phase: 2, name: 'Soạn thảo', slaDays: 7, approvers: [], innerSteps: [] }],
[cloneFrom],
)
@ -294,6 +338,12 @@ function WorkflowDesigner({
(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('/workflows', {
@ -307,6 +357,14 @@ function WorkflowDesigner({
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,
})),
})),
})
},
@ -366,7 +424,7 @@ function WorkflowDesigner({
size="sm"
variant="outline"
onClick={() =>
setSteps([...steps, { phase: 3, name: '', slaDays: 7, approvers: [] }])
setSteps([...steps, { phase: 3, name: '', slaDays: 7, approvers: [], innerSteps: [] }])
}
>
<Plus className="h-3.5 w-3.5" />
@ -490,6 +548,104 @@ function WorkflowDesigner({
))}
</div>
</div>
{/* Inner Steps (Mig 20) — N-stage approval Phòng × Cấp chức danh — mirror PE Mig 18 */}
<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>