Files
solution-erp/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx
pqhuy1987 b1bae77c9c
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s
[CLAUDE] Workflow: sua quy trinh duyet TAI CHO + co F6 AllowApproverDelete (dot 1)
Nen cho tinh nang "xoa phieu PE o man duyet" (UAT: phieu bam sai goi thau dang
an luy ke). Dot 1 lam NEN, dot 2 moi lam nut xoa.

Vi sao phai lam nen truoc: phieu dang chay pin ApprovalWorkflowId va doc workflow
SONG theo Id do. Moi POST tao workflow moi = Id MOI, nen tick co tren ban moi
KHONG BAO GIO toi duoc phieu dang treo. Phai co lenh sua-tai-cho giu nguyen Id.

- Domain: ApprovalWorkflowLevel + AllowApproverDelete (F6, per-NGUOI) + Mig
  20260727033522_AddPeAllowApproverDelete (AddColumn bit default false, no new table)
- Application: UpdateAwDefinitionCommand + Validator + Handler
  - 2 phep thu: (T1) khung khong doi - Order tung row bat bien
                (T2) luat choi khong doi - cam CeoApprovalThreshold, AllowApproverFinalize,
                     Code, ApplicableType => 409 bao tao ban moi
  - diff theo Level.Id (CAM delete-then-insert: no FK Restrict + mo coi LevelOpinion)
  - guard 2 tang chu ky: tang 1 chu ky tren ho so CON SONG; tang 2 chu ky CON LUU
    (cha xoa mem hoac opinion xoa mem) - ca hai deu CHAN, khong xoa cung
  - purge chi khi ho so cha KHONG CON ROW; boc transaction (ExecuteDelete tu commit)
  - KHONG tang Version, KHONG dung IsActive, KHONG doi Id
- Api: PUT /api/approval-workflows-v2/{id} + [Authorize(Policy="Workflows.Create")]
- Infra: message 403 in TEN NGUOI thay GUID tran
- FE: Designer tach "Sua tai cho" (PUT) vs "Tao phien ban moi" (POST) + o tick F6
- Tests: +21 (551 tong, 0 fail) - gom F12 chung minh phieu dang chay nhan thay doi ngay

Review: 14 FLAG, 3 HIGH da va (ExecuteDelete ngoai transaction / purge xoa cung
chu ky phieu xoa mem / endpoint khong co day FE goi toi).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:36:59 +07:00

1311 lines
66 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, Pencil, AlertTriangle } 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
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO)
// [F6 S155] PER-NGƯỜI (per-row, KHÔNG per-Cấp): NV slot này được XÓA phiếu khi
// đang ChoDuyet và đến lượt duyệt của chính mình. 1 row = 1 người ⇒ admin chỉ
// đích danh, không mở cho cả Cấp.
allowApproverDelete: boolean
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
canEditProBudget: boolean
canEditCcmBudget: 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
// S69 — ngưỡng giá trị "gói CEO". Null = không áp ngưỡng (luồng đủ quy trình).
// Gói < ngưỡng: CCM duyệt-final không cần CEO. ≥ ngưỡng: phải lên CEO.
ceoApprovalThreshold: number | null
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
// ALL xuống per slot Level (xem LevelDto). Admin opt-in per-Approver-slot
// pattern proven 3× cumulative: F1+F3 (5 flag Mig 29) + F4 (Mig 30) + F2
// (Mig 31 refactor sang Approver scope, storage cũ Users đã drop).
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 = {
// [F-7 S155] Id của dòng Cấp ĐANG TỒN TẠI trong DB. `undefined` = dòng MỚI
// (admin vừa bấm "+ Thêm NV"). CHỈ dùng cho đường PUT sửa-tại-chỗ:
// `UpdateAwLevelInput.Id` là `Guid?` — có Id = sửa dòng cũ, null = thêm người
// vào Cấp có sẵn (ApprovalWorkflowV2AdminFeatures.cs:424-426).
// Đường POST tạo version mới KHÔNG gửi field này (BE sinh Id mới).
id?: string
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
allowApproverFinalize: boolean
// [F6 S155] Cho phép NV này xóa phiếu khi đến lượt duyệt của mình (default false).
allowApproverDelete: boolean
}
type EditStep = {
// [F-7 S155] Id + Order GỐC của Bước trong DB — BẮT BUỘC cho PUT sửa-tại-chỗ
// (`UpdateAwStepInput.Id` là Guid **không** nullable: lệnh này không có đường
// tạo Bước mới) và `reqStep.Order` phải KHỚP `dbStep.Order` (:554) — nên KHÔNG
// được suy ra bằng index `i+1` như đường POST (Order trong DB có thể không liền mạch).
id?: string
order?: number
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 => ({
// [F-7 S155] Giữ Id + Order gốc. Đường POST (tạo version mới) KHÔNG dùng 2
// field này; đường PUT (sửa tại chỗ) BẮT BUỘC có. Cùng 1 hàm nạp cho cả 2
// chế độ để không sinh nhánh nạp thứ hai — nơi 2 nhánh dễ lệch nhau.
id: s.id,
order: s.order,
name: s.name,
departmentId: s.departmentId,
levelEntries: s.levels
.filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP)
.map(l => ({
id: l.id,
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,
allowApproverFinalize: l.allowApproverFinalize ?? false,
// [F6 S155] `?? false` giữ nguyên khuôn F5: bản cũ (BE chưa có cột) clone
// ra vẫn hợp lệ thay vì undefined lọt vào checked → uncontrolled input.
allowApproverDelete: l.allowApproverDelete ?? 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,
allowApproverFinalize: false,
// [F6 S155] default false — quyền xóa phiếu đang duyệt phải admin tick tay.
allowApproverDelete: false,
}
}
// [F-7 S155] 🔴 CHỐNG BẪY "silent drop": payload gửi BE là object literal LIỆT KÊ
// TAY từng field (không spread `...e`) — thiếu 1 dòng thì TS vẫn build sạch, UI vẫn
// tick, BE nhận default `false` ⇒ tick vô hình (đã xảy ra thật với F6, xem
// sub-implementer-frontend-2.md §2.4). Từ đợt này có HAI call-site (POST + PUT) ⇒
// nguy cơ nhân đôi. Gom 9 cờ vào ĐÚNG MỘT hàm, cả 2 call-site cùng spread nó:
// thêm cờ thứ 10 chỉ phải sửa 1 chỗ, không thể lệch giữa 2 đường.
// - 7 cờ QUYỀN-TẠI-CHỖ (4×Return + EditDetails + EditBudget + Delete): PUT ghi được.
// - 2 cờ ĐỔI-ĐƯỜNG-ĐI (SkipToFinal, Finalize): PUT chỉ ECHO — gửi lệch ⇒ 409
// (ApprovalWorkflowV2AdminFeatures.cs:580-585). Vẫn PHẢI gửi đúng giá trị đang lưu.
function levelFlagsPayload(e: EditLevelEntry) {
return {
allowReturnOneLevel: e.allowReturnOneLevel,
allowReturnOneStep: e.allowReturnOneStep,
allowReturnToAssignee: e.allowReturnToAssignee,
allowReturnToDrafter: e.allowReturnToDrafter,
allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
allowApproverDelete: e.allowApproverDelete,
}
}
// 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)
// [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id +
// Version). Null ⇒ chế độ tạo version MỚI (POST) như cũ. Hai đường loại trừ nhau.
const [editTarget, setEditTarget] = 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 => { setEditTarget(null); setCloneFrom(d); setDesignerOpen(true) }}
onEditInPlace={d => { setCloneFrom(null); setEditTarget(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={() => { setEditTarget(null); 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 => { setEditTarget(null); setCloneFrom(dd); setDesignerOpen(true) }}
// 🔴 Version ARCHIVED cũng PHẢI sửa-tại-chỗ được: đúng kịch bản F3 —
// admin lỡ bấm "Tạo quy trình mới", bản cũ archive, nhưng phiếu đang
// treo vẫn pin Id bản cũ đó. Cứu phiếu = sửa CHÍNH bản archived này.
onEditInPlace={dd => { setCloneFrom(null); setEditTarget(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}
editTarget={editTarget}
onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null); onSaved() }}
/>
)}
</div>
)
}
// ===== Definition card (read-only) =====
function DefinitionCard({
def,
isActive,
onClone,
onEditInPlace,
onToggleSelectable,
onDelete,
}: {
def: DefinitionDto
isActive: boolean
onClone: (d: DefinitionDto) => void
onEditInPlace: (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>
)}
{/* S69 — badge ngưỡng gói CEO (nếu có set) */}
{def.ceoApprovalThreshold != null && (
<span
className="inline-flex items-center gap-1 rounded-full bg-rose-100 px-2 py-0.5 text-[10px] font-medium text-rose-700"
title="Gói ≥ ngưỡng phải lên CEO duyệt. Gói < ngưỡng: CCM duyệt là xong."
>
Ngưỡng CEO: {def.ceoApprovalThreshold.toLocaleString('vi-VN')}đ
</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 flex-wrap 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>
)}
{/* [S76] Badge quyền nhập/điều chỉnh ngân sách (hiển thị-only, suy từ role) */}
{l.canEditProBudget && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700" title="Được nhập/điều chỉnh ngân sách cột PRO"> NS PRO</span>
)}
{l.canEditCcmBudget && (
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[9px] font-semibold text-sky-700" title="Được nhập/điều chỉnh ngân sách cột CCM"> NS CCM</span>
)}
</div>
))}
</div>
</li>
))
)}
</ul>
</li>
))}
</ol>
</div>
<div className="flex w-44 shrink-0 flex-col gap-1.5">
{/* [F-7 S155] HAI hành động TÁCH BẠCH — hệ quả khác hẳn nhau, nhãn phải nói rõ:
(1) SỬA TẠI CHỖ → PUT, giữ nguyên Id + phiên bản ⇒ phiếu ĐANG CHẠY nhận NGAY.
(2) TẠO PHIÊN BẢN MỚI → POST, sinh Id mới ⇒ CHỈ áp cho phiếu tạo SAU đó. */}
<Button
size="sm"
onClick={() => onEditInPlace(def)}
title="Sửa ngay trên phiên bản này: thêm/bớt người trong Cấp có sẵn, đổi quyền duyệt. Giữ nguyên số phiên bản — phiếu ĐANG CHẠY áp dụng NGAY."
>
<Pencil className="h-3.5 w-3.5" />
Sửa tại chỗ
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onClone(def)}
title="Chép bản này thành phiên bản MỚI (số phiên bản tăng). Phiếu đang chạy VẪN theo bản cũ — chỉ phiếu tạo sau mới theo bản mới."
>
<Plus className="h-3.5 w-3.5" />
Tạo phiên bản mới
</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,
editTarget,
onClose,
onSaved,
}: {
applicableType: number
applicableTypeLabel: string
cloneFrom: DefinitionDto | null
// [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom.
editTarget: DefinitionDto | null
onClose: () => void
onSaved: () => void
}) {
// [F-7 S155] `isEdit` quyết định TOÀN BỘ hành vi khác biệt của Designer:
// nguồn nạp, field nào khoá, thao tác nào chặn, và gọi PUT hay POST.
const isEdit = editTarget !== null
const source = editTarget ?? cloneFrom
const initialSteps: EditStep[] = useMemo(
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source],
)
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(source?.code ?? defaultCode)
const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(source?.description ?? '')
// S69 — Ngưỡng gói CEO (nullable). String form, '' = null. Clone giữ ngưỡng cũ.
// [F-7 S155] Ở chế độ sửa-tại-chỗ ô này bị KHOÁ và giá trị gửi lên KHÔNG lấy từ
// state string này (xem payload PUT) — tránh mọi sai lệch do parse/format.
const [ceoThreshold, setCeoThreshold] = useState(
source?.ceoApprovalThreshold != null ? String(source.ceoApprovalThreshold) : '',
)
// [F-7 S155] Lý do sửa → BE ghi vào changelog của CHÍNH những phiếu đang chạy
// trên quy trình này (ApprovalWorkflowV2AdminFeatures.cs:802-818). Optional.
const [reason, setReason] = useState('')
// [F-7 S155] Thông điệp lỗi từ BE (nhất là 409 giải thích vì sao KHÔNG sửa tại
// chỗ được). Toast tự tắt sau vài giây và cắt dòng — 409 của lệnh này dài và là
// hướng dẫn hành động ("hãy tạo phiên bản MỚI") ⇒ phải giữ trên màn để đọc kỹ.
const [serverError, setServerError] = useState<string | null>(null)
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
// ALL xuống per Level slot (xem EditLevelEntry, render inline mỗi Level row).
// Admin opt-in per-Approver-slot pattern proven 3× cumulative.
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.`)
}
// [F-7 S155] Ràng "NV phải thuộc Phòng đã chọn" CHỈ áp cho đường tạo mới.
// Sửa tại chỗ được phép đổi Phòng của Bước (BE xếp hạng AN TOÀN và KHÔNG
// kiểm quan hệ NV↔Phòng — :705-706 chỉ gán DepartmentId), trong khi người
// đang duyệt thì KHÔNG được đụng (đổi người mà đã có chữ ký ⇒ 409). Giữ
// ràng này ở chế độ sửa sẽ chặn oan đúng thao tác BE cho phép.
if (!isEdit) {
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.`)
}
}
}
}
// ============ [F-7 S155] ĐƯỜNG 1 — SỬA TẠI CHỖ (PUT, giữ Id + Version) ============
if (editTarget) {
// Chặn TRƯỚC ở FE những thay đổi BE sẽ trả 409, để người dùng đọc được câu
// tiếng Việt nói rõ phải làm gì thay vì nhận lỗi sau khi bấm Lưu. UI đã khoá
// các nút ±Bước/đổi thứ tự, đây là lớp chặn thứ hai (state vẫn có thể lệch
// nếu dữ liệu nạp vào bất thường).
if (steps.length !== editTarget.steps.length) {
throw new Error(
`Sửa tại chỗ không thêm/bớt được Bước (đang có ${editTarget.steps.length}, sau khi sửa còn ${steps.length}). Hãy dùng "Tạo phiên bản mới".`,
)
}
for (const s of steps) {
const src = editTarget.steps.find(x => x.id === s.id)
if (!s.id || !src) {
throw new Error('Sửa tại chỗ không tạo được Bước mới. Hãy dùng "Tạo phiên bản mới".')
}
if (src.levels.some(l => l.order < 1 || l.order > MAX_LEVELS_PER_STEP)) {
throw new Error(
`Bước "${src.name}" có cấp ngoài phạm vi 1${MAX_LEVELS_PER_STEP} nên màn này không hiển thị đủ. Không sửa tại chỗ được — hãy dùng "Tạo phiên bản mới".`,
)
}
// `src.levels[].order` từ DTO là `number`, còn `levelEntries[].order` là
// `LevelOrder` (1|2|3) ⇒ ép cả hai Set về `number` để so sánh được.
const srcOrders = new Set<number>(src.levels.map(l => l.order))
const curOrders = new Set<number>(s.levelEntries.map(e => e.order))
const lost = [...srcOrders].filter(o => !curOrders.has(o)).sort()
if (lost.length > 0) {
throw new Error(
`Bước "${s.name}": Cấp ${lost.join(', ')} sẽ không còn ai duyệt — phiếu đang chờ ở cấp đó sẽ KẸT. Hãy giữ ít nhất 1 người ở mỗi Cấp, hoặc dùng "Tạo phiên bản mới".`,
)
}
const added = [...curOrders].filter(o => !srcOrders.has(o)).sort()
if (added.length > 0) {
throw new Error(
`Bước "${s.name}": sửa tại chỗ không thêm được Cấp mới (Cấp ${added.join(', ')}). Hãy dùng "Tạo phiên bản mới".`,
)
}
}
await api.put(`/approval-workflows-v2/${editTarget.id}`, {
id: editTarget.id,
name,
description: description || null,
// Lý do sửa → changelog phiếu đang chạy. Rỗng = không gửi.
reason: reason.trim() === '' ? null : reason.trim(),
// ECHO (null = BE bỏ qua, nhưng gửi đúng vẫn tốt hơn: lệch ⇒ 409 sớm).
code: editTarget.code,
applicableType,
// 🔴 ECHO NGHIÊM (ApprovalWorkflowV2AdminFeatures.cs:529 so sánh BẰNG,
// KHÔNG có "null = bỏ qua"): thiếu/sai ⇒ 409. Lấy THẲNG số từ DTO vừa
// GET về — KHÔNG đi qua state string `ceoThreshold` để loại mọi rủi ro
// parse/format ('' → null, dấu phân cách nghìn, làm tròn).
ceoApprovalThreshold: editTarget.ceoApprovalThreshold,
steps: steps.map(s => ({
id: s.id,
// Order gốc, KHÔNG phải index: BE bắt `reqStep.Order == dbStep.Order` (:554).
order: s.order,
name: s.name,
departmentId: s.departmentId,
levels: s.levelEntries.map(e => ({
// null = dòng MỚI (thêm người vào Cấp có sẵn). Có Id = sửa dòng cũ.
id: e.id ?? null,
order: e.order,
name: `Cấp ${e.order}`,
approverUserId: e.approverUserId,
// 9 cờ qua ĐÚNG 1 hàm dùng chung với POST — xem levelFlagsPayload().
...levelFlagsPayload(e),
})),
})),
})
return
}
// ============ ĐƯỜNG 2 — TẠO PHIÊN BẢN MỚI (POST, sinh Id mới) ============
await api.post('/approval-workflows-v2', {
applicableType,
code,
name,
description: description || null,
// S69 — ngưỡng gói CEO: '' → null (không áp ngưỡng), else parse số.
ceoApprovalThreshold: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')),
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,
// [F6 S155 → F-7 S155] 🔴 DÂY DỄ QUÊN NHẤT: payload liệt kê tay từng
// field (KHÔNG spread `...e`) ⇒ thiếu 1 cờ thì TS vẫn build sạch, ô tick
// vẫn hiện, chỉ BE không bao giờ nhận. Từ khi có call-site thứ hai (PUT),
// 9 cờ gom vào levelFlagsPayload() dùng CHUNG — thêm cờ mới chỉ sửa 1 nơi.
...levelFlagsPayload(e),
})),
})),
})
},
onSuccess: () => {
setServerError(null)
toast.success(
isEdit
? `Đã lưu thay đổi trên ${editTarget!.code} v${editTarget!.version} — GIỮ NGUYÊN phiên bản. Các phiếu đang chạy trên quy trình này áp dụng NGAY.`
: 'Đã lưu quy trình mới. Version cũ đã archive. Lưu ý: phiếu ĐANG chạy vẫn theo bản cũ.',
)
onSaved()
},
onError: err => {
// 🔴 KHÔNG nuốt thông điệp BE. 409 của lệnh sửa-tại-chỗ giải thích chính xác
// thay đổi nào phạm luật + bảo phải làm gì ("tạo phiên bản MỚI") ⇒ giữ lại
// trên màn (banner) chứ không chỉ toast thoáng qua.
const msg = getErrorMessage(err)
setServerError(msg)
toast.error(msg)
},
})
function submit(e: FormEvent) {
e.preventDefault()
setServerError(null)
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={
isEdit
? `Sửa tại chỗ — ${editTarget!.code} v${String(editTarget!.version).padStart(2, '0')} (giữ nguyên phiên bản)`
: `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…'
: isEdit ? 'Lưu thay đổi (giữ nguyên phiên bản)' : 'Lưu + kích hoạt'}
</Button>
</>
}
>
<form id="aw-v2-form" onSubmit={submit} className="space-y-4">
{/* [F-7 S155] Banner phân biệt HAI đường — người dùng phải hiểu hệ quả
khác nhau TRƯỚC khi sửa, không phải sau khi bấm Lưu. */}
{isEdit ? (
<div className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-[11.5px] leading-relaxed text-emerald-900">
<div className="font-semibold">Đang SỬA TẠI CHỖ phiên bản {editTarget!.code} v{String(editTarget!.version).padStart(2, '0')} không tạo phiên bản mới.</div>
<div className="mt-1">
Lưu xong, thay đi áp dụng <span className="font-semibold">NGAY</span> cho cả những phiếu <span className="font-semibold">đang chạy</span> trên quy trình này
(kể cả phiếu đang chờ duyệt) đây cách duy nhất đ cờ quyền vừa tick tới đưc phiếu đã gửi đi.
</div>
<div className="mt-1">
Sửa được: thêm/bớt người trong Cấp <span className="font-semibold"> sẵn</span> · đi người duyệt · các ô quyền duyệt (gồm quyền XÓA phiếu) · tên hiển thị · Phòng của Bước.
<br />
Muốn thêm/bớt Bước hoặc Cấp, đi thứ tự, đi / Ngưỡng CEO / cờ &quot;Duyệt KẾT THÚC&quot; / &quot;Duyt thng Cp cui&quot; phi bm <span className="font-semibold">&quot;To phiên bn mi&quot;</span>.
</div>
</div>
) : (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-[11.5px] leading-relaxed text-amber-900">
<div className="font-semibold">Đang TẠO PHIÊN BẢN MỚI số phiên bản sẽ tăng, bản hiện tại chuyển sang lưu trữ.</div>
<div className="mt-1">
Phiếu <span className="font-semibold">đang chạy vẫn theo bản </span> (phiếu gắn chặt vào phiên bản lúc tạo). Chỉ phiếu tạo SAU khi lưu mới theo bản này.
Nếu chỉ muốn thêm người / đi quyền cho phiếu đang treo, hãy đóng lại bấm <span className="font-semibold">&quot;Sửa tại chỗ&quot;</span>.
</div>
</div>
)}
{/* [F-7 S155] Thông điệp từ máy chủ — nhất là 409 "không sửa tại chỗ được vì…".
Giữ trên màn (không chỉ toast) để đọc kỹ và làm theo hướng dẫn. */}
{serverError && (
<div className="flex items-start gap-2 rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-[11.5px] leading-relaxed text-rose-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="whitespace-pre-wrap">{serverError}</div>
</div>
)}
<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"
// Mã là khoá của chuỗi phiên bản ⇒ sửa tại chỗ KHÔNG đổi được (BE :521 → 409).
disabled={isEdit}
/>
<div className="text-[11px] text-slate-400">
{isEdit
? 'Không đổi được khi sửa tại chỗ — mã là khoá của chuỗi phiên bản.'
: '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>
{/* S69 — Ngưỡng giá trị gói CEO (nullable). Empty = không áp ngưỡng. */}
<div className="col-span-2 space-y-1.5">
<Label>Ngưỡng giá trị gói CEO (đ)</Label>
<Input
inputMode="numeric"
value={ceoThreshold === '' ? '' : Number(ceoThreshold.replace(/[^\d]/g, '')).toLocaleString('vi-VN')}
onChange={e => setCeoThreshold(e.target.value.replace(/[^\d]/g, ''))}
placeholder="Để trống = luôn theo đủ quy trình"
// 🔴 Sửa tại chỗ KHÔNG đổi được ngưỡng: phiếu đang chờ sẽ đổi nhánh
// cần/không-cần CEO giữa chừng (BE :529 → 409). Khoá ô để người dùng
// không gõ vào rồi mới nhận lỗi. Giá trị gửi lên lấy thẳng từ bản đang lưu.
disabled={isEdit}
/>
<div className="text-[11px] leading-relaxed text-slate-400">
{isEdit
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
</div>
</div>
{/* [F-7 S155] Lý do sửa — BE ghi vào changelog của chính những phiếu đang
chạy trên quy trình này, để người đọc lịch sử phiếu hiểu vì sao quy
trình đổi giữa chừng (ApprovalWorkflowV2AdminFeatures.cs:802-818). */}
{isEdit && (
<div className="col-span-2 space-y-1.5">
<Label> do sửa (ghi vào lịch sử phiếu đang chạy)</Label>
<Textarea
rows={2}
maxLength={500}
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Vd: Bổ sung quyền xóa phiếu cho Trưởng phòng Mua hàng theo yêu cầu ngày 27/07."
/>
</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>
{/* [Mig 58 CCM item 4 — anh Kiệt FDC] Duyệt là KẾT THÚC: NV slot
này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương). */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-emerald-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverFinalize}
onChange={e => updateField('allowApproverFinalize', e.target.checked)}
/>
<span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span>
</label>
{/* [F6 S155 — quyền xóa phiếu đang duyệt] PER-NGƯỜI, KHÔNG
per-Cấp: 1 dòng NV = 1 ô tick riêng. Tick cho NV nào thì
CHỈ NV đó xóa được, và chỉ khi phiếu đang Chờ duyệt ĐÚNG
lượt của họ. Cấp có 3 NV mà tick 1 ⇒ vẫn chỉ 1 người xóa. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-rose-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverDelete}
onChange={e => updateField('allowApproverDelete', e.target.checked)}
/>
<span>Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình</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>
)
}