[CLAUDE] Phase3: Workflow MVP — 9-phase state machine + code gen + FE Inbox/Detail

Backend Contracts domain (5 entities):
- Contract aggregate: Phase (9 enum), SlaDeadline, MaHopDong, BypassProcurementAndCCM, DraftData, SlaWarningSent
- ContractApproval: FromPhase → ToPhase, ApproverUserId (null = system auto-approve), Decision, Comment
- ContractComment: thread theo Phase current
- ContractAttachment: FileName + StoragePath + Purpose (DraftExport/ScannedSigned/SealedCopy)
- ContractCodeSequence: Prefix PK + LastSeq — atomic gen

EF configs:
- Unique MaHopDong filtered [MaHopDong] IS NOT NULL
- Indexes: Phase+IsDeleted, SupplierId, ProjectId, SlaDeadline, ContractId+ApprovedAt, ContractId+CreatedAt
- Cascade delete Approvals/Comments/Attachments khi Contract xoa
- Query filter IsDeleted
- Migration AddContractsWorkflow (DB 19 tables)

Workflow service:
- IContractWorkflowService.TransitionAsync:
  - Adjacency check qua Transitions Dict<(from,to), roles[]> (12 transitions)
  - Role guard: user phai co role ∈ allowed
  - Admin bypass (role Admin pass moi check)
  - System bypass (userId=null + Decision=AutoApprove → cho SLA job sau nay)
  - Bypass CCM: BypassProcurementAndCCM=true cho phep DangInKy → DangTrinhKy skip phase 6
  - Gen ma HD khi chuyen DangDongDau (idempotent — khong gen lai neu da co)
  - Reset SlaDeadline = UtcNow + PhaseSla
  - Insert ContractApproval row

Code generator (RG-001):
- 7 format theo ContractType: HDTP / HDGK / NCC / HDDV / MB + 2 framework (year prefix)
- BeginTransactionAsync(Serializable) + ContractCodeSequences UPSERT → atomic
- Idempotent: neu MaHopDong da co thi skip

CQRS (8 feature, ContractFeatures.cs):
- CreateContractCommand + Validator + Handler (set SlaDeadline = +7d)
- UpdateContractDraftCommand (chi khi Phase=DangSoanThao)
- TransitionContractCommand (delegate → WorkflowService)
- AddCommentCommand (phase = hien tai)
- ListContractsQuery (PagedResult + filter phase/supplier/project/search)
- GetMyInboxQuery (map Phase → actor roles, filter theo role user)
- GetContractQuery (detail + approvals + comments + attachments + resolve user names)
- DeleteContractCommand (soft, block > DangInKy)

Controller:
- ContractsController 8 endpoint: GET list/inbox/detail, POST create/transition/comment, PUT update, DELETE

Frontend fe-admin (2 page moi):
- types/contracts.ts: ContractPhase const + Label + Color maps + types
- components/PhaseBadge.tsx
- pages/contracts/ContractsListPage.tsx: filter phase + search + click → detail
- pages/contracts/ContractDetailPage.tsx: 2-col layout (info+comments | timeline), action dialog select target phase + comment

Frontend fe-user (4 page moi + 14 file shared):
- cp 14 file shared tu fe-admin (menuKeys, types/*, DataTable, PhaseBadge, Dialog, Textarea, Select, apiError, usePermission, PermissionGuard)
- AuthContext update: load menu tu /menus/me + cache
- Layout: menu fixed 3 muc + user info + roles display
- InboxPage: list HD cho role user xu ly (sort theo SLA)
- ContractCreatePage: form chon loai + template + NCC + du an + gia tri + bypass CDT
- ContractDetailPage: duplicate fe-admin pattern (convention)
- MyContractsPage: list HD cua toi
- App.tsx: 4 route moi

E2E verified:
- Setup Supplier + Project
- POST /contracts → 201 + phase=2
- POST /contracts/{id}/transitions x7 → di het 9 phase
- Final: MaHopDong = "FLOCK 01/HĐGK/SOL&PVL2026/01" dung format RG-001
- Approvals: 7 rows audit day du

Docs:
- .claude/skills/contract-workflow/SKILL.md: placeholder → full spec voi state machine, SLA table, role matrix, 7 code format, code pointers, API, E2E workflow, pitfalls
- docs/changelog/sessions/2026-04-21-1330-phase3-workflow.md: session log
- docs/STATUS.md: Phase 3 MVP done, next Phase 4
- docs/HANDOFF.md: update phase status + file tree + commit log + testing points
- docs/changelog/migration-todos.md: tick Phase 3 MVP items + add iteration 2 list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 12:26:09 +07:00
parent 5113e4c771
commit 7e957a7654
49 changed files with 4490 additions and 156 deletions

View File

@ -10,6 +10,8 @@ import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
function App() {
return (
@ -30,6 +32,8 @@ function App() {
<Route path="/master/departments" element={<DepartmentsPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/forms" element={<FormsPage />} />
<Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="*"

View File

@ -0,0 +1,10 @@
import { cn } from '@/lib/cn'
import { ContractPhaseColor, ContractPhaseLabel } from '@/types/contracts'
export function PhaseBadge({ phase, className }: { phase: number; className?: string }) {
return (
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-xs font-medium', ContractPhaseColor[phase], className)}>
{ContractPhaseLabel[phase]}
</span>
)
}

View File

@ -0,0 +1,250 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { ArrowRight, CheckCircle2, MessageSquare, Clock, ArrowLeft, XCircle } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { PhaseBadge } from '@/components/PhaseBadge'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
ApprovalDecision,
ContractPhase,
ContractPhaseLabel,
type ContractDetail,
} from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
// Các phase có thể chuyển đến từ phase hiện tại (match adjacency BE)
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],
}
export function ContractDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const qc = useQueryClient()
const [actionOpen, setActionOpen] = useState(false)
const [targetPhase, setTargetPhase] = useState<number>(0)
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
const [comment, setComment] = useState('')
const [commentInput, setCommentInput] = useState('')
const detail = useQuery({
queryKey: ['contract', id],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${id}`)).data,
enabled: !!id,
})
const transition = useMutation({
mutationFn: async () => {
await api.post(`/contracts/${id}/transitions`, { targetPhase, decision, comment: comment || null })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', id] })
qc.invalidateQueries({ queryKey: ['contracts'] })
qc.invalidateQueries({ queryKey: ['inbox'] })
toast.success('Đã chuyển phase')
setActionOpen(false)
setComment('')
},
onError: err => toast.error(getErrorMessage(err)),
})
const addComment = useMutation({
mutationFn: async (content: string) => {
await api.post(`/contracts/${id}/comments`, { content })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', id] })
setCommentInput('')
toast.success('Đã gửi comment')
},
onError: err => toast.error(getErrorMessage(err)),
})
if (detail.isLoading) return <div className="p-8 text-slate-500">Đang tải</div>
if (!detail.data) return <div className="p-8 text-slate-500">Không tìm thấy .</div>
const c = detail.data
const availableTargets = NEXT_PHASES[c.phase] ?? []
function openAction(decisionType: number) {
const targets = NEXT_PHASES[c.phase] ?? []
// Default: approve → first target; reject → prev (find DangSoanThao)
const defaultTarget = decisionType === ApprovalDecision.Reject
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
: targets[0]
setTargetPhase(defaultTarget)
setDecision(decisionType)
setActionOpen(true)
}
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft className="h-5 w-5" />
</button>
{c.tenHopDong ?? 'HĐ chưa đặt tên'}
</span>
}
description={
<span className="flex items-center gap-3">
<span className="font-mono text-xs">{c.maHopDong ?? '(chưa có mã)'}</span>
<PhaseBadge phase={c.phase} />
</span>
}
actions={
<div className="flex gap-2">
{availableTargets.length > 0 && (
<>
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
<XCircle className="h-4 w-4" />
Yêu cầu sửa
</Button>
<Button onClick={() => openAction(ApprovalDecision.Approve)}>
<CheckCircle2 className="h-4 w-4" />
Duyệt phase tiếp
</Button>
</>
)}
</div>
}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold text-slate-700">Thông tin </h2>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div><dt className="text-slate-500">Loại</dt><dd>{ContractTypeLabel[c.type] ?? '—'}</dd></div>
<div><dt className="text-slate-500">Giá trị</dt><dd>{fmtMoney(c.giaTri)}</dd></div>
<div><dt className="text-slate-500">NCC</dt><dd>{c.supplierName}</dd></div>
<div><dt className="text-slate-500">Dự án</dt><dd>{c.projectName}</dd></div>
<div><dt className="text-slate-500">Phòng ban</dt><dd>{c.departmentName ?? '—'}</dd></div>
<div><dt className="text-slate-500">Người soạn</dt><dd>{c.drafterName ?? '—'}</dd></div>
<div><dt className="text-slate-500">SLA</dt><dd>{c.slaDeadline ? fmt(c.slaDeadline) : '—'}</dd></div>
<div><dt className="text-slate-500">Bypass CCM</dt><dd>{c.bypassProcurementAndCCM ? 'Có (HĐ Chủ đầu tư)' : 'Không'}</dd></div>
</dl>
{c.noiDung && (
<div className="mt-3">
<dt className="text-sm text-slate-500">Nội dung</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{c.noiDung}</dd>
</div>
)}
</section>
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<MessageSquare className="h-4 w-4" />
Góp ý ({c.comments.length})
</h2>
<div className="space-y-3">
{c.comments.length === 0 && <div className="text-sm text-slate-400">Chưa góp ý.</div>}
{c.comments.map(cm => (
<div key={cm.id} className="rounded-md border border-slate-100 p-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span className="font-medium text-slate-700">{cm.userName}</span>
<span>{fmt(cm.createdAt)} · {ContractPhaseLabel[cm.phase]}</span>
</div>
<div className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{cm.content}</div>
</div>
))}
</div>
<form
className="mt-4 flex gap-2"
onSubmit={(e: FormEvent) => {
e.preventDefault()
if (!commentInput.trim()) return
addComment.mutate(commentInput.trim())
}}
>
<Textarea
rows={2}
placeholder="Thêm góp ý…"
value={commentInput}
onChange={e => setCommentInput(e.target.value)}
/>
<Button type="submit" disabled={addComment.isPending || !commentInput.trim()}>
Gửi
</Button>
</form>
</section>
</div>
<aside>
<section className="rounded-lg border border-slate-200 bg-white p-5">
<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ử duyệt ({c.approvals.length})
</h2>
<ol className="space-y-3">
{c.approvals.length === 0 && <li className="text-sm text-slate-400">Chưa lịch sử.</li>}
{c.approvals.map(a => (
<li key={a.id} className="flex gap-3">
<div className="mt-1 h-2 w-2 rounded-full bg-brand-500" />
<div className="flex-1 space-y-0.5 text-sm">
<div className="flex items-center gap-1">
<PhaseBadge phase={a.fromPhase} className="text-[10px]" />
<ArrowRight className="h-3 w-3 text-slate-400" />
<PhaseBadge phase={a.toPhase} className="text-[10px]" />
</div>
<div className="text-slate-700">{a.approverName ?? 'Hệ thống'}</div>
<div className="text-xs text-slate-500">{fmt(a.approvedAt)}</div>
{a.comment && <div className="mt-1 rounded bg-slate-50 px-2 py-1 text-xs text-slate-600">{a.comment}</div>}
</div>
</li>
))}
</ol>
</section>
</aside>
</div>
<Dialog
open={actionOpen}
onClose={() => setActionOpen(false)}
title={decision === ApprovalDecision.Reject ? 'Yêu cầu sửa' : 'Chuyển phase tiếp'}
footer={
<>
<Button variant="outline" onClick={() => setActionOpen(false)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending || !targetPhase}>
{transition.isPending ? 'Đang xử lý…' : 'Xác nhận'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">Chuyển đến phase</label>
<Select value={targetPhase} onChange={e => setTargetPhase(Number(e.target.value))}>
{availableTargets.map(p => (
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">Ghi chú (optional)</label>
<Textarea rows={3} value={comment} onChange={e => setComment(e.target.value)} />
</div>
</div>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,81 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
import { PhaseBadge } from '@/components/PhaseBadge'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import type { Paged } from '@/types/master'
import { ContractPhase, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
const fmtSla = (s: string | null) => {
if (!s) return '—'
const ms = new Date(s).getTime() - Date.now()
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (ms < 0) return <span className="text-red-600">Quá hạn</span>
if (days > 0) return `còn ${days}d ${hours}h`
return <span className="text-amber-600">còn {hours}h</span>
}
export function ContractsListPage() {
const navigate = useNavigate()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [phase, setPhase] = useState<string>('')
const list = useQuery({
queryKey: ['contracts', { page, search, phase }],
queryFn: async () => {
const res = await api.get<Paged<ContractListItem>>('/contracts', {
params: { page, pageSize: 20, search: search || undefined, phase: phase || undefined },
})
return res.data
},
})
const columns: Column<ContractListItem>[] = [
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
{ key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
{ key: 'type', header: 'Loại', width: 'w-32', render: c => ContractTypeLabel[c.type] ?? '—' },
{ key: 'phase', header: 'Phase', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
{ key: 'supplierName', header: 'NCC', render: c => c.supplierName },
{ key: 'projectName', header: 'Dự án', render: c => c.projectName },
{ key: 'giaTri', header: 'Giá trị', align: 'right', width: 'w-32', render: c => fmtMoney(c.giaTri) },
{ key: 'slaDeadline', header: 'SLA', width: 'w-32', render: c => fmtSla(c.slaDeadline) },
]
return (
<div className="p-6">
<PageHeader title="Hợp đồng" description="Danh sách toàn bộ HĐ — filter theo phase, NCC, dự án." />
<div className="mb-3 flex gap-2">
<Input
placeholder="Tìm theo mã HĐ, tên, NCC…"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="max-w-sm"
/>
<Select value={phase} onChange={e => { setPhase(e.target.value); setPage(1) }} className="max-w-xs">
<option value="">Tất cả phase</option>
{Object.values(ContractPhase).map(p => (
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
))}
</Select>
</div>
<DataTable
columns={columns}
rows={list.data?.items ?? []}
getRowKey={c => c.id}
isLoading={list.isLoading}
onRowClick={c => navigate(`/contracts/${c.id}`)}
/>
<Pagination page={page} pageSize={20} total={list.data?.total ?? 0} onChange={setPage} />
</div>
)
}

View File

@ -0,0 +1,122 @@
export const ContractPhase = {
DangChon: 1,
DangSoanThao: 2,
DangGopY: 3,
DangDamPhan: 4,
DangInKy: 5,
DangKiemTraCCM: 6,
DangTrinhKy: 7,
DangDongDau: 8,
DaPhatHanh: 9,
TuChoi: 99,
} as const
export type ContractPhase = typeof ContractPhase[keyof typeof ContractPhase]
export const ContractPhaseLabel: Record<number, string> = {
1: 'Đang chọn NCC',
2: 'Đang soạn thảo',
3: 'Đang góp ý',
4: 'Đang đàm phán',
5: 'Đang in ký',
6: 'CCM kiểm tra',
7: 'Đang trình ký',
8: 'Đang đóng dấu',
9: 'Đã phát hành',
99: 'Từ chối',
}
export const ContractPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-blue-100 text-blue-700',
3: 'bg-amber-100 text-amber-700',
4: 'bg-orange-100 text-orange-700',
5: 'bg-purple-100 text-purple-700',
6: 'bg-indigo-100 text-indigo-700',
7: 'bg-fuchsia-100 text-fuchsia-700',
8: 'bg-pink-100 text-pink-700',
9: 'bg-emerald-100 text-emerald-700',
99: 'bg-red-100 text-red-700',
}
export const ApprovalDecision = {
Pending: 0,
Approve: 1,
Reject: 2,
AutoApprove: 3,
} as const
export type ApprovalDecision = typeof ApprovalDecision[keyof typeof ApprovalDecision]
export type ContractListItem = {
id: string
maHopDong: string | null
tenHopDong: string | null
type: number
phase: number
supplierId: string
supplierName: string
projectId: string
projectName: string
giaTri: number
slaDeadline: string | null
createdAt: string
}
export type ContractApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type ContractComment = {
id: string
userId: string
userName: string
phase: number
content: string
createdAt: string
}
export type ContractAttachment = {
id: string
fileName: string
storagePath: string
fileSize: number
contentType: string
purpose: number
note: string | null
createdAt: string
}
export type ContractDetail = {
id: string
maHopDong: string | null
tenHopDong: string | null
noiDung: string | null
type: number
phase: number
supplierId: string
supplierName: string
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
templateId: string | null
giaTri: number
bypassProcurementAndCCM: boolean
slaDeadline: string | null
draftData: string | null
createdAt: string
updatedAt: string | null
approvals: ContractApproval[]
comments: ContractComment[]
attachments: ContractAttachment[]
}