diff --git a/fe-admin/src/pages/system/WorkflowsPage.tsx b/fe-admin/src/pages/system/WorkflowsPage.tsx index 21cd41f..73fcda5 100644 --- a/fe-admin/src/pages/system/WorkflowsPage.tsx +++ b/fe-admin/src/pages/system/WorkflowsPage.tsx @@ -1,53 +1,78 @@ +import { useMemo, useState, type FormEvent } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { GitBranch, Info } from 'lucide-react' +import { GitBranch, Plus, Trash2, CheckCircle2, Info, History } 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 { ContractPhaseLabel } from '@/types/contracts' +import { AVAILABLE_ROLES, RoleLabel } from '@/types/users' -type WorkflowPolicyDto = { - name: string - description: string - activePhases: number[] -} +// ===== Types ===== -type WorkflowTypeAssignmentDto = { +type ApproverDto = { kind: number; assignmentValue: string; displayName: string | null } +type StepDto = { id: string; order: number; phase: number; phaseLabel: string; name: string; slaDays: number | null; approvers: ApproverDto[] } +type DefinitionDto = { + id: string + code: string + version: number contractType: number contractTypeLabel: string - currentPolicy: string - defaultPolicy: string - policy: WorkflowPolicyDto + name: string + description: string | null + isActive: boolean + activatedAt: string | null + createdAt: string + contractsUsingCount: number + steps: StepDto[] +} +type TypeSummaryDto = { + contractType: number + contractTypeLabel: string + active: DefinitionDto | null + history: DefinitionDto[] } -type WorkflowAdminOverviewDto = { - availablePolicies: WorkflowPolicyDto[] - assignments: WorkflowTypeAssignmentDto[] +const PHASE_OPTIONS: { value: number; label: string }[] = [ + { value: 2, label: 'Đang soạn thảo' }, + { value: 3, label: 'Đang góp ý' }, + { value: 4, label: 'Đang đàm phán' }, + { value: 5, label: 'Đang in ký' }, + { value: 6, label: 'CCM kiểm tra' }, + { value: 7, label: 'Đang trình ký' }, + { value: 8, label: 'Đang đóng dấu' }, + { value: 9, label: 'Đã phát hành' }, +] + +type EditStepApprover = { kind: 1 | 2; assignmentValue: string } +type EditStep = { phase: number; name: string; slaDays: number | null; approvers: EditStepApprover[] } + +function copyFromDefinition(d: DefinitionDto): EditStep[] { + return d.steps.map(s => ({ + phase: s.phase, + name: s.name, + slaDays: s.slaDays, + approvers: s.approvers.map(a => ({ kind: a.kind as 1 | 2, assignmentValue: a.assignmentValue })), + })) } +// ===== Page ===== + export function WorkflowsPage() { const qc = useQueryClient() - const overview = useQuery({ queryKey: ['workflow-overview'], - queryFn: async () => (await api.get('/workflows')).data, + queryFn: async () => (await api.get<{ types: TypeSummaryDto[] }>('/workflows')).data, }) - const update = useMutation({ - mutationFn: async ({ contractType, policyName }: { contractType: number; policyName: string }) => { - await api.put(`/workflows/${contractType}`, { policyName }) - }, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['workflow-overview'] }) - // Invalidate contract details too — FE gets fresh policy next time user opens - qc.invalidateQueries({ queryKey: ['contract'] }) - toast.success('Đã cập nhật quy trình') - }, - onError: err => toast.error(getErrorMessage(err)), - }) - - const data = overview.data + const [activeType, setActiveType] = useState(null) + const tab = activeType ?? overview.data?.types[0]?.contractType ?? 1 + const currentType = overview.data?.types.find(t => t.contractType === tab) return (
@@ -58,81 +83,406 @@ export function WorkflowsPage() { Quy trình duyệt hợp đồng } - description="Cấu hình quy trình duyệt cho từng loại HĐ. Mỗi loại có thể chọn 1 policy khác nhau." + description="Mỗi loại HĐ có quy trình riêng, hỗ trợ versioning. Tạo version mới → HĐ tương lai chạy theo. HĐ cũ vẫn giữ quy trình cũ." /> -
- -
- Standard: quy trình đầy đủ 8 phase có CCM review — áp dụng cho HĐ Thầu phụ/Giao khoán/NCC. - {' · '} - SkipCcm: bỏ phase CCM, đi thẳng từ 'Đang in ký' → 'Đang trình ký' — áp dụng HĐ Dịch vụ/Mua bán/Nguyên tắc. - {' · '} - Đặt về policy mặc định = xóa override, registry dùng logic hardcoded. -
-
- - {overview.isLoading &&
Đang tải…
} - - {data && ( -
- {data.assignments.map(a => { - const isOverridden = a.currentPolicy !== a.defaultPolicy - return ( -
-
-
-
-

{a.contractTypeLabel}

- {isOverridden && ( - - Đã override - - )} -
-

{a.policy.description}

- -
- Các phase: - {a.policy.activePhases - .filter(p => p !== 99) // hide TuChoi in timeline — it's a terminal error path - .map((p, idx, arr) => ( - - - {ContractPhaseLabel[p] ?? p} - - {idx < arr.length - 1 && } - - ))} -
-
-
- - -
-
-
- ) - })} + {/* Tabs */} + {overview.data && ( +
+ {overview.data.types.map(t => ( + + ))}
)} -
- Iteration 2: cho phép tạo policy custom (phase sequence + SLA + role-per-phase) thay vì chọn - từ 2 policy pre-built. Data model đã hỗ trợ — chỉ cần thêm UI builder. + {overview.isLoading &&
Đang tải…
} + + {currentType && qc.invalidateQueries({ queryKey: ['workflow-overview'] })} />} +
+ ) +} + +// ===== Per-type panel ===== + +function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) { + const [designerOpen, setDesignerOpen] = useState(false) + const [cloneFrom, setCloneFrom] = useState(null) + + return ( +
+ {type.active ? ( + { setCloneFrom(d); setDesignerOpen(true) }} /> + ) : ( +
+ Chưa có quy trình cho loại này. Tạo version đầu tiên bên dưới. +
+ )} + +
+

Lịch sử versions

+ +
+ + {type.history.filter(d => !d.isActive).length === 0 && ( +
+ Chưa có version cũ. Khi tạo version mới, version hiện tại tự động archive. +
+ )} + +
+ {type.history + .filter(d => !d.isActive) + .map(d => ( + { setCloneFrom(dd); setDesignerOpen(true) }} /> + ))} +
+ + {designerOpen && ( + { setDesignerOpen(false); setCloneFrom(null) }} + onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }} + /> + )} +
+ ) +} + +// ===== Definition card (read-only view) ===== + +function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActive: boolean; onClone: (d: DefinitionDto) => void }) { + return ( +
+
+
+
+

+ {def.name} +

+ + {def.code} v{String(def.version).padStart(2, '0')} + + {isActive ? ( + + + Đang áp dụng + + ) : ( + + + Archived · {def.contractsUsingCount} HĐ còn chạy + + )} +
+ {def.description &&

{def.description}

} + +
    + {def.steps.map(s => ( +
  1. +
    + {s.order} +
    +
    +
    + {s.name} + ({s.phaseLabel}) + {s.slaDays != null && ( + + SLA {s.slaDays}d + + )} +
    +
    + {s.approvers.length === 0 && ( + Chưa có người duyệt + )} + {s.approvers.map((a, i) => ( + + {a.kind === 1 ? 'Role' : 'User'}: {a.displayName ?? a.assignmentValue} + + ))} +
    +
    +
  2. + ))} +
+
+
) } + +// ===== Designer dialog ===== + +function WorkflowDesigner({ + contractType, + contractTypeLabel, + cloneFrom, + onClose, + onSaved, +}: { + contractType: number + contractTypeLabel: string + cloneFrom: DefinitionDto | null + onClose: () => void + onSaved: () => void +}) { + const initialSteps: EditStep[] = useMemo( + () => + cloneFrom + ? copyFromDefinition(cloneFrom) + : [{ phase: 2, name: 'Soạn thảo', slaDays: 7, approvers: [] }], + [cloneFrom], + ) + + const [code, setCode] = useState(cloneFrom?.code ?? 'QT-NEW') + const [name, setName] = useState(cloneFrom ? `${cloneFrom.name} (clone)` : `Quy trình ${contractTypeLabel}`) + const [description, setDescription] = useState(cloneFrom?.description ?? '') + const [steps, setSteps] = useState(initialSteps) + + const usersList = useQuery({ + queryKey: ['users-for-approver'], + queryFn: async () => + (await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const save = useMutation({ + mutationFn: async () => { + await api.post('/workflows', { + contractType, + code, + name, + description: description || null, + steps: steps.map((s, i) => ({ + order: i + 1, + phase: s.phase, + name: s.name, + slaDays: s.slaDays, + approvers: s.approvers, + })), + }) + }, + onSuccess: () => { + toast.success('Đã lưu quy trình mới. Version cũ đã archive.') + onSaved() + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + function submit(e: FormEvent) { + e.preventDefault() + if (steps.length === 0) { + toast.error('Phải có ít nhất 1 bước') + return + } + save.mutate() + } + + return ( + + + + + } + > +
+
+
+ + setCode(e.target.value)} required className="font-mono" /> +
Ví dụ QT-TP, QT-MB. Version auto-tăng mỗi lần lưu.
+
+
+ + setName(e.target.value)} required /> +
+
+ +