[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 { PermissionsPage } from '@/pages/system/PermissionsPage'
|
||||||
import { RolesPage } from '@/pages/system/RolesPage'
|
import { RolesPage } from '@/pages/system/RolesPage'
|
||||||
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
|
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
|
||||||
|
import { PeWorkflowsPage } from '@/pages/system/PeWorkflowsPage'
|
||||||
import { FormsPage } from '@/pages/forms/FormsPage'
|
import { FormsPage } from '@/pages/forms/FormsPage'
|
||||||
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
|
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
|
||||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||||
@ -47,6 +48,8 @@ function App() {
|
|||||||
<Route path="/system/permissions" element={<PermissionsPage />} />
|
<Route path="/system/permissions" element={<PermissionsPage />} />
|
||||||
<Route path="/system/workflows" element={<WorkflowsPage />} />
|
<Route path="/system/workflows" element={<WorkflowsPage />} />
|
||||||
<Route path="/system/workflows/:typeCode" 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="/forms" element={<FormsPage />} />
|
||||||
<Route path="/contracts" element={<ContractsListPage />} />
|
<Route path="/contracts" element={<ContractsListPage />} />
|
||||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||||
|
|||||||
@ -18,12 +18,15 @@ import { cn } from '@/lib/cn'
|
|||||||
import {
|
import {
|
||||||
PeAttachmentPurpose,
|
PeAttachmentPurpose,
|
||||||
PeAttachmentPurposeLabel,
|
PeAttachmentPurposeLabel,
|
||||||
|
PeDepartmentKind,
|
||||||
|
PeDepartmentKindLabel,
|
||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
PurchaseEvaluationTypeLabel,
|
PurchaseEvaluationTypeLabel,
|
||||||
type PeAttachment,
|
type PeAttachment,
|
||||||
type PeChangelog,
|
type PeChangelog,
|
||||||
|
type PeDepartmentOpinion,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
type PeDetailRow,
|
type PeDetailRow,
|
||||||
type PeQuote,
|
type PeQuote,
|
||||||
@ -108,6 +111,9 @@ export function PeDetailTabs({
|
|||||||
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
||||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
|
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||||||
|
<DepartmentOpinionsSection ev={evaluation} readOnly={readOnly} />
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</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 =====
|
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||||||
|
|
||||||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
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
|
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 = {
|
export type PeDetailBundle = {
|
||||||
id: string
|
id: string
|
||||||
maPhieu: string | null
|
maPhieu: string | null
|
||||||
@ -202,5 +228,6 @@ export type PeDetailBundle = {
|
|||||||
details: PeDetailRow[]
|
details: PeDetailRow[]
|
||||||
approvals: PeApproval[]
|
approvals: PeApproval[]
|
||||||
attachments: PeAttachment[]
|
attachments: PeAttachment[]
|
||||||
|
departmentOpinions: PeDepartmentOpinion[]
|
||||||
workflow: PeWorkflowSummary
|
workflow: PeWorkflowSummary
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,12 +18,15 @@ import { cn } from '@/lib/cn'
|
|||||||
import {
|
import {
|
||||||
PeAttachmentPurpose,
|
PeAttachmentPurpose,
|
||||||
PeAttachmentPurposeLabel,
|
PeAttachmentPurposeLabel,
|
||||||
|
PeDepartmentKind,
|
||||||
|
PeDepartmentKindLabel,
|
||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
PurchaseEvaluationTypeLabel,
|
PurchaseEvaluationTypeLabel,
|
||||||
type PeAttachment,
|
type PeAttachment,
|
||||||
type PeChangelog,
|
type PeChangelog,
|
||||||
|
type PeDepartmentOpinion,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
type PeDetailRow,
|
type PeDetailRow,
|
||||||
type PeQuote,
|
type PeQuote,
|
||||||
@ -108,6 +111,9 @@ export function PeDetailTabs({
|
|||||||
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
||||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
|
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||||||
|
<DepartmentOpinionsSection ev={evaluation} readOnly={readOnly} />
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</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 =====
|
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||||||
|
|
||||||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
||||||
|
|||||||
@ -175,6 +175,32 @@ export type BudgetSummary = {
|
|||||||
tongNganSach: number
|
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 = {
|
export type PeDetailBundle = {
|
||||||
id: string
|
id: string
|
||||||
maPhieu: string | null
|
maPhieu: string | null
|
||||||
@ -202,5 +228,6 @@ export type PeDetailBundle = {
|
|||||||
details: PeDetailRow[]
|
details: PeDetailRow[]
|
||||||
approvals: PeApproval[]
|
approvals: PeApproval[]
|
||||||
attachments: PeAttachment[]
|
attachments: PeAttachment[]
|
||||||
|
departmentOpinions: PeDepartmentOpinion[]
|
||||||
workflow: PeWorkflowSummary
|
workflow: PeWorkflowSummary
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
// Versioned workflow admin cho module Duyệt NCC (PE). Reuse policy
|
||||||
|
// "Workflows.Read" + "Workflows.Create" giống Contract — admin có quyền
|
||||||
|
// quản lý cả 2 nhóm workflow (HĐ + PE).
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/pe-workflows")]
|
||||||
|
[Authorize(Policy = "Workflows.Read")]
|
||||||
|
public class PeWorkflowsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PeWorkflowAdminOverviewDto>> Overview(CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetPeWorkflowAdminOverviewQuery(), ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = "Workflows.Create")]
|
||||||
|
public async Task<ActionResult<object>> Create([FromBody] CreatePeWorkflowDefinitionCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = await mediator.Send(cmd, ct);
|
||||||
|
return Ok(new { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -201,8 +201,29 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
|||||||
id, body.ContractType, body.TenHopDong, body.BypassProcurementAndCCM), ct);
|
id, body.ContractType, body.TenHopDong, body.BypassProcurementAndCCM), ct);
|
||||||
return Ok(new { contractId });
|
return Ok(new { contractId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Ý kiến 4 phòng ban ==========
|
||||||
|
|
||||||
|
// Upsert opinion (Add nếu chưa có, Update text + optional sign).
|
||||||
|
[HttpPost("{id:guid}/opinions")]
|
||||||
|
public async Task<ActionResult<object>> UpsertOpinion(
|
||||||
|
Guid id, [FromBody] OpinionBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resultId = await mediator.Send(new UpsertPeDepartmentOpinionCommand(
|
||||||
|
id, body.Kind, body.Opinion, body.Sign), ct);
|
||||||
|
return Ok(new { id = resultId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/opinions/{kind}")]
|
||||||
|
public async Task<IActionResult> DeleteOpinion(Guid id, PeDepartmentKind kind, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeletePeDepartmentOpinionCommand(id, kind), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record OpinionBody(PeDepartmentKind Kind, string? Opinion, bool Sign);
|
||||||
|
|
||||||
public record CreateContractFromEvaluationBody(
|
public record CreateContractFromEvaluationBody(
|
||||||
Domain.Contracts.ContractType ContractType,
|
Domain.Contracts.ContractType ContractType,
|
||||||
string? TenHopDong,
|
string? TenHopDong,
|
||||||
|
|||||||
@ -58,6 +58,7 @@ public interface IApplicationDbContext
|
|||||||
DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps { get; }
|
DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps { get; }
|
||||||
DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers { get; }
|
DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers { get; }
|
||||||
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
|
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
|
||||||
|
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
|
||||||
|
|
||||||
// Module Ngân sách (Phase 7)
|
// Module Ngân sách (Phase 7)
|
||||||
DbSet<Budget> Budgets { get; }
|
DbSet<Budget> Budgets { get; }
|
||||||
|
|||||||
@ -95,6 +95,15 @@ public record PurchaseEvaluationAttachmentDto(
|
|||||||
string? Note,
|
string? Note,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationDepartmentOpinionDto(
|
||||||
|
Guid Id,
|
||||||
|
PeDepartmentKind Kind,
|
||||||
|
string KindLabel,
|
||||||
|
string? Opinion,
|
||||||
|
DateTime? SignedAt,
|
||||||
|
Guid? UserId,
|
||||||
|
string? UserName);
|
||||||
|
|
||||||
public record PurchaseEvaluationDetailBundleDto(
|
public record PurchaseEvaluationDetailBundleDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string? MaPhieu,
|
string? MaPhieu,
|
||||||
@ -122,4 +131,5 @@ public record PurchaseEvaluationDetailBundleDto(
|
|||||||
List<PurchaseEvaluationDetailDto> Details,
|
List<PurchaseEvaluationDetailDto> Details,
|
||||||
List<PurchaseEvaluationApprovalDto> Approvals,
|
List<PurchaseEvaluationApprovalDto> Approvals,
|
||||||
List<PurchaseEvaluationAttachmentDto> Attachments,
|
List<PurchaseEvaluationAttachmentDto> Attachments,
|
||||||
|
List<PurchaseEvaluationDepartmentOpinionDto> DepartmentOpinions,
|
||||||
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
||||||
|
|||||||
@ -0,0 +1,152 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Contracts; // ChangelogAction
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
|
// Ý kiến 4 phòng ban (Phê duyệt / CCM / MuaHàng / SM-PM) trên PHIẾU TRÌNH KÝ.
|
||||||
|
// Upsert pattern — UPDATE in-place khi user đổi ý (không version), audit qua
|
||||||
|
// PurchaseEvaluationChangelog. UNIQUE index (PEId, Kind) bảo vệ tối đa 1 row
|
||||||
|
// mỗi loại phòng ban per phiếu.
|
||||||
|
|
||||||
|
// ========== UPSERT (Add nếu chưa có, Update nếu rồi) ==========
|
||||||
|
|
||||||
|
public record UpsertPeDepartmentOpinionCommand(
|
||||||
|
Guid PurchaseEvaluationId,
|
||||||
|
PeDepartmentKind Kind,
|
||||||
|
string? Opinion,
|
||||||
|
bool Sign) : IRequest<Guid>;
|
||||||
|
// Sign=true → set SignedAt + UserId hiện tại (đóng dấu xác nhận).
|
||||||
|
// Sign=false → chỉ lưu text Opinion (chưa ký).
|
||||||
|
|
||||||
|
public class UpsertPeDepartmentOpinionCommandValidator : AbstractValidator<UpsertPeDepartmentOpinionCommand>
|
||||||
|
{
|
||||||
|
public UpsertPeDepartmentOpinionCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
|
||||||
|
RuleFor(x => x.Kind).IsInEnum();
|
||||||
|
RuleFor(x => x.Opinion).MaximumLength(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpsertPeDepartmentOpinionCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
UserManager<User> userManager) : IRequestHandler<UpsertPeDepartmentOpinionCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(UpsertPeDepartmentOpinionCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(p => p.Id == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||||
|
|
||||||
|
var existing = await db.PurchaseEvaluationDepartmentOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.PurchaseEvaluationId == pe.Id && o.Kind == request.Kind, ct);
|
||||||
|
|
||||||
|
string? actorName = null;
|
||||||
|
if (request.Sign)
|
||||||
|
{
|
||||||
|
var u = await userManager.FindByIdAsync(currentUser.UserId.Value.ToString());
|
||||||
|
actorName = u?.FullName ?? u?.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid resultId;
|
||||||
|
ChangelogAction action;
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
var entity = new PurchaseEvaluationDepartmentOpinion
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = pe.Id,
|
||||||
|
Kind = request.Kind,
|
||||||
|
Opinion = request.Opinion,
|
||||||
|
SignedAt = request.Sign ? DateTime.UtcNow : null,
|
||||||
|
UserId = request.Sign ? currentUser.UserId : null,
|
||||||
|
UserName = request.Sign ? actorName : null,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationDepartmentOpinions.Add(entity);
|
||||||
|
resultId = entity.Id;
|
||||||
|
action = ChangelogAction.Insert;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Opinion = request.Opinion;
|
||||||
|
if (request.Sign)
|
||||||
|
{
|
||||||
|
existing.SignedAt = DateTime.UtcNow;
|
||||||
|
existing.UserId = currentUser.UserId;
|
||||||
|
existing.UserName = actorName;
|
||||||
|
}
|
||||||
|
// Sign=false giữ nguyên SignedAt/UserId cũ (user đã ký rồi vẫn giữ chữ ký).
|
||||||
|
resultId = existing.Id;
|
||||||
|
action = ChangelogAction.Update;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = pe.Id,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Header, // không có entity type riêng cho opinion
|
||||||
|
EntityId = resultId,
|
||||||
|
Action = action,
|
||||||
|
PhaseAtChange = pe.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
UserName = actorName,
|
||||||
|
Summary = request.Sign
|
||||||
|
? $"Ý kiến {KindLabel(request.Kind)} — đã ký"
|
||||||
|
: $"Ý kiến {KindLabel(request.Kind)} — cập nhật text",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return resultId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string KindLabel(PeDepartmentKind k) => k switch
|
||||||
|
{
|
||||||
|
PeDepartmentKind.PheDuyet => "Phê duyệt",
|
||||||
|
PeDepartmentKind.Ccm => "P.CCM",
|
||||||
|
PeDepartmentKind.MuaHang => "P.Mua hàng",
|
||||||
|
PeDepartmentKind.SmPm => "SM-PM",
|
||||||
|
_ => k.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DELETE (rare — admin override) ==========
|
||||||
|
|
||||||
|
public record DeletePeDepartmentOpinionCommand(Guid PurchaseEvaluationId, PeDepartmentKind Kind) : IRequest;
|
||||||
|
|
||||||
|
public class DeletePeDepartmentOpinionCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<DeletePeDepartmentOpinionCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(DeletePeDepartmentOpinionCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.PurchaseEvaluationDepartmentOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.PurchaseEvaluationId == request.PurchaseEvaluationId && o.Kind == request.Kind, ct)
|
||||||
|
?? throw new NotFoundException("PEDepartmentOpinion", request.Kind);
|
||||||
|
|
||||||
|
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(p => p.Id == request.PurchaseEvaluationId, ct);
|
||||||
|
db.PurchaseEvaluationDepartmentOpinions.Remove(entity);
|
||||||
|
|
||||||
|
if (pe is not null)
|
||||||
|
{
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = pe.Id,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Header,
|
||||||
|
Action = ChangelogAction.Delete,
|
||||||
|
PhaseAtChange = pe.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Xóa ý kiến phòng ban ({request.Kind})",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,247 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Contracts; // WorkflowApproverKind reuse
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
|
// Versioned workflow management cho module Duyệt NCC (PE) — mirror Contract
|
||||||
|
// `WorkflowAdminFeatures` pattern. Phiếu PE đã pin WorkflowDefinitionId tại
|
||||||
|
// thời điểm tạo → vẫn chạy version cũ kể cả khi admin activate version mới.
|
||||||
|
|
||||||
|
public record PeWorkflowStepApproverDto(
|
||||||
|
int Kind, // 1=Role, 2=User (reuse WorkflowApproverKind)
|
||||||
|
string AssignmentValue,
|
||||||
|
string? DisplayName);
|
||||||
|
|
||||||
|
public record PeWorkflowStepDto(
|
||||||
|
Guid Id,
|
||||||
|
int Order,
|
||||||
|
int Phase,
|
||||||
|
string PhaseLabel,
|
||||||
|
string Name,
|
||||||
|
int? SlaDays,
|
||||||
|
List<PeWorkflowStepApproverDto> Approvers);
|
||||||
|
|
||||||
|
public record PeWorkflowDefinitionDto(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
int Version,
|
||||||
|
int EvaluationType,
|
||||||
|
string EvaluationTypeLabel,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime? ActivatedAt,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
int EvaluationsUsingCount,
|
||||||
|
List<PeWorkflowStepDto> Steps);
|
||||||
|
|
||||||
|
public record PeWorkflowTypeSummaryDto(
|
||||||
|
int EvaluationType,
|
||||||
|
string EvaluationTypeLabel,
|
||||||
|
PeWorkflowDefinitionDto? Active,
|
||||||
|
List<PeWorkflowDefinitionDto> History);
|
||||||
|
|
||||||
|
public record PeWorkflowAdminOverviewDto(List<PeWorkflowTypeSummaryDto> Types);
|
||||||
|
|
||||||
|
// ========== GET overview ==========
|
||||||
|
|
||||||
|
public record GetPeWorkflowAdminOverviewQuery : IRequest<PeWorkflowAdminOverviewDto>;
|
||||||
|
|
||||||
|
public class GetPeWorkflowAdminOverviewQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
UserManager<User> userManager) : IRequestHandler<GetPeWorkflowAdminOverviewQuery, PeWorkflowAdminOverviewDto>
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<PurchaseEvaluationType, string> TypeLabels = new()
|
||||||
|
{
|
||||||
|
[PurchaseEvaluationType.DuyetNcc] = "Duyệt NCC",
|
||||||
|
[PurchaseEvaluationType.DuyetNccPhuongAn] = "Duyệt NCC + Giải pháp",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<PurchaseEvaluationPhase, string> PhaseLabels = new()
|
||||||
|
{
|
||||||
|
[PurchaseEvaluationPhase.DangSoanThao] = "Đang soạn thảo",
|
||||||
|
[PurchaseEvaluationPhase.ChoPurchasing] = "Chờ Purchasing",
|
||||||
|
[PurchaseEvaluationPhase.ChoDuAn] = "Chờ Dự án",
|
||||||
|
[PurchaseEvaluationPhase.ChoCCM] = "Chờ CCM",
|
||||||
|
[PurchaseEvaluationPhase.ChoCEODuyetPA] = "Chờ CEO duyệt PA",
|
||||||
|
[PurchaseEvaluationPhase.ChoCEODuyetNCC] = "Chờ CEO duyệt NCC",
|
||||||
|
[PurchaseEvaluationPhase.DaDuyet] = "Đã duyệt",
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<PeWorkflowAdminOverviewDto> Handle(GetPeWorkflowAdminOverviewQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var definitions = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
|
||||||
|
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||||
|
.ThenInclude(s => s.Approvers)
|
||||||
|
.OrderByDescending(d => d.Version)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Resolve user names cho User-kind approvers
|
||||||
|
var userIds = definitions
|
||||||
|
.SelectMany(d => d.Steps)
|
||||||
|
.SelectMany(s => s.Approvers)
|
||||||
|
.Where(a => a.Kind == WorkflowApproverKind.User && Guid.TryParse(a.AssignmentValue, out _))
|
||||||
|
.Select(a => Guid.Parse(a.AssignmentValue))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var userNames = userIds.Count == 0
|
||||||
|
? new Dictionary<Guid, string>()
|
||||||
|
: await userManager.Users.AsNoTracking()
|
||||||
|
.Where(u => userIds.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
|
||||||
|
|
||||||
|
// Count phiếu PE per definition
|
||||||
|
var usageCounts = await db.PurchaseEvaluations.AsNoTracking()
|
||||||
|
.Where(p => p.WorkflowDefinitionId != null)
|
||||||
|
.GroupBy(p => p.WorkflowDefinitionId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Id, x => x.Count, ct);
|
||||||
|
|
||||||
|
PeWorkflowDefinitionDto ToDto(PurchaseEvaluationWorkflowDefinition d) => new(
|
||||||
|
d.Id,
|
||||||
|
d.Code,
|
||||||
|
d.Version,
|
||||||
|
(int)d.EvaluationType,
|
||||||
|
TypeLabels.GetValueOrDefault(d.EvaluationType, d.EvaluationType.ToString()),
|
||||||
|
d.Name,
|
||||||
|
d.Description,
|
||||||
|
d.IsActive,
|
||||||
|
d.ActivatedAt,
|
||||||
|
d.CreatedAt,
|
||||||
|
usageCounts.GetValueOrDefault(d.Id, 0),
|
||||||
|
d.Steps.OrderBy(s => s.Order).Select(s => new PeWorkflowStepDto(
|
||||||
|
s.Id,
|
||||||
|
s.Order,
|
||||||
|
(int)s.Phase,
|
||||||
|
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
|
||||||
|
s.Name,
|
||||||
|
s.SlaDays,
|
||||||
|
s.Approvers.Select(a => new PeWorkflowStepApproverDto(
|
||||||
|
(int)a.Kind,
|
||||||
|
a.AssignmentValue,
|
||||||
|
ResolveDisplay(a, userNames))).ToList()
|
||||||
|
)).ToList());
|
||||||
|
|
||||||
|
var types = Enum.GetValues<PurchaseEvaluationType>()
|
||||||
|
.Select(type =>
|
||||||
|
{
|
||||||
|
var versions = definitions.Where(d => d.EvaluationType == type).Select(ToDto).ToList();
|
||||||
|
return new PeWorkflowTypeSummaryDto(
|
||||||
|
(int)type,
|
||||||
|
TypeLabels.GetValueOrDefault(type, type.ToString()),
|
||||||
|
versions.FirstOrDefault(v => v.IsActive),
|
||||||
|
versions);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PeWorkflowAdminOverviewDto(types);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveDisplay(PurchaseEvaluationWorkflowStepApprover a, Dictionary<Guid, string> userNames)
|
||||||
|
{
|
||||||
|
if (a.Kind == WorkflowApproverKind.Role) return a.AssignmentValue;
|
||||||
|
if (Guid.TryParse(a.AssignmentValue, out var uid) && userNames.TryGetValue(uid, out var n)) return n;
|
||||||
|
return a.AssignmentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== POST new version ==========
|
||||||
|
|
||||||
|
public record CreatePeWorkflowStepApproverInput(int Kind, string AssignmentValue);
|
||||||
|
|
||||||
|
public record CreatePeWorkflowStepInput(
|
||||||
|
int Order,
|
||||||
|
int Phase,
|
||||||
|
string Name,
|
||||||
|
int? SlaDays,
|
||||||
|
List<CreatePeWorkflowStepApproverInput> Approvers);
|
||||||
|
|
||||||
|
public record CreatePeWorkflowDefinitionCommand(
|
||||||
|
PurchaseEvaluationType EvaluationType,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
List<CreatePeWorkflowStepInput> Steps) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class CreatePeWorkflowDefinitionCommandValidator : AbstractValidator<CreatePeWorkflowDefinitionCommand>
|
||||||
|
{
|
||||||
|
public CreatePeWorkflowDefinitionCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.EvaluationType).IsInEnum();
|
||||||
|
RuleFor(x => x.Code).NotEmpty().MaximumLength(100)
|
||||||
|
.Matches("^[A-Za-z0-9._-]+$")
|
||||||
|
.WithMessage("Code chỉ dùng chữ, số, và các ký tự . _ -");
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.Description).MaximumLength(1000);
|
||||||
|
RuleFor(x => x.Steps).NotEmpty()
|
||||||
|
.WithMessage("Quy trình phải có ít nhất 1 bước.");
|
||||||
|
RuleForEach(x => x.Steps).ChildRules(step =>
|
||||||
|
{
|
||||||
|
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
|
||||||
|
// Phase 1..7 thường, 99 = TuChoi (không nên dùng làm step)
|
||||||
|
step.RuleFor(s => s.Phase).Must(p => p >= 1 && p <= 7)
|
||||||
|
.WithMessage("Phase phải nằm trong 1..7 (state thường, không bao gồm Từ chối=99).");
|
||||||
|
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
|
||||||
|
step.RuleFor(s => s.SlaDays).GreaterThanOrEqualTo(0)
|
||||||
|
.When(s => s.SlaDays != null);
|
||||||
|
step.RuleForEach(s => s.Approvers).ChildRules(app =>
|
||||||
|
{
|
||||||
|
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
|
||||||
|
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreatePeWorkflowDefinitionCommandHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<CreatePeWorkflowDefinitionCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(CreatePeWorkflowDefinitionCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var nextVersion = await db.PurchaseEvaluationWorkflowDefinitions
|
||||||
|
.Where(w => w.Code == request.Code)
|
||||||
|
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
|
||||||
|
nextVersion++;
|
||||||
|
|
||||||
|
// Deactivate active version cho EvaluationType này (only ONE active per type)
|
||||||
|
var activeVersions = await db.PurchaseEvaluationWorkflowDefinitions
|
||||||
|
.Where(w => w.EvaluationType == request.EvaluationType && w.IsActive)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var old in activeVersions) old.IsActive = false;
|
||||||
|
|
||||||
|
var def = new PurchaseEvaluationWorkflowDefinition
|
||||||
|
{
|
||||||
|
Code = request.Code,
|
||||||
|
Version = nextVersion,
|
||||||
|
EvaluationType = request.EvaluationType,
|
||||||
|
Name = request.Name,
|
||||||
|
Description = request.Description,
|
||||||
|
IsActive = true,
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
Steps = request.Steps
|
||||||
|
.OrderBy(s => s.Order)
|
||||||
|
.Select(s => new PurchaseEvaluationWorkflowStep
|
||||||
|
{
|
||||||
|
Order = s.Order,
|
||||||
|
Phase = (PurchaseEvaluationPhase)s.Phase,
|
||||||
|
Name = s.Name,
|
||||||
|
SlaDays = s.SlaDays,
|
||||||
|
Approvers = s.Approvers.Select(a => new PurchaseEvaluationWorkflowStepApprover
|
||||||
|
{
|
||||||
|
Kind = (WorkflowApproverKind)a.Kind,
|
||||||
|
AssignmentValue = a.AssignmentValue,
|
||||||
|
}).ToList(),
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationWorkflowDefinitions.Add(def);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return def.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -342,6 +342,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
.Include(x => x.Details).ThenInclude(d => d.Quotes)
|
.Include(x => x.Details).ThenInclude(d => d.Quotes)
|
||||||
.Include(x => x.Approvals)
|
.Include(x => x.Approvals)
|
||||||
.Include(x => x.Attachments)
|
.Include(x => x.Attachments)
|
||||||
|
.Include(x => x.DepartmentOpinions)
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||||
|
|
||||||
@ -438,11 +439,26 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
a.Id, a.PurchaseEvaluationSupplierId, a.FileName, a.StoragePath,
|
a.Id, a.PurchaseEvaluationSupplierId, a.FileName, a.StoragePath,
|
||||||
a.FileSize, a.ContentType, a.Purpose, a.Note, a.CreatedAt))
|
a.FileSize, a.ContentType, a.Purpose, a.Note, a.CreatedAt))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
e.DepartmentOpinions
|
||||||
|
.OrderBy(o => (int)o.Kind)
|
||||||
|
.Select(o => new PurchaseEvaluationDepartmentOpinionDto(
|
||||||
|
o.Id, o.Kind, KindLabel(o.Kind),
|
||||||
|
o.Opinion, o.SignedAt, o.UserId, o.UserName))
|
||||||
|
.ToList(),
|
||||||
new PurchaseEvaluationWorkflowSummaryDto(
|
new PurchaseEvaluationWorkflowSummaryDto(
|
||||||
policy.Name, policy.Description,
|
policy.Name, policy.Description,
|
||||||
policy.ActivePhases.ToList(),
|
policy.ActivePhases.ToList(),
|
||||||
policy.NextPhasesFrom(e.Phase).ToList()));
|
policy.NextPhasesFrom(e.Phase).ToList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string KindLabel(PeDepartmentKind k) => k switch
|
||||||
|
{
|
||||||
|
PeDepartmentKind.PheDuyet => "Phê duyệt",
|
||||||
|
PeDepartmentKind.Ccm => "P.CCM",
|
||||||
|
PeDepartmentKind.MuaHang => "P.Mua hàng",
|
||||||
|
PeDepartmentKind.SmPm => "SM-PM",
|
||||||
|
_ => k.ToString(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== DELETE ==========
|
// ========== DELETE ==========
|
||||||
|
|||||||
@ -34,4 +34,5 @@ public class PurchaseEvaluation : AuditableEntity
|
|||||||
public List<PurchaseEvaluationApproval> Approvals { get; set; } = new();
|
public List<PurchaseEvaluationApproval> Approvals { get; set; } = new();
|
||||||
public List<PurchaseEvaluationChangelog> Changelogs { get; set; } = new();
|
public List<PurchaseEvaluationChangelog> Changelogs { get; set; } = new();
|
||||||
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
|
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
|
||||||
|
public List<PurchaseEvaluationDepartmentOpinion> DepartmentOpinions { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
// "Ý kiến 4 phòng ban" — sign-off block trên PHIẾU TRÌNH KÝ CHỌN TP/NCC.
|
||||||
|
// 4 box: Phê duyệt / P.CCM / P.MuaHàng / SM-PM. Mỗi box có:
|
||||||
|
// - Opinion text (text ý kiến)
|
||||||
|
// - SignedAt date
|
||||||
|
// - UserId người ký (lưu UserName denorm để render readable)
|
||||||
|
//
|
||||||
|
// Lưu thành table riêng (1:N với PurchaseEvaluation, max 4 row mỗi PE) để:
|
||||||
|
// - Dễ query "phòng nào chưa ký"
|
||||||
|
// - Audit history (mỗi update là 1 record? — không, dùng UPDATE in-place,
|
||||||
|
// audit qua PurchaseEvaluationChangelog).
|
||||||
|
// - Không bloat header với 12 column nullable.
|
||||||
|
public enum PeDepartmentKind
|
||||||
|
{
|
||||||
|
PheDuyet = 1, // box "PHÊ DUYỆT" trên cùng — typically Drafter/PM ký xác nhận trình
|
||||||
|
Ccm = 2, // P.CCM — Cost Control review
|
||||||
|
MuaHang = 3, // P.MuaHàng (PRO) — Procurement
|
||||||
|
SmPm = 4, // SM-PM — Site Manager / Project Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PurchaseEvaluationDepartmentOpinion : AuditableEntity
|
||||||
|
{
|
||||||
|
public Guid PurchaseEvaluationId { get; set; }
|
||||||
|
public PeDepartmentKind Kind { get; set; }
|
||||||
|
|
||||||
|
public string? Opinion { get; set; } // text ý kiến (max 2000)
|
||||||
|
public DateTime? SignedAt { get; set; }
|
||||||
|
public Guid? UserId { get; set; } // người ký
|
||||||
|
public string? UserName { get; set; } // denorm cho readable render
|
||||||
|
|
||||||
|
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
|
||||||
|
}
|
||||||
@ -59,6 +59,7 @@ public class ApplicationDbContext
|
|||||||
public DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps => Set<PurchaseEvaluationWorkflowStep>();
|
public DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps => Set<PurchaseEvaluationWorkflowStep>();
|
||||||
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
|
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
|
||||||
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
|
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
|
||||||
|
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
|
||||||
|
|
||||||
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
|
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
|
||||||
public DbSet<Budget> Budgets => Set<Budget>();
|
public DbSet<Budget> Budgets => Set<Budget>();
|
||||||
|
|||||||
@ -32,6 +32,7 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
|
|||||||
b.HasMany(x => x.Approvals).WithOne(a => a.PurchaseEvaluation).HasForeignKey(a => a.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
|
b.HasMany(x => x.Approvals).WithOne(a => a.PurchaseEvaluation).HasForeignKey(a => a.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
|
||||||
b.HasMany(x => x.Changelogs).WithOne(c => c.PurchaseEvaluation).HasForeignKey(c => c.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
|
b.HasMany(x => x.Changelogs).WithOne(c => c.PurchaseEvaluation).HasForeignKey(c => c.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
|
||||||
b.HasMany(x => x.Attachments).WithOne(a => a.PurchaseEvaluation).HasForeignKey(a => a.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
|
b.HasMany(x => x.Attachments).WithOne(a => a.PurchaseEvaluation).HasForeignKey(a => a.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasMany(x => x.DepartmentOpinions).WithOne(o => o.PurchaseEvaluation).HasForeignKey(o => o.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
|
||||||
// Quotes không FK trực tiếp tới PurchaseEvaluation (đi qua Detail) —
|
// Quotes không FK trực tiếp tới PurchaseEvaluation (đi qua Detail) —
|
||||||
// nhưng collection navigation có nên cần config riêng bên dưới.
|
// nhưng collection navigation có nên cần config riêng bên dưới.
|
||||||
|
|
||||||
@ -217,3 +218,21 @@ public class PurchaseEvaluationCodeSequenceConfiguration
|
|||||||
b.Property(x => x.Prefix).HasMaxLength(100);
|
b.Property(x => x.Prefix).HasMaxLength(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PurchaseEvaluationDepartmentOpinionConfiguration
|
||||||
|
: IEntityTypeConfiguration<PurchaseEvaluationDepartmentOpinion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PurchaseEvaluationDepartmentOpinion> b)
|
||||||
|
{
|
||||||
|
b.ToTable("PurchaseEvaluationDepartmentOpinions");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
b.Property(x => x.Kind).HasConversion<int>();
|
||||||
|
b.Property(x => x.Opinion).HasMaxLength(2000);
|
||||||
|
b.Property(x => x.UserName).HasMaxLength(200);
|
||||||
|
|
||||||
|
// Each PE × Kind unique — max 1 ý kiến per phòng ban per phiếu.
|
||||||
|
// UPDATE in-place khi user đổi ý → audit qua Changelog.
|
||||||
|
b.HasIndex(x => new { x.PurchaseEvaluationId, x.Kind }).IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPurchaseEvaluationDepartmentOpinions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PurchaseEvaluationDepartmentOpinions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Kind = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Opinion = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UserName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PurchaseEvaluationDepartmentOpinions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PurchaseEvaluationDepartmentOpinions_PurchaseEvaluations_PurchaseEvaluationId",
|
||||||
|
column: x => x.PurchaseEvaluationId,
|
||||||
|
principalTable: "PurchaseEvaluations",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PurchaseEvaluationDepartmentOpinions_PurchaseEvaluationId_Kind",
|
||||||
|
table: "PurchaseEvaluationDepartmentOpinions",
|
||||||
|
columns: new[] { "PurchaseEvaluationId", "Kind" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PurchaseEvaluationDepartmentOpinions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2451,6 +2451,61 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("PurchaseEvaluationCodeSequences", (string)null);
|
b.ToTable("PurchaseEvaluationCodeSequences", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Opinion")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<Guid>("PurchaseEvaluationId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SignedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("PurchaseEvaluationId", "Kind")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("PurchaseEvaluationDepartmentOpinions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -3073,6 +3128,17 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("PurchaseEvaluation");
|
b.Navigation("PurchaseEvaluation");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
|
||||||
|
.WithMany("DepartmentOpinions")
|
||||||
|
.HasForeignKey("PurchaseEvaluationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("PurchaseEvaluation");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
|
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
|
||||||
@ -3199,6 +3265,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.Navigation("Changelogs");
|
b.Navigation("Changelogs");
|
||||||
|
|
||||||
|
b.Navigation("DepartmentOpinions");
|
||||||
|
|
||||||
b.Navigation("Details");
|
b.Navigation("Details");
|
||||||
|
|
||||||
b.Navigation("Quotes");
|
b.Navigation("Quotes");
|
||||||
|
|||||||
Reference in New Issue
Block a user