[CLAUDE] PE: Workflow designer admin UI + Ý kiến 4 phòng ban (P1 Session 5)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m51s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m51s
==== Task 1: PE Workflow Designer admin ====
BE (mirror Contract WorkflowAdminFeatures pattern):
- Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs ~250 LOC:
- GetPeWorkflowAdminOverviewQuery → list 2 EvaluationType (DuyetNcc / DuyetNccPhuongAn) với Active + History versions + count phiếu đang dùng
- CreatePeWorkflowDefinitionCommand + Validator: auto-increment Version per Code, deactivate Active cũ trong cùng EvaluationType (1 active per type invariant)
- DTOs: PeWorkflowStepApproverDto / PeWorkflowStepDto / PeWorkflowDefinitionDto / PeWorkflowTypeSummaryDto / PeWorkflowAdminOverviewDto
- Phase validation 1..7 (state thường, không bao gồm 99=TuChoi)
- Api/Controllers/PeWorkflowsController.cs: 2 endpoint GET /api/pe-workflows + POST. Reuse policy "Workflows.Read" + "Workflows.Create" (admin chung quyền cho cả 2 nhóm WF).
FE:
- pages/system/PeWorkflowsPage.tsx ~500 LOC mirror WorkflowsPage:
- Landing 2-card grid khi /system/pe-workflows (chưa pick type)
- TypePanel khi /system/pe-workflows/:typeCode (DuyetNcc / DuyetNccPhuongAn)
- DefinitionCard read-only view với active badge + version + steps + approvers (Role/User chip)
- PeWorkflowDesigner dialog: clone từ existing, edit Code/Name/Description, add/remove steps, +Role / +User approvers per step, save → version mới + deactivate cũ
- App.tsx route /system/pe-workflows + /system/pe-workflows/:typeCode
- Layout đã có resolver PeWf_<Code> → /system/pe-workflows/<code> từ session 3
==== Task 2: Ý kiến 4 phòng ban PE ====
Domain:
- PurchaseEvaluationDepartmentOpinion entity (AuditableEntity) — PEId + Kind + Opinion text + SignedAt + UserId + UserName denorm
- PeDepartmentKind enum (PheDuyet / Ccm / MuaHang / SmPm)
- PE entity + collection navigation DepartmentOpinions
Infrastructure:
- PurchaseEvaluationDepartmentOpinionConfiguration EF: UNIQUE(PEId, Kind) — max 1 row per phòng ban per phiếu (UPDATE in-place)
- ApplicationDbContext + IApplicationDbContext DbSet
- Migration 15 AddPurchaseEvaluationDepartmentOpinions (15 migration total / 52 DB tables)
Application:
- PeDepartmentOpinionFeatures.cs: UpsertPeDepartmentOpinionCommand (sign=true → set SignedAt+UserId, sign=false chỉ lưu text giữ chữ ký cũ) + DeletePeDepartmentOpinionCommand
- DTO bundle update: + DepartmentOpinions list trong PurchaseEvaluationDetailBundleDto
- GetPurchaseEvaluationQueryHandler load DepartmentOpinions + KindLabel resolution
API:
- POST /api/purchase-evaluations/{id}/opinions (upsert)
- DELETE /api/purchase-evaluations/{id}/opinions/{kind}
FE:
- types/purchaseEvaluation.ts: + PeDepartmentKind enum + PeDepartmentKindLabel + PeDepartmentOpinion type + departmentOpinions vào bundle
- PeDetailTabs Section "5. Ý kiến 4 phòng ban (sign-off)" — 2x2 grid OpinionBox per kind:
- Read mode (readOnly menu Duyệt): hiển thị text + chữ ký
- Edit mode: textarea + 2 button "Lưu text" / "Lưu & Ký"
- Badge "Đã ký" emerald + tên người ký + ngày khi signedAt != null
==== Task 3: User seed verify ====
Seed `SeedDemoUsersAsync` đã match đúng user list authoritative (5 PRO TPB+NV / 7 CCM TPB+NV / 1 ISO / 1 CEO) từ prior commit. DbInitializer reconcile sẽ tự sync khi API restart. Typo trong list user (soluttions / trương) đã fixed sensibly trong seed.
==== Build verify ====
- dotnet build clean (0 error)
- fe-admin TS build pass (1 module mới PeWorkflowsPage)
- fe-user TS build pass (PE detail mirror)
Total: 8 file mới (BE 4 + FE 1 + Migration 2 + 1 Domain) + 13 file modified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -12,6 +12,7 @@ import { CatalogsPage } from '@/pages/master/CatalogsPage'
|
||||
import { PermissionsPage } from '@/pages/system/PermissionsPage'
|
||||
import { RolesPage } from '@/pages/system/RolesPage'
|
||||
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
|
||||
import { PeWorkflowsPage } from '@/pages/system/PeWorkflowsPage'
|
||||
import { FormsPage } from '@/pages/forms/FormsPage'
|
||||
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
|
||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||
@ -47,6 +48,8 @@ function App() {
|
||||
<Route path="/system/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/system/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="/system/workflows/:typeCode" element={<WorkflowsPage />} />
|
||||
<Route path="/system/pe-workflows" element={<PeWorkflowsPage />} />
|
||||
<Route path="/system/pe-workflows/:typeCode" element={<PeWorkflowsPage />} />
|
||||
<Route path="/forms" element={<FormsPage />} />
|
||||
<Route path="/contracts" element={<ContractsListPage />} />
|
||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||
|
||||
@ -18,12 +18,15 @@ import { cn } from '@/lib/cn'
|
||||
import {
|
||||
PeAttachmentPurpose,
|
||||
PeAttachmentPurposeLabel,
|
||||
PeDepartmentKind,
|
||||
PeDepartmentKindLabel,
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeAttachment,
|
||||
type PeChangelog,
|
||||
type PeDepartmentOpinion,
|
||||
type PeDetailBundle,
|
||||
type PeDetailRow,
|
||||
type PeQuote,
|
||||
@ -108,6 +111,9 @@ export function PeDetailTabs({
|
||||
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||||
<DepartmentOpinionsSection ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -122,6 +128,131 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section 5 — Ý kiến 4 phòng ban =====
|
||||
// Render 2x2 grid 4 box (Phê duyệt / CCM / MuaHàng / SM-PM). Mỗi box hiển
|
||||
// thị Opinion text + chữ ký (UserName + SignedAt) nếu đã ký, hoặc form nhập
|
||||
// + 2 button "Lưu" + "Lưu & Ký" khi chưa ký / readOnly=false.
|
||||
function DepartmentOpinionsSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const KINDS: { kind: number; label: string }[] = [
|
||||
{ kind: PeDepartmentKind.PheDuyet, label: PeDepartmentKindLabel[PeDepartmentKind.PheDuyet] },
|
||||
{ kind: PeDepartmentKind.Ccm, label: PeDepartmentKindLabel[PeDepartmentKind.Ccm] },
|
||||
{ kind: PeDepartmentKind.MuaHang, label: PeDepartmentKindLabel[PeDepartmentKind.MuaHang] },
|
||||
{ kind: PeDepartmentKind.SmPm, label: PeDepartmentKindLabel[PeDepartmentKind.SmPm] },
|
||||
]
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{KINDS.map(k => {
|
||||
const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null
|
||||
return (
|
||||
<OpinionBox
|
||||
key={k.kind}
|
||||
evaluationId={ev.id}
|
||||
kind={k.kind}
|
||||
kindLabel={k.label}
|
||||
existing={existing}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpinionBox({
|
||||
evaluationId,
|
||||
kind,
|
||||
kindLabel,
|
||||
existing,
|
||||
readOnly,
|
||||
}: {
|
||||
evaluationId: string
|
||||
kind: number
|
||||
kindLabel: string
|
||||
existing: PeDepartmentOpinion | null
|
||||
readOnly: boolean
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [text, setText] = useState(existing?.opinion ?? '')
|
||||
const isSigned = !!existing?.signedAt
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (sign: boolean) =>
|
||||
api.post(`/purchase-evaluations/${evaluationId}/opinions`, {
|
||||
kind,
|
||||
opinion: text || null,
|
||||
sign,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã lưu ý kiến.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border bg-white p-3',
|
||||
isSigned ? 'border-emerald-200' : 'border-slate-200',
|
||||
)}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-[13px] font-semibold uppercase tracking-wide text-slate-700">{kindLabel}</h4>
|
||||
{isSigned && (
|
||||
<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">
|
||||
<Check className="h-3 w-3" /> Đã ký
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<>
|
||||
<div className="min-h-[60px] whitespace-pre-wrap text-sm text-slate-800">
|
||||
{existing?.opinion ?? <span className="italic text-slate-400">— chưa có ý kiến</span>}
|
||||
</div>
|
||||
{isSigned && (
|
||||
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
|
||||
Ký bởi <strong>{existing?.userName ?? '—'}</strong> · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
placeholder="Nhập ý kiến…"
|
||||
className="w-full resize-none rounded border border-slate-200 px-2 py-1.5 text-sm focus:border-brand-300 focus:outline-none focus:ring-1 focus:ring-brand-200"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] text-slate-500">
|
||||
{isSigned
|
||||
? <>Ký bởi <strong>{existing?.userName ?? '—'}</strong> · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}</>
|
||||
: 'Chưa ký'}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => save.mutate(false)}
|
||||
disabled={save.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
Lưu text
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => save.mutate(true)}
|
||||
disabled={save.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
{isSigned ? 'Cập nhật chữ ký' : 'Lưu & Ký'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||||
|
||||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
||||
|
||||
499
fe-admin/src/pages/system/PeWorkflowsPage.tsx
Normal file
499
fe-admin/src/pages/system/PeWorkflowsPage.tsx
Normal file
@ -0,0 +1,499 @@
|
||||
// PE Workflow admin — mirror WorkflowsPage cho module Duyệt NCC. URL pattern
|
||||
// /system/pe-workflows/:typeCode (DuyetNcc | DuyetNccPhuongAn). Phase enum
|
||||
// khác Contract (1=DangSoanThao..7=DaDuyet, 99=TuChoi).
|
||||
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, 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 (mirror BE PeWorkflowAdminOverviewDto) =====
|
||||
|
||||
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
|
||||
evaluationType: number
|
||||
evaluationTypeLabel: string
|
||||
name: string
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
activatedAt: string | null
|
||||
createdAt: string
|
||||
evaluationsUsingCount: number
|
||||
steps: StepDto[]
|
||||
}
|
||||
type TypeSummaryDto = {
|
||||
evaluationType: number
|
||||
evaluationTypeLabel: string
|
||||
active: DefinitionDto | null
|
||||
history: DefinitionDto[]
|
||||
}
|
||||
|
||||
// PE Phase 1..7 (state thường); 99=TuChoi không là step quy trình.
|
||||
const PHASE_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: 'Đang soạn thảo' },
|
||||
{ value: 2, label: 'Chờ Purchasing' },
|
||||
{ value: 3, label: 'Chờ Dự án' },
|
||||
{ value: 4, label: 'Chờ CCM' },
|
||||
{ value: 5, label: 'Chờ CEO duyệt PA' },
|
||||
{ value: 6, label: 'Chờ CEO duyệt NCC' },
|
||||
{ value: 7, label: 'Đã duyệt' },
|
||||
]
|
||||
|
||||
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 =====
|
||||
|
||||
// Map URL type code → int (mirror PeWf_<Code> menu key)
|
||||
const PE_TYPE_CODE_TO_INT: Record<string, number> = {
|
||||
DuyetNcc: 1,
|
||||
DuyetNccPhuongAn: 2,
|
||||
}
|
||||
|
||||
export function PeWorkflowsPage() {
|
||||
const qc = useQueryClient()
|
||||
const { typeCode } = useParams<{ typeCode?: string }>()
|
||||
const overview = useQuery({
|
||||
queryKey: ['pe-workflow-overview'],
|
||||
queryFn: async () => (await api.get<{ types: TypeSummaryDto[] }>('/pe-workflows')).data,
|
||||
})
|
||||
|
||||
const selectedTypeInt = typeCode ? PE_TYPE_CODE_TO_INT[typeCode] : null
|
||||
const currentType = selectedTypeInt
|
||||
? overview.data?.types.find(t => t.evaluationType === selectedTypeInt)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
{currentType ? `Quy trình: ${currentType.evaluationTypeLabel}` : 'Quy trình duyệt NCC (PE)'}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
currentType
|
||||
? 'Tạo version mới → phiếu PE tương lai dùng. Phiếu đã tạo giữ version cũ (pinned lúc tạo).'
|
||||
: 'Chọn loại Duyệt NCC từ menu bên trái để xem + chỉnh quy trình.'
|
||||
}
|
||||
/>
|
||||
|
||||
{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">
|
||||
{overview.data.types.map(t => (
|
||||
<div key={t.evaluationType} 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.evaluationTypeLabel}</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.history.length} version${t.history.length > 1 ? 's' : ''}`
|
||||
: 'Chưa có quy trình'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentType && <TypePanel type={currentType} onSaved={() => qc.invalidateQueries({ queryKey: ['pe-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 && (
|
||||
<PeWorkflowDesigner
|
||||
evaluationType={type.evaluationType}
|
||||
evaluationTypeLabel={type.evaluationTypeLabel}
|
||||
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.evaluationsUsingCount} phiếu 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 PeWorkflowDesigner({
|
||||
evaluationType,
|
||||
evaluationTypeLabel,
|
||||
cloneFrom,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
evaluationType: number
|
||||
evaluationTypeLabel: string
|
||||
cloneFrom: DefinitionDto | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const initialSteps: EditStep[] = useMemo(
|
||||
() =>
|
||||
cloneFrom
|
||||
? copyFromDefinition(cloneFrom)
|
||||
: [{ phase: 1, name: 'Soạn thảo', slaDays: 3, approvers: [] }],
|
||||
[cloneFrom],
|
||||
)
|
||||
|
||||
const defaultCode = evaluationType === 1 ? 'QT-DN-A' : 'QT-DN-B'
|
||||
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
|
||||
const [name, setName] = useState(cloneFrom ? `${cloneFrom.name} (clone)` : `Quy trình ${evaluationTypeLabel}`)
|
||||
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('/pe-workflows', {
|
||||
evaluationType,
|
||||
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 — ${evaluationTypeLabel}`}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={submit} disabled={save.isPending} form="pe-wf-form">
|
||||
{save.isPending ? 'Đang lưu…' : 'Lưu + kích hoạt'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="pe-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-DN-A, QT-DN-B. 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: 2, name: '', slaDays: 3, 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.
|
||||
Phiếu hiện tại vẫn giữ version cũ (được pin tại thời điểm tạo), chỉ phiếu MỚI đi theo version này.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -175,6 +175,32 @@ export type BudgetSummary = {
|
||||
tongNganSach: number
|
||||
}
|
||||
|
||||
// Mirror BE PeDepartmentKind enum
|
||||
export const PeDepartmentKind = {
|
||||
PheDuyet: 1,
|
||||
Ccm: 2,
|
||||
MuaHang: 3,
|
||||
SmPm: 4,
|
||||
} as const
|
||||
export type PeDepartmentKind = typeof PeDepartmentKind[keyof typeof PeDepartmentKind]
|
||||
|
||||
export const PeDepartmentKindLabel: Record<number, string> = {
|
||||
1: 'Phê duyệt',
|
||||
2: 'P.CCM',
|
||||
3: 'P.Mua hàng',
|
||||
4: 'SM-PM',
|
||||
}
|
||||
|
||||
export type PeDepartmentOpinion = {
|
||||
id: string
|
||||
kind: number
|
||||
kindLabel: string
|
||||
opinion: string | null
|
||||
signedAt: string | null
|
||||
userId: string | null
|
||||
userName: string | null
|
||||
}
|
||||
|
||||
export type PeDetailBundle = {
|
||||
id: string
|
||||
maPhieu: string | null
|
||||
@ -202,5 +228,6 @@ export type PeDetailBundle = {
|
||||
details: PeDetailRow[]
|
||||
approvals: PeApproval[]
|
||||
attachments: PeAttachment[]
|
||||
departmentOpinions: PeDepartmentOpinion[]
|
||||
workflow: PeWorkflowSummary
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user