[CLAUDE] Domain+Infra+App+FE: dynamic workflow policy per ContractType
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m42s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m42s
Đọc QT-TP-NCC.docx: quy trình 9 bước chỉ áp dụng cho Thầu phụ/NCC/Tổ đội.
Dịch vụ/Mua bán/Nguyên tắc bypass CCM. Thay hardcoded dict bằng policy
registry.
Domain — WorkflowPolicy.cs:
- Record WorkflowPolicy { Name, Description, Transitions, PhaseSla,
ActivePhases } — pure data, testable.
- WorkflowPolicies.Standard: 9-phase full (Thầu phụ/Giao khoán/NCC)
- WorkflowPolicies.SkipCcm: 7-phase (Dịch vụ/Mua bán/Nguyên tắc)
- WorkflowPolicyRegistry.For(type) map ContractType → policy
- WorkflowPolicyRegistry.ForContract(c) override nếu BypassProcurement
AndCCM=true (instance-level escape hatch)
Infrastructure — ContractWorkflowService:
- Xóa hardcoded Transitions/PhaseSla dicts → load từ policy.ForContract
- TransitionAsync: validate qua policy.Transitions thay vì dict local
- Error message include policy.Name để debug dễ hơn
- GetPhaseSla trả SLA từ Standard policy (fallback — SLA hiện tại giống
nhau giữa 2 policy)
Application — ContractDetailDto:
- Field mới `Workflow: WorkflowSummaryDto { PolicyName, Description,
ActivePhases, NextPhases }` — FE dùng để render nút chuyển phase
dynamic + timeline card.
- BuildWorkflowSummary helper trong ContractFeatures.
FE (both apps):
- Type WorkflowSummary + ContractDetail.workflow
- ContractDetailPage xóa hardcoded NEXT_PHASES — dùng
c.workflow.nextPhases từ BE (single source of truth)
- WorkflowSummaryCard: timeline của ActivePhases với check/current/
future states + policy name/description ở header
- Card hiển thị trong sidebar, phía trên "Lịch sử duyệt"
Docs:
- gotchas.md #21 marked RESOLVED (NEXT_PHASES sync không còn cần)
Foundation: sau này admin có thể edit policy qua UI khi chuyển sang DB-
backed policy — nhưng API contract (WorkflowSummaryDto) đã stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
63
fe-user/src/components/WorkflowSummaryCard.tsx
Normal file
63
fe-user/src/components/WorkflowSummaryCard.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Check, Circle, GitBranch } from 'lucide-react'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { ContractPhaseLabel } from '@/types/contracts'
|
||||
import type { WorkflowSummary } from '@/types/contracts'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// Shows the active WorkflowPolicy (Standard / SkipCcm / …) as a timeline of
|
||||
// phases with the current one highlighted. Drafter + approvers see clearly
|
||||
// "where we are" and "what comes next" — no more mental mapping of enum ints.
|
||||
export function WorkflowSummaryCard({
|
||||
workflow,
|
||||
currentPhase,
|
||||
}: {
|
||||
workflow: WorkflowSummary
|
||||
currentPhase: number
|
||||
}) {
|
||||
const currentIdx = workflow.activePhases.indexOf(currentPhase)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Quy trình: {workflow.policyName}
|
||||
</h2>
|
||||
<p className="mb-4 text-xs leading-relaxed text-slate-500">{workflow.policyDescription}</p>
|
||||
|
||||
<ol className="space-y-2">
|
||||
{workflow.activePhases.map((phase, idx) => {
|
||||
const isDone = currentIdx >= 0 && idx < currentIdx
|
||||
const isCurrent = phase === currentPhase
|
||||
const isFuture = currentIdx >= 0 && idx > currentIdx
|
||||
return (
|
||||
<li key={phase} className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold',
|
||||
isDone && 'bg-emerald-100 text-emerald-700',
|
||||
isCurrent && 'bg-brand-600 text-white ring-4 ring-brand-100',
|
||||
isFuture && 'bg-slate-100 text-slate-400',
|
||||
)}
|
||||
>
|
||||
{isDone ? <Check className="h-3 w-3" /> : isCurrent ? <Circle className="h-2 w-2 fill-current" /> : idx + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isCurrent && 'font-semibold text-slate-900',
|
||||
isFuture && 'text-slate-500',
|
||||
isDone && 'text-slate-600',
|
||||
)}
|
||||
>
|
||||
{ContractPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent && <PhaseBadge phase={phase} className="text-[10px]" />}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { PageHeader } from '@/components/PageHeader'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
@ -26,15 +27,7 @@ import { ContractTypeLabel } from '@/types/forms'
|
||||
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
|
||||
|
||||
const NEXT_PHASES: Record<number, number[]> = {
|
||||
[ContractPhase.DangSoanThao]: [ContractPhase.DangGopY, ContractPhase.TuChoi],
|
||||
[ContractPhase.DangGopY]: [ContractPhase.DangDamPhan, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangDamPhan]: [ContractPhase.DangInKy],
|
||||
[ContractPhase.DangInKy]: [ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy],
|
||||
[ContractPhase.DangKiemTraCCM]: [ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangTrinhKy]: [ContractPhase.DangDongDau, ContractPhase.DangSoanThao],
|
||||
[ContractPhase.DangDongDau]: [ContractPhase.DaPhatHanh],
|
||||
}
|
||||
// NEXT_PHASES dynamic từ BE qua contract.workflow.nextPhases.
|
||||
|
||||
export function ContractDetailPage() {
|
||||
const { id } = useParams()
|
||||
@ -82,10 +75,10 @@ export function ContractDetailPage() {
|
||||
if (!detail.data) return <div className="p-8 text-slate-500">Không tìm thấy HĐ.</div>
|
||||
const c = detail.data
|
||||
|
||||
const availableTargets = NEXT_PHASES[c.phase] ?? []
|
||||
const availableTargets = c.workflow?.nextPhases ?? []
|
||||
|
||||
function openAction(decisionType: number) {
|
||||
const targets = NEXT_PHASES[c.phase] ?? []
|
||||
const targets = c.workflow?.nextPhases ?? []
|
||||
const defaultTarget = decisionType === ApprovalDecision.Reject
|
||||
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
|
||||
: targets[0]
|
||||
@ -184,8 +177,10 @@ export function ContractDetailPage() {
|
||||
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<aside className="space-y-4">
|
||||
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Clock className="h-4 w-4" />
|
||||
Lịch sử ({c.approvals.length})
|
||||
|
||||
@ -94,6 +94,13 @@ export type ContractAttachment = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type WorkflowSummary = {
|
||||
policyName: string
|
||||
policyDescription: string
|
||||
activePhases: number[]
|
||||
nextPhases: number[]
|
||||
}
|
||||
|
||||
export type ContractDetail = {
|
||||
id: string
|
||||
maHopDong: string | null
|
||||
@ -119,4 +126,5 @@ export type ContractDetail = {
|
||||
approvals: ContractApproval[]
|
||||
comments: ContractComment[]
|
||||
attachments: ContractAttachment[]
|
||||
workflow: WorkflowSummary
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user