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