[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:
@ -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ư — HĐ chờ xử lý</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 HĐ chờ role của bạn xử lý 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>
|
||||
)
|
||||
}
|
||||
|
||||
149
fe-user/src/pages/contracts/ContractCreatePage.tsx
Normal file
149
fe-user/src/pages/contracts/ContractCreatePage.tsx
Normal 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 HĐ *</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 HĐ</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">HĐ với Chủ đầu tư (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>
|
||||
)
|
||||
}
|
||||
241
fe-user/src/pages/contracts/ContractDetailPage.tsx
Normal file
241
fe-user/src/pages/contracts/ContractDetailPage.tsx
Normal 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 HĐ.</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 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">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 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ử ({c.approvals.length})
|
||||
</h2>
|
||||
<ol className="space-y-3">
|
||||
{c.approvals.length === 0 && <li className="text-sm text-slate-400">Chưa có.</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>
|
||||
)
|
||||
}
|
||||
53
fe-user/src/pages/contracts/MyContractsPage.tsx
Normal file
53
fe-user/src/pages/contracts/MyContractsPage.tsx
Normal 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" />
|
||||
HĐ 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user