Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m32s
User yêu cầu: mỗi loại HĐ có quy trình riêng với admin add roles + users vào từng bước. Khi tạo version mới → HĐ tương lai chạy theo, HĐ cũ giữ version cũ. Domain: - WorkflowDefinition (Code + Version + ContractType + IsActive + Steps) - WorkflowStep (Order + Phase + Name + SlaDays + Approvers) - WorkflowStepApprover (Kind: Role/User + AssignmentValue) - Contract.WorkflowDefinitionId — pinned at creation - WorkflowPolicyRegistry.FromDefinition() — build runtime policy từ DB Infrastructure: - EF config + migration AddVersionedWorkflows (3 table mới) - DbInitializer.SeedWorkflowDefinitionsAsync: v01 per 7 ContractType, steps sinh từ hardcoded WorkflowPolicies (Role approvers). - ContractWorkflowService.TransitionAsync: load pinned WorkflowDefinition → FromDefinition(), fallback cho HĐ cũ không có pin. Application: - CreateContractCommand pin WorkflowDefinitionId = active version cho type - ContractFeatures.Get(id): load pinned def cho workflow summary - WorkflowAdminFeatures: GetWorkflowAdminOverviewQuery (7 types + active + history + ContractsUsingCount), CreateWorkflowDefinitionCommand (validate payload, auto-increment version, deactivate old). Api: - GET /api/workflows trả overview - POST /api/workflows tạo version mới (deactivate old) FE /system/workflows: - Tabs per 7 ContractType, mỗi tab hiện active version + lịch sử - DefinitionCard: steps với badge role/user + SLA + archived indicator hiện "N HĐ còn chạy" cho version cũ - WorkflowDesigner modal: form code/name/desc + danh sách steps (phase/name/SLA) + approvers (+ Role hoặc + User). Drop step ok. Clone từ version hiện tại để tạo v02 có điểm start sensible. - Amber banner: HĐ cũ không bị ảnh hưởng khi tạo version mới Invariants được giữ: - Unique (Code, Version) index - Chỉ 1 version IsActive per ContractType tại 1 thời điểm - Set default sẽ auto xóa override → respect legacy override table - Role-kind approvers drive transition guards; User-kind fallback DeptManager role cho v1 (user-level targeting = iteration 2) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
489 lines
19 KiB
TypeScript
489 lines
19 KiB
TypeScript
import { useMemo, useState, type FormEvent } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
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 { AVAILABLE_ROLES, RoleLabel } from '@/types/users'
|
|
|
|
// ===== Types =====
|
|
|
|
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
|
|
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[]
|
|
}
|
|
|
|
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<{ types: TypeSummaryDto[] }>('/workflows')).data,
|
|
})
|
|
|
|
const [activeType, setActiveType] = useState<number | null>(null)
|
|
const tab = activeType ?? overview.data?.types[0]?.contractType ?? 1
|
|
const currentType = overview.data?.types.find(t => t.contractType === tab)
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<PageHeader
|
|
title={
|
|
<span className="flex items-center gap-2">
|
|
<GitBranch className="h-5 w-5" />
|
|
Quy trình duyệt hợp đồng
|
|
</span>
|
|
}
|
|
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ũ."
|
|
/>
|
|
|
|
{/* Tabs */}
|
|
{overview.data && (
|
|
<div className="mb-5 flex gap-1 overflow-x-auto border-b border-slate-200">
|
|
{overview.data.types.map(t => (
|
|
<button
|
|
key={t.contractType}
|
|
onClick={() => setActiveType(t.contractType)}
|
|
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition ${
|
|
tab === t.contractType
|
|
? 'border-brand-600 text-brand-700'
|
|
: 'border-transparent text-slate-500 hover:text-slate-800'
|
|
}`}
|
|
>
|
|
{t.contractTypeLabel}
|
|
{t.active && (
|
|
<span className="ml-2 rounded bg-brand-50 px-1.5 py-0.5 font-mono text-[10px] text-brand-700">
|
|
v{String(t.active.version).padStart(2, '0')}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
|
|
|
{currentType && <TypePanel type={currentType} onSaved={() => qc.invalidateQueries({ queryKey: ['workflow-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)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{type.active ? (
|
|
<DefinitionCard def={type.active} isActive onClone={d => { setCloneFrom(d); setDesignerOpen(true) }} />
|
|
) : (
|
|
<div className="rounded-xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">
|
|
Chưa có quy trình cho loại này. Tạo version đầu tiên bên dưới.
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-slate-700">Lịch sử versions</h3>
|
|
<Button onClick={() => { setCloneFrom(type.active); setDesignerOpen(true) }}>
|
|
<Plus className="h-4 w-4" />
|
|
Tạo quy trình mới
|
|
</Button>
|
|
</div>
|
|
|
|
{type.history.filter(d => !d.isActive).length === 0 && (
|
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-4 text-xs text-slate-500">
|
|
Chưa có version cũ. Khi tạo version mới, version hiện tại tự động archive.
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
{type.history
|
|
.filter(d => !d.isActive)
|
|
.map(d => (
|
|
<DefinitionCard key={d.id} def={d} isActive={false} onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }} />
|
|
))}
|
|
</div>
|
|
|
|
{designerOpen && (
|
|
<WorkflowDesigner
|
|
contractType={type.contractType}
|
|
contractTypeLabel={type.contractTypeLabel}
|
|
cloneFrom={cloneFrom}
|
|
onClose={() => { setDesignerOpen(false); setCloneFrom(null) }}
|
|
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ===== Definition card (read-only view) =====
|
|
|
|
function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActive: boolean; onClone: (d: DefinitionDto) => 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 · {def.contractsUsingCount} HĐ còn chạy
|
|
</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="flex items-start gap-3 rounded-lg border border-slate-100 bg-slate-50/30 p-2.5">
|
|
<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>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="font-medium text-slate-800">{s.name}</span>
|
|
<span className="text-[11px] text-slate-400">({s.phaseLabel})</span>
|
|
{s.slaDays != null && (
|
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
|
SLA {s.slaDays}d
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{s.approvers.length === 0 && (
|
|
<span className="text-[11px] italic text-slate-400">Chưa có người duyệt</span>
|
|
)}
|
|
{s.approvers.map((a, i) => (
|
|
<span
|
|
key={i}
|
|
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
|
a.kind === 1 ? 'bg-brand-50 text-brand-700' : 'bg-violet-50 text-violet-700'
|
|
}`}
|
|
>
|
|
{a.kind === 1 ? 'Role' : 'User'}: {a.displayName ?? a.assignmentValue}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => onClone(def)}>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Tạo từ bản này
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ===== 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<EditStep[]>(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 (
|
|
<Dialog
|
|
open
|
|
onClose={onClose}
|
|
title={`Tạo quy trình mới — ${contractTypeLabel}`}
|
|
size="xl"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={onClose}>Hủy</Button>
|
|
<Button onClick={submit} disabled={save.isPending} form="wf-form">
|
|
{save.isPending ? 'Đang lưu…' : 'Lưu + kích hoạt'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<form id="wf-form" onSubmit={submit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label>Mã quy trình *</Label>
|
|
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
|
|
<div className="text-[11px] text-slate-400">Ví dụ QT-TP, QT-MB. Version auto-tăng mỗi lần lưu.</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label>Tên hiển thị *</Label>
|
|
<Input value={name} onChange={e => setName(e.target.value)} required />
|
|
</div>
|
|
<div className="col-span-2 space-y-1.5">
|
|
<Label>Mô tả</Label>
|
|
<Textarea rows={2} value={description} onChange={e => setDescription(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Các bước ({steps.length})</Label>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
setSteps([...steps, { phase: 3, name: '', slaDays: 7, approvers: [] }])
|
|
}
|
|
>
|
|
<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]">Phase</Label>
|
|
<Select
|
|
value={s.phase}
|
|
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, phase: Number(e.target.value) } : x)))}
|
|
>
|
|
{PHASE_OPTIONS.map(p => (
|
|
<option key={p.value} value={p.value}>{p.label}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<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)))} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">SLA (ngày)</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={s.slaDays ?? ''}
|
|
onChange={e =>
|
|
setSteps(steps.map((x, i) => (i === idx ? { ...x, slaDays: e.target.value ? Number(e.target.value) : null } : x)))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSteps(steps.filter((_, i) => i !== idx))}
|
|
className="flex h-7 w-7 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 className="mt-2 border-t border-slate-200 pt-2">
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<Label className="text-[11px]">Người duyệt</Label>
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 1, assignmentValue: AVAILABLE_ROLES[1] }] } : x)))}
|
|
className="rounded bg-brand-50 px-2 py-0.5 text-[11px] font-medium text-brand-700 hover:bg-brand-100"
|
|
>
|
|
+ Role
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSteps(steps.map((x, i) => (i === idx ? { ...x, approvers: [...x.approvers, { kind: 2, assignmentValue: usersList.data?.[0]?.id ?? '' }] } : x)))}
|
|
className="rounded bg-violet-50 px-2 py-0.5 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
|
>
|
|
+ User
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{s.approvers.length === 0 && (
|
|
<div className="rounded bg-slate-100 px-2 py-1.5 text-[11px] italic text-slate-500">
|
|
Chưa có người duyệt — tối thiểu nên có 1 Role hoặc 1 User.
|
|
</div>
|
|
)}
|
|
<div className="space-y-1">
|
|
{s.approvers.map((a, ai) => (
|
|
<div key={ai} className="flex items-center gap-1">
|
|
{a.kind === 1 ? (
|
|
<Select
|
|
value={a.assignmentValue}
|
|
onChange={e =>
|
|
setSteps(steps.map((x, i) =>
|
|
i === idx ? { ...x, approvers: x.approvers.map((y, j) => (j === ai ? { ...y, assignmentValue: e.target.value } : y)) } : x,
|
|
))
|
|
}
|
|
className="h-7 flex-1 text-xs"
|
|
>
|
|
{AVAILABLE_ROLES.map(r => (
|
|
<option key={r} value={r}>Role: {RoleLabel[r] ?? r}</option>
|
|
))}
|
|
</Select>
|
|
) : (
|
|
<Select
|
|
value={a.assignmentValue}
|
|
onChange={e =>
|
|
setSteps(steps.map((x, i) =>
|
|
i === idx ? { ...x, approvers: x.approvers.map((y, j) => (j === ai ? { ...y, assignmentValue: e.target.value } : y)) } : x,
|
|
))
|
|
}
|
|
className="h-7 flex-1 text-xs"
|
|
>
|
|
{usersList.data?.map(u => (
|
|
<option key={u.id} value={u.id}>User: {u.fullName} ({u.email})</option>
|
|
))}
|
|
</Select>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setSteps(steps.map((x, i) =>
|
|
i === idx ? { ...x, approvers: x.approvers.filter((_, j) => j !== ai) } : x,
|
|
))
|
|
}
|
|
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
|
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<div>
|
|
Khi lưu: version mới tự động tăng từ <code className="font-mono">{code}</code>, thành version đang áp dụng.
|
|
HĐ hiện tại vẫn giữ version cũ (được pin tại thời điểm tạo), chỉ HĐ MỚI đi theo version này.
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
)
|
|
}
|