[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:
@ -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="*"
|
||||
|
||||
10
fe-admin/src/components/PhaseBadge.tsx
Normal file
10
fe-admin/src/components/PhaseBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
250
fe-admin/src/pages/contracts/ContractDetailPage.tsx
Normal file
250
fe-admin/src/pages/contracts/ContractDetailPage.tsx
Normal 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 HĐ.</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 HĐ</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 có 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 có 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>
|
||||
)
|
||||
}
|
||||
81
fe-admin/src/pages/contracts/ContractsListPage.tsx
Normal file
81
fe-admin/src/pages/contracts/ContractsListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
fe-admin/src/types/contracts.ts
Normal file
122
fe-admin/src/types/contracts.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user