[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

@ -1,18 +1,64 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Inbox } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
import { PhaseBadge } from '@/components/PhaseBadge'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import 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 InboxPage() {
const navigate = useNavigate()
const { user } = useAuth()
const list = useQuery({
queryKey: ['inbox'],
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).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 hiện tại', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
{ key: 'supplierName', header: 'NCC', render: c => c.supplierName },
{ 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-8">
<h1 className="text-2xl font-bold text-slate-900">Hộp thư chờ xử </h1>
<p className="mt-2 text-slate-600">
Xin chào <span className="font-medium">{user?.fullName}</span>. Vai trò:{' '}
<span className="font-mono text-sm">{user?.roles.join(', ')}</span>
</p>
<div className="mt-6 rounded-lg border border-slate-200 bg-white p-6 text-sm text-slate-500">
Danh sách chờ role của bạn xử sẽ hiển thị đây (Phase 3).
</div>
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Inbox className="h-5 w-5" />
Hộp thư
</span>
}
description={`HĐ chờ vai trò ${user?.roles.join(', ') ?? ''} xử lý. Click row để xem chi tiết.`}
/>
<DataTable
columns={columns}
rows={list.data ?? []}
getRowKey={c => c.id}
isLoading={list.isLoading}
empty="Không có HĐ nào chờ bạn xử lý."
onRowClick={c => navigate(`/contracts/${c.id}`)}
/>
</div>
)
}

View File

@ -0,0 +1,149 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
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 type { Paged, Project, Supplier } from '@/types/master'
import type { ContractTemplate } from '@/types/forms'
import { ContractTypeLabel } from '@/types/forms'
export function ContractCreatePage() {
const navigate = useNavigate()
const [type, setType] = useState(2)
const [supplierId, setSupplierId] = useState('')
const [projectId, setProjectId] = useState('')
const [templateId, setTemplateId] = useState('')
const [giaTri, setGiaTri] = useState('')
const [tenHopDong, setTenHopDong] = useState('')
const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false)
const suppliers = useQuery({
queryKey: ['suppliers-all'],
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
})
const projects = useQuery({
queryKey: ['projects-all'],
queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items,
})
const templates = useQuery({
queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
})
const create = useMutation({
mutationFn: async () => {
const res = await api.post<{ id: string }>('/contracts', {
type: Number(type),
supplierId,
projectId,
departmentId: null,
templateId: templateId || null,
giaTri: giaTri ? Number(giaTri) : 0,
tenHopDong: tenHopDong || null,
noiDung: noiDung || null,
bypassProcurementAndCCM: bypass,
draftData: null,
})
return res.data.id
},
onSuccess: id => {
toast.success('Đã tạo HĐ draft')
navigate(`/contracts/${id}`)
},
onError: err => toast.error(getErrorMessage(err)),
})
function submit(e: FormEvent) {
e.preventDefault()
if (!supplierId || !projectId) {
toast.error('Chọn NCC và dự án')
return
}
create.mutate()
}
return (
<div className="p-6">
<PageHeader title="Tạo hợp đồng mới" description="Điền thông tin cơ bản. Sau đó có thể bổ sung + submit lên phase góp ý." />
<form onSubmit={submit} className="max-w-3xl space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Loại *</Label>
<Select value={type} onChange={e => setType(Number(e.target.value))}>
{Object.entries(ContractTypeLabel).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Template (optional)</Label>
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}>
<option value=""> Chưa chọn </option>
{templates.data?.filter(t => t.isActive).map(t => (
<option key={t.id} value={t.id}>{t.formCode} {t.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>NCC *</Label>
<Select value={supplierId} onChange={e => setSupplierId(e.target.value)} required>
<option value=""> Chọn </option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Dự án *</Label>
<Select value={projectId} onChange={e => setProjectId(e.target.value)} required>
<option value=""> Chọn </option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Tên </Label>
<Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" />
</div>
<div className="space-y-1.5">
<Label>Giá trị (VND)</Label>
<Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} />
</div>
<div className="flex items-end gap-2 pb-2">
<input
type="checkbox"
id="bypass"
checked={bypass}
onChange={e => setBypass(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
<Label htmlFor="bypass" className="cursor-pointer"> với Chủ đu (bypass CCM)</Label>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} />
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>Hủy</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'}
</Button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,241 @@
// Fe-user version identical with fe-admin ContractDetailPage.
// Duplicate có chủ đích (theo convention dự án).
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'
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: ['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')
},
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] ?? []
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 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">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>
</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ử ({c.approvals.length})
</h2>
<ol className="space-y-3">
{c.approvals.length === 0 && <li className="text-sm text-slate-400">Chưa .</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,53 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { FileText } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
import { PhaseBadge } from '@/components/PhaseBadge'
import { api } from '@/lib/api'
import type { Paged } from '@/types/master'
import type { ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function MyContractsPage() {
const navigate = useNavigate()
const list = useQuery({
queryKey: ['my-contracts'],
queryFn: async () => (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).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', 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: 'giaTri', header: 'Giá trị', align: 'right', width: 'w-32', render: c => fmtMoney(c.giaTri) },
]
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<FileText className="h-5 w-5" />
của tôi
</span>
}
description="Danh sách HĐ bạn đã tạo hoặc tham gia."
/>
<DataTable
columns={columns}
rows={list.data?.items ?? []}
getRowKey={c => c.id}
isLoading={list.isLoading}
empty="Bạn chưa tạo HĐ nào."
onRowClick={c => navigate(`/contracts/${c.id}`)}
/>
</div>
)
}