[CLAUDE] FE-Admin: Designer Quy trình duyệt mới V2 (Chunk C)
Page mới `/system/approval-workflows-v2/:typeCode` mirror Designer cũ
nhưng theo schema Mig 22:
Bước (Phòng) > N Cấp (mỗi cấp = 1 NV cụ thể qua Select duy nhất)
Files:
- fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx (new — 480 LOC)
- Overview cards (Active version + History list per ApplicableType)
- DefinitionCard read-only render Bước → Cấp với approver name + email
- Designer dialog: Mã/Tên/Mô tả + reorder Step/Level (chevron up/down)
+ Add/Remove Step + Add/Remove Level + Select Phòng + Select NV duyệt
- Validate: mỗi Step phải có ≥1 Level, mỗi Level phải có approverUserId
- Auto-assign code QT-DN-V2-001 / QT-DN-PA-V2-001 / QT-HD-V2-001
- fe-admin/src/lib/menuKeys.ts (+2 const sync với BE MenuKeys)
- fe-admin/src/components/Layout.tsx (resolver: ApprovalWorkflowsV2 root +
AwV2_<TypeCode> leaf → /system/approval-workflows-v2/<code>)
- fe-admin/src/App.tsx (import + 2 route)
Verify: npm build fe-admin OK, 1924 modules transformed, 0 TS error.
Next: Chunk D — STATUS + HANDOFF + CLAUDE.md update + final commit.
This commit is contained in:
@ -13,6 +13,7 @@ import { PermissionsPage } from '@/pages/system/PermissionsPage'
|
||||
import { RolesPage } from '@/pages/system/RolesPage'
|
||||
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
|
||||
import { PeWorkflowsPage } from '@/pages/system/PeWorkflowsPage'
|
||||
import { ApprovalWorkflowsV2Page } from '@/pages/system/ApprovalWorkflowsV2Page'
|
||||
import { FormsPage } from '@/pages/forms/FormsPage'
|
||||
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
|
||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||
@ -51,6 +52,9 @@ function App() {
|
||||
<Route path="/system/workflows/:typeCode" element={<WorkflowsPage />} />
|
||||
<Route path="/system/pe-workflows" element={<PeWorkflowsPage />} />
|
||||
<Route path="/system/pe-workflows/:typeCode" element={<PeWorkflowsPage />} />
|
||||
{/* Quy trình duyệt MỚI (Mig 22 — UAT) */}
|
||||
<Route path="/system/approval-workflows-v2" element={<ApprovalWorkflowsV2Page />} />
|
||||
<Route path="/system/approval-workflows-v2/:typeCode" element={<ApprovalWorkflowsV2Page />} />
|
||||
<Route path="/forms" element={<FormsPage />} />
|
||||
<Route path="/contracts" element={<ContractsListPage />} />
|
||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||
|
||||
@ -91,6 +91,17 @@ function resolvePath(key: string): string | null {
|
||||
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}`
|
||||
}
|
||||
|
||||
// Quy trình duyệt MỚI (Mig 22 — Session 17): root = group bowed, leaf =
|
||||
// type-specific designer. Sau UAT thay thế PeWorkflows + Workflows cũ.
|
||||
if (key === 'ApprovalWorkflowsV2') return '/system/approval-workflows-v2'
|
||||
const awV2Match = key.match(/^AwV2_(.+)$/)
|
||||
if (awV2Match) {
|
||||
const code = awV2Match[1]
|
||||
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') {
|
||||
return `/system/approval-workflows-v2/${code}`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,9 @@ export const MenuKeys = {
|
||||
Permissions: 'Permissions',
|
||||
PurchaseEvaluations: 'PurchaseEvaluations',
|
||||
PeWorkflows: 'PeWorkflows',
|
||||
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08)
|
||||
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
|
||||
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
|
||||
} as const
|
||||
|
||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||
|
||||
638
fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx
Normal file
638
fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx
Normal file
@ -0,0 +1,638 @@
|
||||
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
|
||||
// Schema riêng UAT trước khi drop legacy. Cấu trúc:
|
||||
// Quy trình (Mã + Tên + ApplicableType)
|
||||
// Bước 1 — Phòng A
|
||||
// Cấp 1 — NV X (1 user CỤ THỂ qua ApproverUserId)
|
||||
// Cấp 2 — NV Y
|
||||
//
|
||||
// Khác Designer cũ (PE workflow): Levels match 1 NV chính xác (KHÔNG OR-of-many).
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import type { Department, Paged } from '@/types/master'
|
||||
|
||||
// ===== Types (mirror BE AwAdminOverviewDto) =====
|
||||
|
||||
type LevelDto = {
|
||||
id: string
|
||||
order: number
|
||||
name: string | null
|
||||
approverUserId: string
|
||||
approverUserName: string | null
|
||||
approverEmail: string | null
|
||||
}
|
||||
type StepDto = {
|
||||
id: string
|
||||
order: number
|
||||
name: string // "Phòng A" — display
|
||||
departmentId: string | null
|
||||
departmentName: string | null
|
||||
levels: LevelDto[]
|
||||
}
|
||||
type DefinitionDto = {
|
||||
id: string
|
||||
code: string
|
||||
version: number
|
||||
applicableType: number
|
||||
applicableTypeLabel: string
|
||||
name: string
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
activatedAt: string | null
|
||||
createdAt: string
|
||||
steps: StepDto[]
|
||||
}
|
||||
type TypeSummaryDto = {
|
||||
applicableType: number
|
||||
applicableTypeLabel: string
|
||||
active: DefinitionDto | null
|
||||
history: DefinitionDto[]
|
||||
}
|
||||
|
||||
type EditLevel = { name: string; approverUserId: string }
|
||||
type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] }
|
||||
|
||||
// FE typeCode → BE int (giống MenuKeys ApplicableType)
|
||||
const TYPE_CODE_TO_INT: Record<string, number> = {
|
||||
DuyetNcc: 1,
|
||||
DuyetNccPhuongAn: 2,
|
||||
Contract: 3,
|
||||
}
|
||||
const DEFAULT_CODE_BY_TYPE: Record<number, string> = {
|
||||
1: 'QT-DN-V2-001',
|
||||
2: 'QT-DN-PA-V2-001',
|
||||
3: 'QT-HD-V2-001',
|
||||
}
|
||||
|
||||
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||
return d.steps.map(s => ({
|
||||
name: s.name,
|
||||
departmentId: s.departmentId,
|
||||
levels: s.levels.map(l => ({ name: l.name ?? '', approverUserId: l.approverUserId })),
|
||||
}))
|
||||
}
|
||||
|
||||
export function ApprovalWorkflowsV2Page() {
|
||||
const qc = useQueryClient()
|
||||
const { typeCode } = useParams<{ typeCode?: string }>()
|
||||
const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
|
||||
queryFn: async () => {
|
||||
const params = selectedTypeInt ? { applicableType: selectedTypeInt } : {}
|
||||
return (await api.get<{ types: TypeSummaryDto[] }>('/approval-workflows-v2', { params })).data
|
||||
},
|
||||
})
|
||||
|
||||
const currentType = selectedTypeInt
|
||||
? overview.data?.types.find(t => t.applicableType === selectedTypeInt)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Workflow className="h-5 w-5" />
|
||||
{currentType
|
||||
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
|
||||
: 'Quy trình duyệt (Mới)'}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
currentType
|
||||
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
|
||||
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
|
||||
}
|
||||
/>
|
||||
|
||||
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||
|
||||
{overview.data && !currentType && (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{overview.data.types.map(t => (
|
||||
<div key={t.applicableType} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">{t.applicableTypeLabel}</h3>
|
||||
{t.active && (
|
||||
<span className="rounded bg-brand-50 px-2 py-0.5 font-mono text-[10px] font-medium text-brand-700">
|
||||
{t.active.code} v{String(t.active.version).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
{t.active
|
||||
? `${t.active.steps.length} bước · ${t.active.steps.reduce((s, x) => s + x.levels.length, 0)} cấp · ${t.history.length} version`
|
||||
: 'Chưa có quy trình'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentType && (
|
||||
<TypePanel
|
||||
type={currentType}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Per-type panel =====
|
||||
|
||||
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
|
||||
const [designerOpen, setDesignerOpen] = useState(false)
|
||||
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
|
||||
const qc = useQueryClient()
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: string) => api.delete(`/approval-workflows-v2/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xoá version')
|
||||
qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{type.active ? (
|
||||
<DefinitionCard
|
||||
def={type.active}
|
||||
isActive
|
||||
onClone={d => { setCloneFrom(d); setDesignerOpen(true) }}
|
||||
onDelete={() => {
|
||||
if (confirm(`Xoá version đang áp dụng "${type.active!.code} v${type.active!.version}"?`)) {
|
||||
del.mutate(type.active!.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Lịch sử versions</h3>
|
||||
<Button onClick={() => { setCloneFrom(type.active); setDesignerOpen(true) }}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo quy trình mới
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
Chưa có version cũ. Khi tạo version mới, version hiện tại tự động archive.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{type.history.filter(d => !d.isActive).map(d => (
|
||||
<DefinitionCard
|
||||
key={d.id}
|
||||
def={d}
|
||||
isActive={false}
|
||||
onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }}
|
||||
onDelete={() => {
|
||||
if (confirm(`Xoá version "${d.code} v${d.version}"?`)) del.mutate(d.id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{designerOpen && (
|
||||
<Designer
|
||||
applicableType={type.applicableType}
|
||||
applicableTypeLabel={type.applicableTypeLabel}
|
||||
cloneFrom={cloneFrom}
|
||||
onClose={() => { setDesignerOpen(false); setCloneFrom(null) }}
|
||||
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Definition card (read-only) =====
|
||||
|
||||
function DefinitionCard({
|
||||
def,
|
||||
isActive,
|
||||
onClone,
|
||||
onDelete,
|
||||
}: {
|
||||
def: DefinitionDto
|
||||
isActive: boolean
|
||||
onClone: (d: DefinitionDto) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
return (
|
||||
<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="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold text-slate-900">{def.name}</h3>
|
||||
<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')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Đang áp dụng
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
<History className="h-3 w-3" />
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{def.description && <p className="mt-1 text-xs leading-relaxed text-slate-500">{def.description}</p>}
|
||||
|
||||
<ol className="mt-3 space-y-2">
|
||||
{def.steps.map(s => (
|
||||
<li key={s.id} className="rounded-lg border border-slate-100 bg-slate-50/30 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
|
||||
{s.order}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-800">Bước {s.order} — {s.name}</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>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-2 ml-9 space-y-1">
|
||||
{s.levels.length === 0 ? (
|
||||
<li className="text-[11px] italic text-slate-400">Chưa có cấp duyệt</li>
|
||||
) : (
|
||||
s.levels.map(l => (
|
||||
<li key={l.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
|
||||
C{l.order}
|
||||
</span>
|
||||
<span className="text-slate-700">{l.name || `Cấp ${l.order}`}</span>
|
||||
<span className="text-slate-400">—</span>
|
||||
<span className="font-medium text-slate-800">
|
||||
{l.approverUserName ?? l.approverUserId}
|
||||
</span>
|
||||
{l.approverEmail && (
|
||||
<span className="text-[10px] text-slate-400">({l.approverEmail})</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={() => onClone(def)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Tạo từ bản này
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Xoá version
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Designer dialog =====
|
||||
|
||||
function Designer({
|
||||
applicableType,
|
||||
applicableTypeLabel,
|
||||
cloneFrom,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
applicableType: number
|
||||
applicableTypeLabel: string
|
||||
cloneFrom: DefinitionDto | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const initialSteps: EditStep[] = useMemo(
|
||||
() =>
|
||||
cloneFrom
|
||||
? copyFromDefinition(cloneFrom)
|
||||
: [{ name: 'Phòng 1', departmentId: null, levels: [{ name: 'Cấp 1', approverUserId: '' }] }],
|
||||
[cloneFrom],
|
||||
)
|
||||
|
||||
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
|
||||
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
|
||||
const [name, setName] = useState(cloneFrom ? `${cloneFrom.name} (clone)` : `Quy trình ${applicableTypeLabel}`)
|
||||
const [description, setDescription] = useState(cloneFrom?.description ?? '')
|
||||
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
|
||||
|
||||
const usersList = useQuery({
|
||||
queryKey: ['users-for-approver-v2'],
|
||||
queryFn: async () =>
|
||||
(await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', {
|
||||
params: { page: 1, pageSize: 200 },
|
||||
})).data.items,
|
||||
})
|
||||
|
||||
const departmentsList = useQuery({
|
||||
queryKey: ['departments-list-v2'],
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Validate có user trong tất cả Cấp
|
||||
for (const s of steps) {
|
||||
if (s.levels.length === 0) throw new Error(`Bước "${s.name}" chưa có cấp duyệt nào.`)
|
||||
for (const l of s.levels) {
|
||||
if (!l.approverUserId) throw new Error(`Bước "${s.name}" có cấp chưa chọn nhân viên duyệt.`)
|
||||
}
|
||||
}
|
||||
await api.post('/approval-workflows-v2', {
|
||||
applicableType,
|
||||
code,
|
||||
name,
|
||||
description: description || null,
|
||||
steps: steps.map((s, i) => ({
|
||||
order: i + 1,
|
||||
name: s.name,
|
||||
departmentId: s.departmentId,
|
||||
levels: s.levels.map((l, j) => ({
|
||||
order: j + 1,
|
||||
name: l.name || null,
|
||||
approverUserId: l.approverUserId,
|
||||
})),
|
||||
})),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã lưu quy trình mới. Version cũ đã archive.')
|
||||
onSaved()
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (steps.length === 0) {
|
||||
toast.error('Phải có ít nhất 1 bước')
|
||||
return
|
||||
}
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
function moveStep(idx: number, dir: -1 | 1) {
|
||||
const newIdx = idx + dir
|
||||
if (newIdx < 0 || newIdx >= steps.length) return
|
||||
const next = [...steps]
|
||||
;[next[idx], next[newIdx]] = [next[newIdx], next[idx]]
|
||||
setSteps(next)
|
||||
}
|
||||
|
||||
function moveLevel(stepIdx: number, levelIdx: number, dir: -1 | 1) {
|
||||
const step = steps[stepIdx]
|
||||
const newIdx = levelIdx + dir
|
||||
if (newIdx < 0 || newIdx >= step.levels.length) return
|
||||
const next = [...step.levels]
|
||||
;[next[levelIdx], next[newIdx]] = [next[newIdx], next[levelIdx]]
|
||||
setSteps(steps.map((x, i) => (i === stepIdx ? { ...x, levels: next } : x)))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Tạo quy trình mới — ${applicableTypeLabel}`}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={submit} disabled={save.isPending} form="aw-v2-form">
|
||||
{save.isPending ? 'Đang lưu…' : 'Lưu + kích hoạt'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="aw-v2-form" onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Mã quy trình *</Label>
|
||||
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
|
||||
<div className="text-[11px] text-slate-400">Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tên hiển thị *</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Mô tả</Label>
|
||||
<Textarea rows={2} value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Các bước duyệt — mỗi bước = 1 Phòng × N cấp NV ({steps.length} bước)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSteps([...steps, {
|
||||
name: `Phòng ${steps.length + 1}`,
|
||||
departmentId: departmentsList.data?.[0]?.id ?? null,
|
||||
levels: [{ name: 'Cấp 1', approverUserId: usersList.data?.[0]?.id ?? '' }],
|
||||
}])}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Thêm bước
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{steps.map((s, idx) => (
|
||||
<div key={idx} className="rounded-md border border-slate-200 bg-slate-50/40 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<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}
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-3 gap-2">
|
||||
<div>
|
||||
<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)))}
|
||||
placeholder="Phòng A"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[11px]">Phòng (hint hiển thị)</Label>
|
||||
<Select
|
||||
value={s.departmentId ?? ''}
|
||||
onChange={e =>
|
||||
setSteps(steps.map((x, i) => (i === idx ? { ...x, departmentId: e.target.value || null } : x)))
|
||||
}
|
||||
>
|
||||
<option value="">— Không —</option>
|
||||
{departmentsList.data?.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
title="Lên"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(idx, 1)}
|
||||
disabled={idx === steps.length - 1}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
title="Xuống"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSteps(steps.filter((_, i) => i !== idx))}
|
||||
className="flex h-6 w-6 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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Levels */}
|
||||
<div className="mt-2 ml-9 space-y-1.5 border-l-2 border-violet-200 pl-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">
|
||||
Cấp duyệt ({s.levels.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx
|
||||
? { ...x, levels: [...x.levels, { name: `Cấp ${x.levels.length + 1}`, approverUserId: usersList.data?.[0]?.id ?? '' }] }
|
||||
: x,
|
||||
))
|
||||
}
|
||||
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
||||
>
|
||||
+ Thêm cấp
|
||||
</button>
|
||||
</div>
|
||||
{s.levels.map((l, li) => (
|
||||
<div key={li} className="flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-violet-100 px-2 py-1 font-mono text-[10px] font-bold text-violet-700">
|
||||
C{li + 1}
|
||||
</span>
|
||||
<Input
|
||||
value={l.name}
|
||||
onChange={e =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx ? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, name: e.target.value } : y)) } : x,
|
||||
))
|
||||
}
|
||||
placeholder={`Cấp ${li + 1}`}
|
||||
className="h-7 max-w-[120px] text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={l.approverUserId}
|
||||
onChange={e =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx
|
||||
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
|
||||
: x,
|
||||
))
|
||||
}
|
||||
className="h-7 flex-1 text-xs"
|
||||
>
|
||||
<option value="">— Chọn NV duyệt —</option>
|
||||
{usersList.data?.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.fullName} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveLevel(idx, li, -1)}
|
||||
disabled={li === 0}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
title="Lên"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveLevel(idx, li, 1)}
|
||||
disabled={li === s.levels.length - 1}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
title="Xuống"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSteps(steps.map((x, i) =>
|
||||
i === idx ? { ...x, levels: x.levels.filter((_, j) => j !== li) } : x,
|
||||
))
|
||||
}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
|
||||
title="Xóa cấp"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{s.levels.length === 0 && (
|
||||
<div className="rounded border border-dashed border-slate-300 px-2 py-1.5 text-[11px] italic text-slate-400">
|
||||
Chưa có cấp. Bấm "+ Thêm cấp" để chỉ định NV duyệt.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<div>
|
||||
Quy tắc duyệt: tuần tự trong cùng Bước (Cấp 1 → Cấp 2 → ...), hết Cấp thì sang Bước kế.
|
||||
Mỗi Cấp = 1 nhân viên cụ thể (KHÔNG OR-of-many). Hết tất cả Bước = Đã duyệt.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user