Files
solution-erp/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx
pqhuy1987 dd52d16ca9 [CLAUDE] FE-Admin: Chunk C — Mig 31 K3 Designer 7th checkbox AllowApproverSkipToFinal + banner rewrite
ApprovalWorkflowsV2Page Designer inline panel mỗi Level entry thêm checkbox thứ 7:
"Cho phép duyệt thẳng Cấp cuối khi đang duyệt" (F2 admin opt-in per-slot Approver).
Group cuối list sau F4 AllowApproverEditBudget (Mig 30) — pattern mirror Mig 29/30
admin opt-in reinforced 3× cumulative.

Types LevelDto + EditLevelEntry +allowApproverSkipToFinal: boolean field.
Helper makeDefaultLevelEntry default false (opt-out — admin tick explicit).
Helper copyFromDefinition propagate flag từ workflow cũ.
POST/PATCH mutation body propagate 7th flag mỗi Level entry.

Banner line ~623-631 rewrite: "F2 cấu hình ở User Management" (Plan D S22 wire) →
"Cấu hình quyền duyệt riêng cho từng NV trong slot Approver bên dưới" — phản ánh
schema Mig 31 (F2 storage moved per-slot).

Per bro decision S23 t1 Plan K: "Tất cả đều cấu hình ngay trong chỗ setup quy trình duyệt".

Verify:
- npm run build fe-admin pass clean
- 0 TS error
- Bundle size 1395.74 KB (unchanged trivial)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:19:48 +07:00

975 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (N NV duyệt)
// Cấp 2 (N NV duyệt)
// Cấp 3 (N NV duyệt)
//
// Iteration 3 (UAT feedback 2026-05-08):
// - TỐI ĐA 3 cấp/bước (không có cấp 4). 1/2/3 cấp đều OK — quy trình
// chạy theo số cấp thật sự có.
// - MỖI CẤP CÓ N NV: convention multiple Level rows cùng Order = same Cấp.
// BE iterate group by Order; trong cùng cấp = OR-of-N approvers.
// - 3 section cố định C1/C2/C3 trong UI. Mỗi section có nút "+ Thêm NV"
// + Trash xóa từng NV.
// - Sequential gating: C2 disabled khi C1 chưa có NV. C3 disabled khi C2
// chưa có NV. Chặn xóa NV cuối C1 khi C2/C3 còn entries.
// - Select NV CHỈ filter theo Phòng đã chọn (đổi Phòng → clear approvers).
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, Pin, PinOff } 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
// Mig 29 (S21 t5) — 5 Allow* options per slot Approver
// Mig 30 (S22+5) — +AllowApproverEditBudget cho Section ngân sách
// Mig 31 (S23 t1) — +AllowApproverSkipToFinal F2 storage swap Users→Level (per-Approver-slot)
allowReturnOneLevel: boolean
allowReturnOneStep: boolean
allowReturnToAssignee: boolean
allowReturnToDrafter: boolean
allowApproverEditDetails: boolean
allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean
}
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
isUserSelectable: boolean // Mig 25 — admin toggle cho user pick
// Mig 29 (S21 t5) — 6 Allow* options MOVED:
// - 5 flag F1+F3 xuống per slot Level (xem LevelDto)
// - 1 flag F2 AllowDrafterSkipToFinal xuống per User (User Management)
activatedAt: string | null
createdAt: string
steps: StepDto[]
}
type TypeSummaryDto = {
applicableType: number
applicableTypeLabel: string
active: DefinitionDto | null
history: DefinitionDto[]
}
type LevelOrder = 1 | 2 | 3
type EditLevelEntry = {
order: LevelOrder
approverUserId: string
// Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ
// AllowReturnToDrafter=true, 4 còn lại false).
// Mig 30 (S22+5) — +AllowApproverEditBudget cho Section ngân sách (default false).
// Mig 31 (S23 t1) — +AllowApproverSkipToFinal F2 admin opt-in per-Approver-slot (default false).
allowReturnOneLevel: boolean
allowReturnOneStep: boolean
allowReturnToAssignee: boolean
allowReturnToDrafter: boolean
allowApproverEditDetails: boolean
allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean
}
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null }
// Tối đa 3 cấp/bước per UAT iter 3 (2026-05-08). Quy trình chạy theo số cấp
// thật sự có (1 / 2 / 3). Mỗi cấp có N NV (multiple Level rows cùng Order).
const MAX_LEVELS_PER_STEP = 3
const LEVEL_ORDERS: LevelOrder[] = [1, 2, 3]
// 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 makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep {
return {
name: `Phòng ${stepNo}`,
departmentId: deptId,
levelEntries: [],
}
}
// Clone existing definition: filter Order ∈ {1,2,3}, drop entries vượt giới hạn.
// Mig 29 (S21 t5) — clone 5 Allow* per slot từ existing Level.
function copyFromDefinition(d: DefinitionDto): EditStep[] {
return d.steps.map(s => ({
name: s.name,
departmentId: s.departmentId,
levelEntries: s.levels
.filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP)
.map(l => ({
order: l.order as LevelOrder,
approverUserId: l.approverUserId,
allowReturnOneLevel: l.allowReturnOneLevel ?? false,
allowReturnOneStep: l.allowReturnOneStep ?? false,
allowReturnToAssignee: l.allowReturnToAssignee ?? false,
allowReturnToDrafter: l.allowReturnToDrafter ?? true,
allowApproverEditDetails: l.allowApproverEditDetails ?? false,
allowApproverEditBudget: l.allowApproverEditBudget ?? false,
allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false,
})),
}))
}
// Mig 29 — Factory default cho entry mới (admin click "+ Thêm NV"). 5 flag
// default backward compat S17: chỉ AllowReturnToDrafter=true.
function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditLevelEntry {
return {
order,
approverUserId,
allowReturnOneLevel: false,
allowReturnOneStep: false,
allowReturnToAssignee: false,
allowReturnToDrafter: true,
allowApproverEditDetails: false,
allowApproverEditBudget: false,
allowApproverSkipToFinal: false,
}
}
// Filter NV theo Phòng. Nếu Phòng = null → fallback all (chưa chọn phòng).
function usersForDept(all: ApproverUser[] | undefined, deptId: string | null): ApproverUser[] {
if (!all) return []
if (!deptId) return all
return all.filter(u => u.departmentId === deptId)
}
function entriesAtLevel(step: EditStep, order: LevelOrder): EditLevelEntry[] {
return step.levelEntries.filter(e => e.order === order)
}
// Cấp k được phép thao tác khi Cấp k-1 có ≥1 NV (gating sequential).
function isLevelEnabled(step: EditStep, order: LevelOrder): boolean {
if (order === 1) return step.departmentId !== null
return entriesAtLevel(step, (order - 1) as LevelOrder).length > 0
}
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)),
})
// Mig 25 — toggle "cho user pick lúc create phiếu" (stick/unstick)
const toggleSelectable = useMutation({
mutationFn: async ({ id, isUserSelectable }: { id: string; isUserSelectable: boolean }) =>
api.patch(`/approval-workflows-v2/${id}/user-selectable`, { isUserSelectable }),
onSuccess: (_data, vars) => {
toast.success(vars.isUserSelectable
? 'Đã ghim — user có thể chọn quy trình này'
: 'Đã bỏ ghim — user không thấy quy trình này')
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) }}
onToggleSelectable={() => toggleSelectable.mutate({
id: type.active!.id,
isUserSelectable: !type.active!.isUserSelectable,
})}
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 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 version . 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) }}
onToggleSelectable={() => toggleSelectable.mutate({
id: d.id,
isUserSelectable: !d.isUserSelectable,
})}
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,
onToggleSelectable,
onDelete,
}: {
def: DefinitionDto
isActive: boolean
onClone: (d: DefinitionDto) => void
onToggleSelectable: () => 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>
)}
{/* Mig 25 — badge IsUserSelectable: ghim cho user pick */}
{def.isUserSelectable && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700" title="User có thể chọn quy trình này khi tạo phiếu">
<Pin className="h-3 w-3" />
Cho user chọn
</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>
{/* Group by Order — 1 cấp có N NV */}
<ul className="mt-2 ml-9 space-y-1.5">
{s.levels.length === 0 ? (
<li className="text-[11px] italic text-slate-400">Chưa cấp duyệt</li>
) : (
Array.from(
s.levels.reduce((map, l) => {
const arr = map.get(l.order) ?? []
arr.push(l)
map.set(l.order, arr)
return map
}, new Map<number, LevelDto[]>()).entries(),
)
.sort(([a], [b]) => a - b)
.map(([order, group]) => (
<li key={order} className="flex items-start gap-2 text-xs">
<span className="mt-0.5 shrink-0 rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
Cấp {order}
</span>
<div className="flex-1 space-y-0.5">
{group.map(l => (
<div key={l.id} className="flex items-center gap-1.5">
<span className="font-medium text-slate-800">
{l.approverUserName ?? l.approverUserId}
</span>
{l.approverEmail && (
<span className="text-[10px] text-slate-400">({l.approverEmail})</span>
)}
</div>
))}
</div>
</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>
{/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */}
<Button
variant="outline"
size="sm"
onClick={onToggleSelectable}
title={def.isUserSelectable ? 'Bỏ ghim — user sẽ không thấy quy trình này' : 'Ghim — user có thể chọn quy trình này'}
>
{def.isUserSelectable ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
{def.isUserSelectable ? 'Bỏ ghim' : 'Ghim cho user'}
</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) : [makeEmptyStep(1)]),
[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 : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(cloneFrom?.description ?? '')
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
// Mig 29 (S21 t5) — 6 Allow* options MOVED:
// - 5 flag F1+F3 xuống per Level slot (xem EditLevelEntry, render mỗi Level row)
// - 1 flag F2 AllowDrafterSkipToFinal xuống per User (User Management page)
const usersList = useQuery({
queryKey: ['users-for-approver-v2'],
queryFn: async () =>
(await api.get<{ items: ApproverUser[] }>('/users', {
params: { page: 1, pageSize: 500 },
})).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 per Bước:
// - Phòng required
// - Cấp 1 phải có ≥1 NV (sequential gating đảm bảo nếu C1 empty thì C2/C3 cũng empty)
// - All approver thuộc đúng Phòng (defensive double-check)
for (const s of steps) {
if (!s.departmentId) {
throw new Error(`Bước "${s.name}" chưa chọn Phòng.`)
}
if (entriesAtLevel(s, 1).length === 0) {
throw new Error(`Bước "${s.name}" chưa có NV ở Cấp 1.`)
}
// Sequential gating đã enforce ở UI nhưng kiểm tra lại
if (entriesAtLevel(s, 2).length === 0 && entriesAtLevel(s, 3).length > 0) {
throw new Error(`Bước "${s.name}": Cấp 3 chỉ thao tác được khi Cấp 2 có NV.`)
}
for (const e of s.levelEntries) {
if (!e.approverUserId) {
throw new Error(`Bước "${s.name}": có dòng cấp chưa chọn NV.`)
}
const u = usersList.data?.find(x => x.id === e.approverUserId)
if (u && u.departmentId !== s.departmentId) {
throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`)
}
}
}
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,
// Mỗi entry → 1 Level row. Multiple rows cùng Order = same Cấp với
// N approvers (BE iterate group by Order).
// Mig 29 (S21 t5) — 5 Allow* options per slot Approver.
// Mig 30 (S22+5) — +AllowApproverEditBudget.
// Mig 31 (S23 t1) — +AllowApproverSkipToFinal F2 storage swap per-slot.
levels: s.levelEntries.map(e => ({
order: e.order,
name: `Cấp ${e.order}`,
approverUserId: e.approverUserId,
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
allowReturnToAssignee: e.allowReturnToAssignee,
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
})),
})),
})
},
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)
}
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> 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> tả</Label>
<Textarea rows={2} value={description} onChange={e => setDescription(e.target.value)} />
</div>
</div>
{/* Mig 29 (S21 t5) — 5 Allow* F1+F3 per slot Approver.
Mig 30 (S22+5) — +AllowApproverEditBudget per slot.
Mig 31 (S23 t1) — F2 storage swap Users→Level: per-Approver-slot.
ALL Allow* options now configured PER NV trong slot Approver dưới đây. */}
<div className="rounded-lg border border-violet-200 bg-violet-50/30 px-3 py-2 text-[11px] leading-relaxed text-violet-800">
Cấu hình quyền duyệt riêng cho từng NV trong slot Approver bên dưới
(Trả lại / Edit Section 2 / Edit Budget / Duyệt thẳng Cấp cuối).
</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 × <span className="font-bold text-violet-700">tối đa {MAX_LEVELS_PER_STEP} cấp NV</span> ({steps.length} bước)
</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setSteps([...steps, makeEmptyStep(steps.length + 1)])}
>
<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 * (chọn đ filter NV duyệt)</Label>
<Select
value={s.departmentId ?? ''}
onChange={e => {
// Đổi Phòng → clear hết approvers vì NV cũ có thể không thuộc Phòng mới.
const newDeptId = e.target.value || null
setSteps(steps.map((x, i) => (i === idx
? { ...x, departmentId: newDeptId, levelEntries: [] }
: x)))
}}
required
>
<option value=""> Chọn Phò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 — 3 section cố định C1/C2/C3. Mỗi cấp có N NV.
Sequential gating: Cấp k chỉ active khi Cấp k-1 có ≥1 NV. */}
<div className="mt-2 ml-9 space-y-2 border-l-2 border-violet-200 pl-3">
{!s.departmentId && (
<div className="rounded bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-700">
Chọn Phòng đ bắt đu cấu hình cấp duyệt.
</div>
)}
{s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && (
<div className="rounded border border-dashed border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] italic text-amber-700">
Phòng này chưa nhân viên. Vào /system/users gán NV vào Phòng trước, sau đó quay lại.
</div>
)}
{LEVEL_ORDERS.map(order => {
const entries = entriesAtLevel(s, order)
const enabled = isLevelEnabled(s, order)
const filteredUsers = usersForDept(usersList.data, s.departmentId)
// NV còn khả dụng (chưa được dùng ở cấp này — cùng cấp không trùng NV)
const usedInThisLevel = new Set(entries.map(e => e.approverUserId))
const availableUsers = filteredUsers.filter(u => !usedInThisLevel.has(u.id))
// Disable Add: Phòng chưa chọn / cấp trước chưa có NV / hết NV available
const addDisabled = !enabled || availableUsers.length === 0
return (
<div
key={order}
className={`rounded-md border p-2 ${
enabled
? 'border-violet-200 bg-white'
: 'border-slate-200 bg-slate-50/50 opacity-60'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
Cấp {order}
</span>
<span className="text-[11px] text-slate-500">
{entries.length === 0
? (enabled ? 'Chưa có NV' : `Hoàn tất Cấp ${order - 1} trước`)
: `${entries.length} NV duyệt`}
</span>
</div>
<button
type="button"
disabled={addDisabled}
onClick={() => {
if (availableUsers.length === 0) return
const firstUser = availableUsers[0]
setSteps(steps.map((x, i) =>
i === idx
? { ...x, levelEntries: [...x.levelEntries, makeDefaultLevelEntry(order, firstUser.id)] }
: x,
))
}}
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-40"
title={
!enabled
? `Cấp ${order - 1} phải có ≥1 NV trước`
: availableUsers.length === 0
? 'Hết NV khả dụng (đã thêm hết hoặc Phòng không còn NV)'
: 'Thêm NV duyệt vào cấp này'
}
>
+ Thêm NV
</button>
</div>
{entries.length > 0 && (
<div className="mt-1.5 space-y-1">
{entries.map((entry, ei) => {
// Tính index trong levelEntries gốc để update
const globalIdx = s.levelEntries.findIndex(
x => x === entry,
)
// NV available cho dropdown: filtered + chính NV đang chọn (giữ option hiện tại)
const dropdownUsers = filteredUsers.filter(
u => u.id === entry.approverUserId || !usedInThisLevel.has(u.id),
)
return (
<div key={ei} className="flex items-center gap-1.5">
<span className="text-[10px] text-slate-400">#{ei + 1}</span>
<Select
value={entry.approverUserId}
onChange={e => {
const newId = e.target.value
setSteps(steps.map((x, i) =>
i === idx
? {
...x,
levelEntries: x.levelEntries.map((y, j) =>
j === globalIdx ? { ...y, approverUserId: newId } : y,
),
}
: x,
))
}}
className="h-7 flex-1 text-xs"
>
{dropdownUsers.map(u => (
<option key={u.id} value={u.id}>
{u.fullName} ({u.email})
</option>
))}
</Select>
<button
type="button"
onClick={() => {
// Chặn xóa NV cuối Cấp 1 nếu Cấp 2/3 còn entries (gating)
if (entries.length === 1) {
const nextLevelHasEntries =
order < MAX_LEVELS_PER_STEP &&
entriesAtLevel(s, (order + 1) as LevelOrder).length > 0
if (nextLevelHasEntries) {
toast.error(
`Hãy xóa hết NV ở Cấp ${order + 1} trước khi rỗng Cấp ${order}.`,
)
return
}
}
setSteps(steps.map((x, i) =>
i === idx
? { ...x, levelEntries: x.levelEntries.filter((_, j) => j !== globalIdx) }
: 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 NV khỏi cấp này"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)
})}
{/* Mig 29 (S21 t5) — 5 Allow* checkbox inline cho mỗi
NV entry. Mặc định AllowReturnToDrafter=true (S17
backward compat). Admin tick mở mode khác per slot. */}
{entries.map((entry, ei) => {
const globalIdx = s.levelEntries.findIndex(x => x === entry)
const updateField = (field: keyof EditLevelEntry, value: boolean) => {
setSteps(steps.map((x, i) =>
i === idx
? {
...x,
levelEntries: x.levelEntries.map((y, j) =>
j === globalIdx ? { ...y, [field]: value } : y,
),
}
: x,
))
}
return (
<div
key={`opts-${ei}`}
className="ml-4 mt-1 rounded border border-amber-100 bg-amber-50/30 px-2 py-1.5"
>
<div className="mb-1 text-[10px] font-medium uppercase text-amber-700">
Quyền duyệt {usersList.data?.find(u => u.id === entry.approverUserId)?.fullName ?? 'Chưa chọn NV'}
</div>
<div className="grid grid-cols-2 gap-1">
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnOneLevel}
onChange={e => updateField('allowReturnOneLevel', e.target.checked)}
/>
<span>Trả về 1 Cấp trước</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnOneStep}
onChange={e => updateField('allowReturnOneStep', e.target.checked)}
/>
<span>Trả về 1 Bước trước</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnToAssignee}
onChange={e => updateField('allowReturnToAssignee', e.target.checked)}
/>
<span>Trả về Người chỉ đnh</span>
</label>
<label className="flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowReturnToDrafter}
onChange={e => updateField('allowReturnToDrafter', e.target.checked)}
/>
<span>Trả về Drafter (mặc đnh)</span>
</label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverEditDetails}
onChange={e => updateField('allowApproverEditDetails', e.target.checked)}
/>
<span>Cho phép chỉnh sửa Section 2 (Hạng mục/NCC/Báo giá) lúc đang duyệt</span>
</label>
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverEditBudget}
onChange={e => updateField('allowApproverEditBudget', e.target.checked)}
/>
<span>Cho phép chỉnh sửa Section ngân sách lúc đang duyệt</span>
</label>
{/* Mig 31 (S23 t1) — F2 AllowApproverSkipToFinal admin opt-in per-slot.
Approver tick = skip thẳng Cấp cuối khi đang ChoDuyet. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-slate-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverSkipToFinal}
onChange={e => updateField('allowApproverSkipToFinal', e.target.checked)}
/>
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
</label>
</div>
</div>
)
})}
</div>
)}
</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: mỗi Bước 1 Phòng + tối đa {MAX_LEVELS_PER_STEP} Cấp. Quy trình chạy theo số cấp thật sự
(1 / 2 / 3 cấp đu OK). Trong cùng 1 Cấp thể nhiều NV chỉ cần 1 NV duyệt cấp đó pass
(OR-of-N). Tuần tự: Cấp 1 Cấp 2 Cấp 3 Bước kế. Hết tất cả Bước = Đã duyệt.
</div>
</div>
</form>
</Dialog>
)
}