All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s
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>
1311 lines
66 KiB
TypeScript
1311 lines
66 KiB
TypeScript
// 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 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={() => { 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 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 => { 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ó 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 là 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">có 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 Mã / Ngưỡng CEO / cờ "Duyệt là KẾT THÚC" / "Duyệt thẳng Cấp cuối" ⇒ phải bấm <span className="font-semibold">"Tạo phiên bản mới"</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 cũ</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 và bấm <span className="font-semibold">"Sửa tại chỗ"</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>Mã 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>Mô 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>Lý 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 có 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 là 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 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ự có
|
||
(1 / 2 / 3 cấp đều OK). Trong cùng 1 Cấp có thể có nhiều NV — chỉ cần 1 NV duyệt là 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>
|
||
)
|
||
}
|