[CLAUDE] FE-Admin: Designer flat UI Phòng × Cấp + types ChoDuyet=10 (Chunk B)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m1s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m1s
PeWorkflowsPage + WorkflowsPage rewrite for flat workflow model (Mig 21): - Drop InnerStepDto + EditInnerStep types - Drop PHASE_OPTIONS (auto-assign ChoDuyet=10 behind scenes) - StepDto + EditStep extend với departmentId, positionLevel - copyFromDefinition simplified - Designer step UI: Tên + Phòng Select + Cấp Select + SLA + Approvers Role/User optional fallback (drop entire InnerSteps sub-section) - DefinitionCard view: hiển thị badge Phòng (emerald) + Cấp NV/PP/TP (violet) + SLA per step - Save payload: phase=10 (ChoDuyet), departmentId, positionLevel - Hint amber: "Mig 21 flat workflow: User cùng Phòng + Cấp ≥ step → duyệt được (OR-of-many)" types/purchaseEvaluation.ts (fe-admin + fe-user mirror): - + ChoDuyet=10 enum value + label "Đang duyệt" + color amber - Legacy 2-6 + 98 keep cho data cũ display OK - getPeDisplayStatus: ChoDuyet + legacy intermediate → "Đã gửi duyệt" Verify: npm build fe-admin + fe-user pass. Pending Chunk D: Docs + Skill + Memory + session log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
// PE Workflow admin — mirror WorkflowsPage cho module Duyệt NCC. URL pattern
|
// PE Workflow admin — Session 16 drastic refactor (Mig 21):
|
||||||
// /system/pe-workflows/:typeCode (DuyetNcc | DuyetNccPhuongAn). Phase enum
|
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Bỏ Phase Select
|
||||||
// khác Contract (1=DangSoanThao..7=DaDuyet, 99=TuChoi).
|
// (auto-assign ChoDuyet=10 behind scenes). Bỏ InnerSteps sub-section.
|
||||||
import { useMemo, useState, type FormEvent } from 'react'
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@ -15,32 +15,23 @@ import { Select } from '@/components/ui/Select'
|
|||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { AVAILABLE_ROLES, RoleLabel, PositionLevel, PositionLevelLabel } from '@/types/users'
|
import { AVAILABLE_ROLES, RoleLabel, PositionLevel, PositionLevelLabel, PositionLevelShort } from '@/types/users'
|
||||||
import type { Department, Paged } from '@/types/master'
|
import type { Department, Paged } from '@/types/master'
|
||||||
|
|
||||||
// ===== Types (mirror BE PeWorkflowAdminOverviewDto) =====
|
// ===== Types (mirror BE PeWorkflowAdminOverviewDto post-Mig 21) =====
|
||||||
|
|
||||||
type ApproverDto = { kind: number; assignmentValue: string; displayName: string | null }
|
type ApproverDto = { kind: number; assignmentValue: string; displayName: string | null }
|
||||||
// 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 = {
|
type StepDto = {
|
||||||
id: string
|
id: string
|
||||||
order: number
|
order: number
|
||||||
phase: number
|
phase: number // [DEPRECATED] always ChoDuyet=10 for new
|
||||||
phaseLabel: string
|
phaseLabel: string
|
||||||
name: string
|
name: string // "Phòng A — Cấp 1"
|
||||||
slaDays: number | null
|
slaDays: number | null
|
||||||
|
departmentId: string | null
|
||||||
|
departmentName: string | null
|
||||||
|
positionLevel: number | null // 1=NV, 2=PP, 3=TP
|
||||||
approvers: ApproverDto[]
|
approvers: ApproverDto[]
|
||||||
innerSteps: InnerStepDto[]
|
|
||||||
}
|
}
|
||||||
type DefinitionDto = {
|
type DefinitionDto = {
|
||||||
id: string
|
id: string
|
||||||
@ -63,55 +54,27 @@ type TypeSummaryDto = {
|
|||||||
history: DefinitionDto[]
|
history: DefinitionDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// PE Phase 1..7 (state thường); 99=TuChoi không là step quy trình.
|
const PHASE_CHO_DUYET = 10 // Mig 21 — generic intermediate phase
|
||||||
const PHASE_OPTIONS: { value: number; label: string }[] = [
|
|
||||||
{ value: 1, label: 'Đang soạn thảo' },
|
|
||||||
{ value: 2, label: 'Chờ Purchasing' },
|
|
||||||
{ value: 3, label: 'Chờ Dự án' },
|
|
||||||
{ value: 4, label: 'Chờ CCM' },
|
|
||||||
{ value: 5, label: 'Chờ CEO duyệt PA' },
|
|
||||||
{ value: 6, label: 'Chờ CEO duyệt NCC' },
|
|
||||||
{ value: 7, label: 'Đã duyệt' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type EditStepApprover = { kind: 1 | 2; assignmentValue: string }
|
type EditStepApprover = { kind: 1 | 2; assignmentValue: string }
|
||||||
// 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 = {
|
type EditStep = {
|
||||||
phase: number
|
name: string // "Phòng A — Cấp 1"
|
||||||
name: string
|
|
||||||
slaDays: number | null
|
slaDays: number | null
|
||||||
|
departmentId: string | null
|
||||||
|
positionLevel: number | null // 1/2/3
|
||||||
approvers: EditStepApprover[]
|
approvers: EditStepApprover[]
|
||||||
innerSteps: EditInnerStep[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||||
return d.steps.map(s => ({
|
return d.steps.map(s => ({
|
||||||
phase: s.phase,
|
|
||||||
name: s.name,
|
name: s.name,
|
||||||
slaDays: s.slaDays,
|
slaDays: s.slaDays,
|
||||||
|
departmentId: s.departmentId,
|
||||||
|
positionLevel: s.positionLevel,
|
||||||
approvers: s.approvers.map(a => ({ kind: a.kind as 1 | 2, assignmentValue: a.assignmentValue })),
|
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,
|
|
||||||
})),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Page =====
|
|
||||||
|
|
||||||
// Map URL type code → int (mirror PeWf_<Code> menu key)
|
|
||||||
const PE_TYPE_CODE_TO_INT: Record<string, number> = {
|
const PE_TYPE_CODE_TO_INT: Record<string, number> = {
|
||||||
DuyetNcc: 1,
|
DuyetNcc: 1,
|
||||||
DuyetNccPhuongAn: 2,
|
DuyetNccPhuongAn: 2,
|
||||||
@ -141,7 +104,7 @@ export function PeWorkflowsPage() {
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
currentType
|
currentType
|
||||||
? 'Tạo version mới → phiếu PE tương lai dùng. Phiếu đã tạo giữ version cũ (pinned lúc tạo).'
|
? 'Mỗi bước = 1 Phòng × Cấp duyệt. Order asc tuần tự. Hết bước = đã duyệt.'
|
||||||
: 'Chọn loại Duyệt NCC từ menu bên trái để xem + chỉnh quy trình.'
|
: 'Chọn loại Duyệt NCC từ menu bên trái để xem + chỉnh quy trình.'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -261,7 +224,16 @@ function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActi
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="font-medium text-slate-800">{s.name}</span>
|
<span className="font-medium text-slate-800">{s.name}</span>
|
||||||
<span className="text-[11px] text-slate-400">({s.phaseLabel})</span>
|
{s.departmentName && (
|
||||||
|
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||||
|
{s.departmentName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.positionLevel != null && (
|
||||||
|
<span className="rounded bg-violet-50 px-1.5 py-0.5 text-[10px] font-medium text-violet-700">
|
||||||
|
{PositionLevelShort[s.positionLevel]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{s.slaDays != null && (
|
{s.slaDays != null && (
|
||||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
||||||
SLA {s.slaDays}d
|
SLA {s.slaDays}d
|
||||||
@ -269,8 +241,8 @@ function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{s.approvers.length === 0 && (
|
{s.approvers.length === 0 && s.departmentName == null && (
|
||||||
<span className="text-[11px] italic text-slate-400">Chưa có người duyệt</span>
|
<span className="text-[11px] italic text-slate-400">Chưa cấu hình người duyệt</span>
|
||||||
)}
|
)}
|
||||||
{s.approvers.map((a, i) => (
|
{s.approvers.map((a, i) => (
|
||||||
<span
|
<span
|
||||||
@ -316,7 +288,7 @@ function PeWorkflowDesigner({
|
|||||||
() =>
|
() =>
|
||||||
cloneFrom
|
cloneFrom
|
||||||
? copyFromDefinition(cloneFrom)
|
? copyFromDefinition(cloneFrom)
|
||||||
: [{ phase: 1, name: 'Soạn thảo', slaDays: 3, approvers: [], innerSteps: [] }],
|
: [{ name: 'Phòng 1 — Cấp 1', slaDays: 3, departmentId: null, positionLevel: PositionLevel.NhanVien, approvers: [] }],
|
||||||
[cloneFrom],
|
[cloneFrom],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -333,7 +305,7 @@ function PeWorkflowDesigner({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const departmentsList = useQuery({
|
const departmentsList = useQuery({
|
||||||
queryKey: ['departments-for-inner-step'],
|
queryKey: ['departments-list'],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
|
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||||
})
|
})
|
||||||
@ -347,18 +319,12 @@ function PeWorkflowDesigner({
|
|||||||
description: description || null,
|
description: description || null,
|
||||||
steps: steps.map((s, i) => ({
|
steps: steps.map((s, i) => ({
|
||||||
order: i + 1,
|
order: i + 1,
|
||||||
phase: s.phase,
|
phase: PHASE_CHO_DUYET, // Mig 21 — always ChoDuyet=10 for new definitions
|
||||||
name: s.name,
|
name: s.name,
|
||||||
slaDays: s.slaDays,
|
slaDays: s.slaDays,
|
||||||
|
departmentId: s.departmentId,
|
||||||
|
positionLevel: s.positionLevel,
|
||||||
approvers: s.approvers,
|
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,
|
|
||||||
})),
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -398,7 +364,7 @@ function PeWorkflowDesigner({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Mã quy trình *</Label>
|
<Label>Mã quy trình *</Label>
|
||||||
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
|
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
|
||||||
<div className="text-[11px] text-slate-400">Ví dụ QT-DN-A, QT-DN-B. Version auto-tăng mỗi lần lưu.</div>
|
<div className="text-[11px] text-slate-400">Vd QT-DN-A. Version auto-tăng mỗi lần lưu.</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Tên hiển thị *</Label>
|
<Label>Tên hiển thị *</Label>
|
||||||
@ -412,12 +378,18 @@ function PeWorkflowDesigner({
|
|||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Các bước ({steps.length})</Label>
|
<Label>Các bước duyệt — mỗi bước = 1 Phòng × Cấp ({steps.length})</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setSteps([...steps, { phase: 2, name: '', slaDays: 3, approvers: [], innerSteps: [] }])}
|
onClick={() => setSteps([...steps, {
|
||||||
|
name: `Phòng ${steps.length + 1} — Cấp 1`,
|
||||||
|
slaDays: 3,
|
||||||
|
departmentId: departmentsList.data?.[0]?.id ?? null,
|
||||||
|
positionLevel: PositionLevel.NhanVien,
|
||||||
|
approvers: [],
|
||||||
|
}])}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Thêm bước
|
Thêm bước
|
||||||
@ -430,32 +402,34 @@ function PeWorkflowDesigner({
|
|||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 grid-cols-3 gap-2">
|
<div className="grid flex-1 grid-cols-4 gap-2">
|
||||||
<div>
|
<div className="col-span-2">
|
||||||
<Label className="text-[11px]">Phase</Label>
|
|
||||||
<Select
|
|
||||||
value={s.phase}
|
|
||||||
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, phase: Number(e.target.value) } : x)))}
|
|
||||||
>
|
|
||||||
{PHASE_OPTIONS.map(p => (
|
|
||||||
<option key={p.value} value={p.value}>{p.label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-[11px]">Tên bước</Label>
|
<Label className="text-[11px]">Tên bước</Label>
|
||||||
<Input value={s.name} onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, name: e.target.value } : x)))} />
|
<Input value={s.name} onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, name: e.target.value } : x)))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[11px]">SLA (ngày)</Label>
|
<Label className="text-[11px]">Phòng</Label>
|
||||||
<Input
|
<Select
|
||||||
type="number"
|
value={s.departmentId ?? ''}
|
||||||
min={0}
|
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, departmentId: e.target.value || null } : x)))}
|
||||||
value={s.slaDays ?? ''}
|
>
|
||||||
onChange={e =>
|
<option value="">— Không —</option>
|
||||||
setSteps(steps.map((x, i) => (i === idx ? { ...x, slaDays: e.target.value ? Number(e.target.value) : null } : x)))
|
{departmentsList.data?.map(d => (
|
||||||
}
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
/>
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Cấp</Label>
|
||||||
|
<Select
|
||||||
|
value={s.positionLevel ?? ''}
|
||||||
|
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, positionLevel: e.target.value ? Number(e.target.value) : null } : x)))}
|
||||||
|
>
|
||||||
|
<option value="">— Không —</option>
|
||||||
|
<option value={PositionLevel.NhanVien}>{PositionLevelLabel[1]}</option>
|
||||||
|
<option value={PositionLevel.PhoPhong}>{PositionLevelLabel[2]}</option>
|
||||||
|
<option value={PositionLevel.TruongPhong}>{PositionLevelLabel[3]}</option>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -468,32 +442,41 @@ function PeWorkflowDesigner({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 border-t border-slate-200 pt-2">
|
<div className="mt-2 grid grid-cols-2 gap-2 border-t border-slate-200 pt-2">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div>
|
||||||
<Label className="text-[11px]">Người duyệt</Label>
|
<Label className="text-[11px]">SLA (ngày, optional)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={s.slaDays ?? ''}
|
||||||
|
onChange={e =>
|
||||||
|
setSteps(steps.map((x, i) => (i === idx ? { ...x, slaDays: e.target.value ? Number(e.target.value) : null } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Người duyệt explicit (optional — fallback nếu không Phòng+Cấp)</Label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 1, assignmentValue: AVAILABLE_ROLES[1] }] } : x)))}
|
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 1, assignmentValue: AVAILABLE_ROLES[1] }] } : x)))}
|
||||||
className="rounded bg-brand-50 px-2 py-0.5 text-[11px] font-medium text-brand-700 hover:bg-brand-100"
|
className="rounded bg-brand-50 px-2 py-1 text-[11px] font-medium text-brand-700 hover:bg-brand-100"
|
||||||
>
|
>
|
||||||
+ Role
|
+ Role
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 2, assignmentValue: usersList.data?.[0]?.id ?? '' }] } : x)))}
|
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 2, assignmentValue: usersList.data?.[0]?.id ?? '' }] } : x)))}
|
||||||
className="rounded bg-violet-50 px-2 py-0.5 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
||||||
>
|
>
|
||||||
+ User
|
+ User
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{s.approvers.length === 0 && (
|
</div>
|
||||||
<div className="rounded bg-slate-100 px-2 py-1.5 text-[11px] italic text-slate-500">
|
|
||||||
Chưa có người duyệt — tối thiểu nên có 1 Role hoặc 1 User.
|
{s.approvers.length > 0 && (
|
||||||
</div>
|
<div className="mt-1 space-y-1">
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{s.approvers.map((a, ai) => (
|
{s.approvers.map((a, ai) => (
|
||||||
<div key={ai} className="flex items-center gap-1">
|
<div key={ai} className="flex items-center gap-1">
|
||||||
{a.kind === 1 ? (
|
{a.kind === 1 ? (
|
||||||
@ -539,105 +522,7 @@ function PeWorkflowDesigner({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -645,8 +530,8 @@ function PeWorkflowDesigner({
|
|||||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
Khi lưu: version mới tự động tăng từ <code className="font-mono">{code}</code>, thành version đang áp dụng.
|
Mig 21 flat workflow: mỗi bước = 1 Phòng × Cấp. User cùng Phòng + Cấp ≥ step's level → được duyệt (OR-of-many).
|
||||||
Phiếu hiện tại vẫn giữ version cũ (được pin tại thời điểm tạo), chỉ phiếu MỚI đi theo version này.
|
User có CanBypassReview cấp cao hơn cùng dept → skip cấp dưới. Hết bước = phiếu DaDuyet.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// Contract Workflow admin — Session 16 drastic refactor (Mig 21):
|
||||||
|
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Bỏ Phase Select.
|
||||||
import { useMemo, useState, type FormEvent } from 'react'
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@ -12,32 +14,23 @@ import { Select } from '@/components/ui/Select'
|
|||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { AVAILABLE_ROLES, RoleLabel, PositionLevel, PositionLevelLabel } from '@/types/users'
|
import { AVAILABLE_ROLES, RoleLabel, PositionLevel, PositionLevelLabel, PositionLevelShort } from '@/types/users'
|
||||||
import type { Department, Paged } from '@/types/master'
|
import type { Department, Paged } from '@/types/master'
|
||||||
|
|
||||||
// ===== Types =====
|
// ===== Types post-Mig 21 =====
|
||||||
|
|
||||||
type ApproverDto = { kind: number; assignmentValue: string; displayName: string | null }
|
type ApproverDto = { kind: number; assignmentValue: string; displayName: string | null }
|
||||||
// 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 = {
|
type StepDto = {
|
||||||
id: string
|
id: string
|
||||||
order: number
|
order: number
|
||||||
phase: number
|
phase: number // [DEPRECATED] always ChoDuyet=10 for new
|
||||||
phaseLabel: string
|
phaseLabel: string
|
||||||
name: string
|
name: string
|
||||||
slaDays: number | null
|
slaDays: number | null
|
||||||
|
departmentId: string | null
|
||||||
|
departmentName: string | null
|
||||||
|
positionLevel: number | null
|
||||||
approvers: ApproverDto[]
|
approvers: ApproverDto[]
|
||||||
innerSteps: InnerStepDto[]
|
|
||||||
}
|
}
|
||||||
type DefinitionDto = {
|
type DefinitionDto = {
|
||||||
id: string
|
id: string
|
||||||
@ -60,55 +53,27 @@ type TypeSummaryDto = {
|
|||||||
history: DefinitionDto[]
|
history: DefinitionDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const PHASE_OPTIONS: { value: number; label: string }[] = [
|
const PHASE_CHO_DUYET = 10
|
||||||
{ value: 2, label: 'Đang soạn thảo' },
|
|
||||||
{ value: 3, label: 'Đang góp ý' },
|
|
||||||
{ value: 4, label: 'Đang đàm phán' },
|
|
||||||
{ value: 5, label: 'Đang in ký' },
|
|
||||||
{ value: 6, label: 'CCM kiểm tra' },
|
|
||||||
{ value: 7, label: 'Đang trình ký' },
|
|
||||||
{ value: 8, label: 'Đang đóng dấu' },
|
|
||||||
{ value: 9, label: 'Đã phát hành' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type EditStepApprover = { kind: 1 | 2; assignmentValue: string }
|
type EditStepApprover = { kind: 1 | 2; assignmentValue: string }
|
||||||
// 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 = {
|
type EditStep = {
|
||||||
phase: number
|
|
||||||
name: string
|
name: string
|
||||||
slaDays: number | null
|
slaDays: number | null
|
||||||
|
departmentId: string | null
|
||||||
|
positionLevel: number | null
|
||||||
approvers: EditStepApprover[]
|
approvers: EditStepApprover[]
|
||||||
innerSteps: EditInnerStep[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||||
return d.steps.map(s => ({
|
return d.steps.map(s => ({
|
||||||
phase: s.phase,
|
|
||||||
name: s.name,
|
name: s.name,
|
||||||
slaDays: s.slaDays,
|
slaDays: s.slaDays,
|
||||||
|
departmentId: s.departmentId,
|
||||||
|
positionLevel: s.positionLevel,
|
||||||
approvers: s.approvers.map(a => ({ kind: a.kind as 1 | 2, assignmentValue: a.assignmentValue })),
|
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,
|
|
||||||
})),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Page =====
|
|
||||||
|
|
||||||
// Map URL type code → int. Mirror Wf_<Code> menu key.
|
|
||||||
const TYPE_CODE_TO_INT: Record<string, number> = {
|
const TYPE_CODE_TO_INT: Record<string, number> = {
|
||||||
ThauPhu: 1,
|
ThauPhu: 1,
|
||||||
GiaoKhoan: 2,
|
GiaoKhoan: 2,
|
||||||
@ -127,8 +92,6 @@ export function WorkflowsPage() {
|
|||||||
queryFn: async () => (await api.get<{ types: TypeSummaryDto[] }>('/workflows')).data,
|
queryFn: async () => (await api.get<{ types: TypeSummaryDto[] }>('/workflows')).data,
|
||||||
})
|
})
|
||||||
|
|
||||||
// URL drives which type to show. `/system/workflows` (no param) → show
|
|
||||||
// landing hint to pick from sidebar; `/system/workflows/<code>` → open that.
|
|
||||||
const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null
|
const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null
|
||||||
const currentType = selectedTypeInt
|
const currentType = selectedTypeInt
|
||||||
? overview.data?.types.find(t => t.contractType === selectedTypeInt)
|
? overview.data?.types.find(t => t.contractType === selectedTypeInt)
|
||||||
@ -140,19 +103,18 @@ export function WorkflowsPage() {
|
|||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<GitBranch className="h-5 w-5" />
|
<GitBranch className="h-5 w-5" />
|
||||||
{currentType ? `Quy trình: ${currentType.contractTypeLabel}` : 'Quy trình duyệt hợp đồng'}
|
{currentType ? `Quy trình: ${currentType.contractTypeLabel}` : 'Quy trình duyệt HĐ'}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
currentType
|
currentType
|
||||||
? 'Tạo version mới → HĐ tương lai dùng. HĐ đã tạo giữ version cũ (pinned lúc tạo).'
|
? 'Mỗi bước = 1 Phòng × Cấp duyệt. Order asc tuần tự. Hết bước = HĐ phát hành.'
|
||||||
: 'Chọn loại HĐ từ menu bên trái để xem + chỉnh quy trình duyệt.'
|
: 'Chọn loại HĐ từ menu bên trái để xem + chỉnh quy trình.'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||||
|
|
||||||
{/* Landing: no type picked yet */}
|
|
||||||
{overview.data && !currentType && (
|
{overview.data && !currentType && (
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{overview.data.types.map(t => (
|
{overview.data.types.map(t => (
|
||||||
@ -180,8 +142,6 @@ export function WorkflowsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Per-type panel =====
|
|
||||||
|
|
||||||
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
|
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
|
||||||
const [designerOpen, setDesignerOpen] = useState(false)
|
const [designerOpen, setDesignerOpen] = useState(false)
|
||||||
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
|
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
|
||||||
@ -192,7 +152,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
|
|||||||
<DefinitionCard def={type.active} isActive onClone={d => { setCloneFrom(d); setDesignerOpen(true) }} />
|
<DefinitionCard def={type.active} isActive onClone={d => { setCloneFrom(d); setDesignerOpen(true) }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">
|
<div className="rounded-xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">
|
||||||
Chưa có quy trình cho loại này. Tạo version đầu tiên bên dưới.
|
Chưa có quy trình cho loại này.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -206,16 +166,14 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
|
|||||||
|
|
||||||
{type.history.filter(d => !d.isActive).length === 0 && (
|
{type.history.filter(d => !d.isActive).length === 0 && (
|
||||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-4 text-xs text-slate-500">
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-4 text-xs text-slate-500">
|
||||||
Chưa có version cũ. Khi tạo version mới, version hiện tại tự động archive.
|
Chưa có version cũ.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{type.history
|
{type.history.filter(d => !d.isActive).map(d => (
|
||||||
.filter(d => !d.isActive)
|
<DefinitionCard key={d.id} def={d} isActive={false} onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }} />
|
||||||
.map(d => (
|
))}
|
||||||
<DefinitionCard key={d.id} def={d} isActive={false} onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{designerOpen && (
|
{designerOpen && (
|
||||||
@ -231,17 +189,13 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Definition card (read-only view) =====
|
|
||||||
|
|
||||||
function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActive: boolean; onClone: (d: DefinitionDto) => void }) {
|
function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActive: boolean; onClone: (d: DefinitionDto) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl border bg-white p-5 shadow-sm ${isActive ? 'border-brand-200' : 'border-slate-200'}`}>
|
<div className={`rounded-xl border bg-white p-5 shadow-sm ${isActive ? 'border-brand-200' : 'border-slate-200'}`}>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-[15px] font-semibold text-slate-900">
|
<h3 className="text-[15px] font-semibold text-slate-900">{def.name}</h3>
|
||||||
{def.name}
|
|
||||||
</h3>
|
|
||||||
<span className="rounded bg-slate-100 px-2 py-0.5 font-mono text-[11px] text-slate-600">
|
<span className="rounded bg-slate-100 px-2 py-0.5 font-mono text-[11px] text-slate-600">
|
||||||
{def.code} v{String(def.version).padStart(2, '0')}
|
{def.code} v{String(def.version).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
@ -268,7 +222,16 @@ function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActi
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="font-medium text-slate-800">{s.name}</span>
|
<span className="font-medium text-slate-800">{s.name}</span>
|
||||||
<span className="text-[11px] text-slate-400">({s.phaseLabel})</span>
|
{s.departmentName && (
|
||||||
|
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||||
|
{s.departmentName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.positionLevel != null && (
|
||||||
|
<span className="rounded bg-violet-50 px-1.5 py-0.5 text-[10px] font-medium text-violet-700">
|
||||||
|
{PositionLevelShort[s.positionLevel]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{s.slaDays != null && (
|
{s.slaDays != null && (
|
||||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
||||||
SLA {s.slaDays}d
|
SLA {s.slaDays}d
|
||||||
@ -276,9 +239,6 @@ function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{s.approvers.length === 0 && (
|
|
||||||
<span className="text-[11px] italic text-slate-400">Chưa có người duyệt</span>
|
|
||||||
)}
|
|
||||||
{s.approvers.map((a, i) => (
|
{s.approvers.map((a, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
@ -304,8 +264,6 @@ function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Designer dialog =====
|
|
||||||
|
|
||||||
function WorkflowDesigner({
|
function WorkflowDesigner({
|
||||||
contractType,
|
contractType,
|
||||||
contractTypeLabel,
|
contractTypeLabel,
|
||||||
@ -323,7 +281,7 @@ function WorkflowDesigner({
|
|||||||
() =>
|
() =>
|
||||||
cloneFrom
|
cloneFrom
|
||||||
? copyFromDefinition(cloneFrom)
|
? copyFromDefinition(cloneFrom)
|
||||||
: [{ phase: 2, name: 'Soạn thảo', slaDays: 7, approvers: [], innerSteps: [] }],
|
: [{ name: 'Phòng 1 — Cấp 1', slaDays: 7, departmentId: null, positionLevel: PositionLevel.NhanVien, approvers: [] }],
|
||||||
[cloneFrom],
|
[cloneFrom],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -339,7 +297,7 @@ function WorkflowDesigner({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const departmentsList = useQuery({
|
const departmentsList = useQuery({
|
||||||
queryKey: ['departments-for-inner-step'],
|
queryKey: ['departments-list'],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
|
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||||
})
|
})
|
||||||
@ -353,18 +311,12 @@ function WorkflowDesigner({
|
|||||||
description: description || null,
|
description: description || null,
|
||||||
steps: steps.map((s, i) => ({
|
steps: steps.map((s, i) => ({
|
||||||
order: i + 1,
|
order: i + 1,
|
||||||
phase: s.phase,
|
phase: PHASE_CHO_DUYET,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
slaDays: s.slaDays,
|
slaDays: s.slaDays,
|
||||||
|
departmentId: s.departmentId,
|
||||||
|
positionLevel: s.positionLevel,
|
||||||
approvers: s.approvers,
|
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,
|
|
||||||
})),
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -404,7 +356,6 @@ function WorkflowDesigner({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Mã quy trình *</Label>
|
<Label>Mã quy trình *</Label>
|
||||||
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
|
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
|
||||||
<div className="text-[11px] text-slate-400">Ví dụ QT-TP, QT-MB. Version auto-tăng mỗi lần lưu.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Tên hiển thị *</Label>
|
<Label>Tên hiển thị *</Label>
|
||||||
@ -418,14 +369,18 @@ function WorkflowDesigner({
|
|||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Các bước ({steps.length})</Label>
|
<Label>Các bước duyệt — mỗi bước = 1 Phòng × Cấp ({steps.length})</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() => setSteps([...steps, {
|
||||||
setSteps([...steps, { phase: 3, name: '', slaDays: 7, approvers: [], innerSteps: [] }])
|
name: `Phòng ${steps.length + 1} — Cấp 1`,
|
||||||
}
|
slaDays: 7,
|
||||||
|
departmentId: departmentsList.data?.[0]?.id ?? null,
|
||||||
|
positionLevel: PositionLevel.NhanVien,
|
||||||
|
approvers: [],
|
||||||
|
}])}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Thêm bước
|
Thêm bước
|
||||||
@ -438,70 +393,80 @@ function WorkflowDesigner({
|
|||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 grid-cols-3 gap-2">
|
<div className="grid flex-1 grid-cols-4 gap-2">
|
||||||
<div>
|
<div className="col-span-2">
|
||||||
<Label className="text-[11px]">Phase</Label>
|
|
||||||
<Select
|
|
||||||
value={s.phase}
|
|
||||||
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, phase: Number(e.target.value) } : x)))}
|
|
||||||
>
|
|
||||||
{PHASE_OPTIONS.map(p => (
|
|
||||||
<option key={p.value} value={p.value}>{p.label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-[11px]">Tên bước</Label>
|
<Label className="text-[11px]">Tên bước</Label>
|
||||||
<Input value={s.name} onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, name: e.target.value } : x)))} />
|
<Input value={s.name} onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, name: e.target.value } : x)))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[11px]">SLA (ngày)</Label>
|
<Label className="text-[11px]">Phòng</Label>
|
||||||
<Input
|
<Select
|
||||||
type="number"
|
value={s.departmentId ?? ''}
|
||||||
min={0}
|
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, departmentId: e.target.value || null } : x)))}
|
||||||
value={s.slaDays ?? ''}
|
>
|
||||||
onChange={e =>
|
<option value="">— Không —</option>
|
||||||
setSteps(steps.map((x, i) => (i === idx ? { ...x, slaDays: e.target.value ? Number(e.target.value) : null } : x)))
|
{departmentsList.data?.map(d => (
|
||||||
}
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
/>
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Cấp</Label>
|
||||||
|
<Select
|
||||||
|
value={s.positionLevel ?? ''}
|
||||||
|
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, positionLevel: e.target.value ? Number(e.target.value) : null } : x)))}
|
||||||
|
>
|
||||||
|
<option value="">— Không —</option>
|
||||||
|
<option value={PositionLevel.NhanVien}>{PositionLevelLabel[1]}</option>
|
||||||
|
<option value={PositionLevel.PhoPhong}>{PositionLevelLabel[2]}</option>
|
||||||
|
<option value={PositionLevel.TruongPhong}>{PositionLevelLabel[3]}</option>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSteps(steps.filter((_, i) => i !== idx))}
|
onClick={() => setSteps(steps.filter((_, i) => i !== idx))}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
|
className="flex h-7 w-7 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
|
||||||
title="Xóa bước"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 border-t border-slate-200 pt-2">
|
<div className="mt-2 grid grid-cols-2 gap-2 border-t border-slate-200 pt-2">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div>
|
||||||
<Label className="text-[11px]">Người duyệt</Label>
|
<Label className="text-[11px]">SLA (ngày, optional)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={s.slaDays ?? ''}
|
||||||
|
onChange={e =>
|
||||||
|
setSteps(steps.map((x, i) => (i === idx ? { ...x, slaDays: e.target.value ? Number(e.target.value) : null } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Người duyệt explicit (optional fallback)</Label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 1, assignmentValue: AVAILABLE_ROLES[1] }] } : x)))}
|
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 1, assignmentValue: AVAILABLE_ROLES[1] }] } : x)))}
|
||||||
className="rounded bg-brand-50 px-2 py-0.5 text-[11px] font-medium text-brand-700 hover:bg-brand-100"
|
className="rounded bg-brand-50 px-2 py-1 text-[11px] font-medium text-brand-700 hover:bg-brand-100"
|
||||||
>
|
>
|
||||||
+ Role
|
+ Role
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 2, assignmentValue: usersList.data?.[0]?.id ?? '' }] } : x)))}
|
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 2, assignmentValue: usersList.data?.[0]?.id ?? '' }] } : x)))}
|
||||||
className="rounded bg-violet-50 px-2 py-0.5 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
||||||
>
|
>
|
||||||
+ User
|
+ User
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{s.approvers.length === 0 && (
|
</div>
|
||||||
<div className="rounded bg-slate-100 px-2 py-1.5 text-[11px] italic text-slate-500">
|
|
||||||
Chưa có người duyệt — tối thiểu nên có 1 Role hoặc 1 User.
|
{s.approvers.length > 0 && (
|
||||||
</div>
|
<div className="mt-1 space-y-1">
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{s.approvers.map((a, ai) => (
|
{s.approvers.map((a, ai) => (
|
||||||
<div key={ai} className="flex items-center gap-1">
|
<div key={ai} className="flex items-center gap-1">
|
||||||
{a.kind === 1 ? (
|
{a.kind === 1 ? (
|
||||||
@ -547,105 +512,7 @@ function WorkflowDesigner({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -653,8 +520,8 @@ function WorkflowDesigner({
|
|||||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
Khi lưu: version mới tự động tăng từ <code className="font-mono">{code}</code>, thành version đang áp dụng.
|
Mig 21 flat workflow: mỗi bước = 1 Phòng × Cấp. User cùng Phòng + Cấp ≥ step's level → được duyệt (OR-of-many).
|
||||||
HĐ hiện tại vẫn giữ version cũ (được pin tại thời điểm tạo), chỉ HĐ MỚI đi theo version này.
|
Hết bước = HĐ DaPhatHanh + auto-gen mã HĐ.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -16,16 +16,19 @@ export const PurchaseEvaluationTypeCode: Record<number, string> = {
|
|||||||
2: 'DuyetNccPhuongAn',
|
2: 'DuyetNccPhuongAn',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mig 21 drastic refactor: enum simplified. ChoDuyet=10 generic intermediate.
|
||||||
|
// Legacy 2-9 + 98 deprecated, giữ values cho data cũ đọc OK.
|
||||||
export const PurchaseEvaluationPhase = {
|
export const PurchaseEvaluationPhase = {
|
||||||
DangSoanThao: 1,
|
DangSoanThao: 1,
|
||||||
ChoPurchasing: 2,
|
ChoPurchasing: 2, // [LEGACY]
|
||||||
ChoDuAn: 3,
|
ChoDuAn: 3, // [LEGACY]
|
||||||
ChoCCM: 4,
|
ChoCCM: 4, // [LEGACY]
|
||||||
ChoCEODuyetPA: 5,
|
ChoCEODuyetPA: 5, // [LEGACY]
|
||||||
ChoCEODuyetNCC: 6,
|
ChoCEODuyetNCC: 6, // [LEGACY]
|
||||||
DaDuyet: 7,
|
DaDuyet: 7,
|
||||||
TraLai: 98, // approver trả về Drafter sửa — vẫn cho edit
|
ChoDuyet: 10, // [Mig 21] generic intermediate
|
||||||
TuChoi: 99, // terminal từ chối — KHÔNG edit
|
TraLai: 98, // [LEGACY]
|
||||||
|
TuChoi: 99,
|
||||||
} as const
|
} as const
|
||||||
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
|
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
|
||||||
|
|
||||||
@ -37,6 +40,7 @@ export const PurchaseEvaluationPhaseLabel: Record<number, string> = {
|
|||||||
5: 'Chờ CEO duyệt PA',
|
5: 'Chờ CEO duyệt PA',
|
||||||
6: 'Chờ CEO duyệt NCC',
|
6: 'Chờ CEO duyệt NCC',
|
||||||
7: 'Đã duyệt',
|
7: 'Đã duyệt',
|
||||||
|
10: 'Đang duyệt',
|
||||||
98: 'Trả lại',
|
98: 'Trả lại',
|
||||||
99: 'Từ chối',
|
99: 'Từ chối',
|
||||||
}
|
}
|
||||||
@ -49,6 +53,7 @@ export const PurchaseEvaluationPhaseColor: Record<number, string> = {
|
|||||||
5: 'bg-fuchsia-100 text-fuchsia-700',
|
5: 'bg-fuchsia-100 text-fuchsia-700',
|
||||||
6: 'bg-pink-100 text-pink-700',
|
6: 'bg-pink-100 text-pink-700',
|
||||||
7: 'bg-emerald-100 text-emerald-700',
|
7: 'bg-emerald-100 text-emerald-700',
|
||||||
|
10: 'bg-amber-100 text-amber-700',
|
||||||
98: 'bg-yellow-100 text-yellow-800',
|
98: 'bg-yellow-100 text-yellow-800',
|
||||||
99: 'bg-red-100 text-red-700',
|
99: 'bg-red-100 text-red-700',
|
||||||
}
|
}
|
||||||
@ -97,6 +102,7 @@ export function getPeDisplayStatus(phase: number): PeDisplayStatus {
|
|||||||
if (phase === PurchaseEvaluationPhase.DaDuyet) return PeDisplayStatus.DaDuyet
|
if (phase === PurchaseEvaluationPhase.DaDuyet) return PeDisplayStatus.DaDuyet
|
||||||
if (phase === PurchaseEvaluationPhase.TraLai) return PeDisplayStatus.TraLai
|
if (phase === PurchaseEvaluationPhase.TraLai) return PeDisplayStatus.TraLai
|
||||||
if (phase === PurchaseEvaluationPhase.TuChoi) return PeDisplayStatus.TuChoi
|
if (phase === PurchaseEvaluationPhase.TuChoi) return PeDisplayStatus.TuChoi
|
||||||
|
// Mig 21 ChoDuyet=10 + legacy intermediate 2-6 → all map "Đã gửi duyệt"
|
||||||
return PeDisplayStatus.DaGuiDuyet
|
return PeDisplayStatus.DaGuiDuyet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,16 +16,19 @@ export const PurchaseEvaluationTypeCode: Record<number, string> = {
|
|||||||
2: 'DuyetNccPhuongAn',
|
2: 'DuyetNccPhuongAn',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mig 21 drastic refactor: enum simplified. ChoDuyet=10 generic intermediate.
|
||||||
|
// Legacy 2-9 + 98 deprecated, giữ values cho data cũ đọc OK.
|
||||||
export const PurchaseEvaluationPhase = {
|
export const PurchaseEvaluationPhase = {
|
||||||
DangSoanThao: 1,
|
DangSoanThao: 1,
|
||||||
ChoPurchasing: 2,
|
ChoPurchasing: 2, // [LEGACY]
|
||||||
ChoDuAn: 3,
|
ChoDuAn: 3, // [LEGACY]
|
||||||
ChoCCM: 4,
|
ChoCCM: 4, // [LEGACY]
|
||||||
ChoCEODuyetPA: 5,
|
ChoCEODuyetPA: 5, // [LEGACY]
|
||||||
ChoCEODuyetNCC: 6,
|
ChoCEODuyetNCC: 6, // [LEGACY]
|
||||||
DaDuyet: 7,
|
DaDuyet: 7,
|
||||||
TraLai: 98, // approver trả về Drafter sửa — vẫn cho edit
|
ChoDuyet: 10, // [Mig 21] generic intermediate
|
||||||
TuChoi: 99, // terminal từ chối — KHÔNG edit
|
TraLai: 98, // [LEGACY]
|
||||||
|
TuChoi: 99,
|
||||||
} as const
|
} as const
|
||||||
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
|
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
|
||||||
|
|
||||||
@ -37,6 +40,7 @@ export const PurchaseEvaluationPhaseLabel: Record<number, string> = {
|
|||||||
5: 'Chờ CEO duyệt PA',
|
5: 'Chờ CEO duyệt PA',
|
||||||
6: 'Chờ CEO duyệt NCC',
|
6: 'Chờ CEO duyệt NCC',
|
||||||
7: 'Đã duyệt',
|
7: 'Đã duyệt',
|
||||||
|
10: 'Đang duyệt',
|
||||||
98: 'Trả lại',
|
98: 'Trả lại',
|
||||||
99: 'Từ chối',
|
99: 'Từ chối',
|
||||||
}
|
}
|
||||||
@ -49,6 +53,7 @@ export const PurchaseEvaluationPhaseColor: Record<number, string> = {
|
|||||||
5: 'bg-fuchsia-100 text-fuchsia-700',
|
5: 'bg-fuchsia-100 text-fuchsia-700',
|
||||||
6: 'bg-pink-100 text-pink-700',
|
6: 'bg-pink-100 text-pink-700',
|
||||||
7: 'bg-emerald-100 text-emerald-700',
|
7: 'bg-emerald-100 text-emerald-700',
|
||||||
|
10: 'bg-amber-100 text-amber-700',
|
||||||
98: 'bg-yellow-100 text-yellow-800',
|
98: 'bg-yellow-100 text-yellow-800',
|
||||||
99: 'bg-red-100 text-red-700',
|
99: 'bg-red-100 text-red-700',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user