[CLAUDE] FE-User+FE-Admin: 3-panel layout cho danh sách HĐ
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m50s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m50s
Redesign trang Danh sách HĐ (Ct_*_List menu fe-user + /contracts admin)
thành 3-panel: List | Detail content | Workflow + lịch sử duyệt. Selected
HĐ giữ qua URL ?id= (bookmarkable + back/forward navigation work).
## Components mới (reuse cho cả 3-panel embedded + fullpage detail)
### fe-user/src/components/contracts/
- ContractDetailContent.tsx — Panel 2 body: header sticky (title + phase
+ actions Yêu cầu sửa/Duyệt) + Info section + Comments thread + form
thêm góp ý + Attachments. Transition Dialog inline. Prop optional
onBack — render arrow back button (fullpage) hoặc skip (embedded).
- WorkflowHistoryPanel.tsx — Panel 3: WorkflowSummaryCard (timeline
policy current+next) + Lịch sử duyệt (approvals: phase from→to + actor
+ timestamp + comment).
### fe-admin/src/components/contracts/
- ContractDetailContent.tsx — variant admin có thêm Phòng ban + Bypass
CCM trong Info section. Invalidate ['contracts'] khi transition.
- WorkflowHistoryPanel.tsx — identical fe-user.
## Trang refactored
### fe-user
- MyContractsPage.tsx — bỏ DataTable, dùng 3-panel grid
lg:grid-cols-[320px_1fr_360px] h-[calc(100vh-4rem)]:
Panel 1: search box + list compact (mã/tên/NCC/phase/SLA/giá), click
update ?id= active highlight ring-brand
Panel 2: detail content embedded
Panel 3: workflow + history
Mobile (<lg): chỉ Panel 1 visible, click row navigate fullpage
/contracts/:id (UX khả dụng, không nhồi 3 panel màn hình hẹp).
URL state: ?type=X (filter loại) + ?id= (selected) + ?q= (search).
- ContractDetailPage.tsx — slim version dùng ContractDetailContent +
WorkflowHistoryPanel, giữ deep link /contracts/:id work.
### fe-admin
- ContractsListPage.tsx — 3-panel + filter phase + pagination compact
trong Panel 1 footer. URL state: ?type, ?pendingMe, ?id, ?q, ?phase,
?page (full bookmarkable). Title hiển thị loại HĐ + count badge.
- ContractDetailPage.tsx — slim version giống fe-user.
## Build verified
- fe-user: tsc -b + vite build pass (1888 modules, 1.08MB JS)
- fe-admin: tsc -b + vite build pass (1903 modules, 1.15MB JS)
Note: npm install resolved @microsoft/signalr 8.0.7 → 8.0.17 (within
^8.0.7 caret), reverted package.json + lock changes do bump không phải
scope task này. Dev tiếp theo run npm install sẽ tự re-resolve.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
206
fe-admin/src/components/contracts/ContractDetailContent.tsx
Normal file
206
fe-admin/src/components/contracts/ContractDetailContent.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
// Reusable detail body — used by full-page ContractDetailPage AND embedded
|
||||||
|
// in ContractsListPage 3-panel layout (Panel 2). Admin variant include thêm
|
||||||
|
// Phòng ban + Bypass CCM trong Info section. Workflow + history live separately
|
||||||
|
// trong WorkflowHistoryPanel (Panel 3).
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
|
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||||
|
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'
|
||||||
|
|
||||||
|
export function ContractDetailContent({
|
||||||
|
contract: c,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
contract: ContractDetail
|
||||||
|
/** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */
|
||||||
|
onBack?: () => void
|
||||||
|
}) {
|
||||||
|
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 transition = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.post(`/contracts/${c.id}/transitions`, { targetPhase, decision, comment: comment || null })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['contract', c.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/${c.id}/comments`, { content })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['contract', c.id] })
|
||||||
|
setCommentInput('')
|
||||||
|
toast.success('Đã gửi comment')
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableTargets = c.workflow?.nextPhases ?? []
|
||||||
|
|
||||||
|
function openAction(decisionType: number) {
|
||||||
|
const targets = c.workflow?.nextPhases ?? []
|
||||||
|
const defaultTarget = decisionType === ApprovalDecision.Reject
|
||||||
|
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
|
||||||
|
: targets[0]
|
||||||
|
setTargetPhase(defaultTarget)
|
||||||
|
setDecision(decisionType)
|
||||||
|
setActionOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header — sticky inside scroll container */}
|
||||||
|
<div className="sticky top-0 z-10 -mx-5 -mt-5 border-b border-slate-200 bg-white px-5 pt-5 pb-3 md:-mx-6 md:-mt-6 md:px-6 md:pt-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onBack && (
|
||||||
|
<button onClick={onBack} className="text-slate-400 hover:text-slate-700" aria-label="Quay lại">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h1 className="truncate text-[18px] font-semibold tracking-tight text-slate-900">
|
||||||
|
{c.tenHopDong ?? 'HĐ chưa đặt tên'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs text-slate-500">
|
||||||
|
<span className="font-mono">{c.maHopDong ?? '(chưa có mã)'}</span>
|
||||||
|
<PhaseBadge phase={c.phase} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{availableTargets.length > 0 && (
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 className="col-span-2">
|
||||||
|
<dt className="text-slate-500 mb-1">SLA</dt>
|
||||||
|
<dd><SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} variant="full" /></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>
|
||||||
|
|
||||||
|
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
fe-admin/src/components/contracts/WorkflowHistoryPanel.tsx
Normal file
45
fe-admin/src/components/contracts/WorkflowHistoryPanel.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// Panel 3 cho 3-panel layout — gom WorkflowSummaryCard (timeline policy) +
|
||||||
|
// approval history (ai/khi/quyết định gì) vào 1 stack.
|
||||||
|
import { ArrowRight, Clock } from 'lucide-react'
|
||||||
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
|
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||||
|
import type { ContractDetail } from '@/types/contracts'
|
||||||
|
|
||||||
|
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||||
|
|
||||||
|
export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail }) {
|
||||||
|
return (
|
||||||
|
<div 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ử 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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,43 +1,16 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
// Fullpage detail (route /contracts/:id) — entered từ notification, bookmark,
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
// hoặc khi width < lg: trên 3-panel page. Dùng 2 component reuse:
|
||||||
|
// ContractDetailContent (Panel 2) + WorkflowHistoryPanel (Panel 3).
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { ArrowRight, CheckCircle2, MessageSquare, Clock, ArrowLeft, XCircle } from 'lucide-react'
|
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||||
import { toast } from 'sonner'
|
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||||
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'
|
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import type { ContractDetail } from '@/types/contracts'
|
||||||
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'
|
|
||||||
|
|
||||||
// NEXT_PHASES giờ là dynamic từ BE qua contract.workflow.nextPhases — không
|
|
||||||
// hardcode FE (xem ContractDetailDto.Workflow).
|
|
||||||
|
|
||||||
export function ContractDetailPage() {
|
export function ContractDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
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({
|
const detail = useQuery({
|
||||||
queryKey: ['contract', id],
|
queryKey: ['contract', id],
|
||||||
@ -45,205 +18,19 @@ export function ContractDetailPage() {
|
|||||||
enabled: !!id,
|
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.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>
|
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 = c.workflow?.nextPhases ?? []
|
|
||||||
|
|
||||||
function openAction(decisionType: number) {
|
|
||||||
const targets = c.workflow?.nextPhases ?? []
|
|
||||||
// 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 (
|
return (
|
||||||
<div className="p-6">
|
<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="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<div className="space-y-4 lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
<ContractDetailContent contract={detail.data} onBack={() => navigate(-1)} />
|
||||||
<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 className="col-span-2"><dt className="text-slate-500 mb-1">SLA</dt><dd><SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} variant="full" /></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>
|
|
||||||
|
|
||||||
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<aside>
|
||||||
<aside className="space-y-4">
|
<WorkflowHistoryPanel contract={detail.data} />
|
||||||
{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ử 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>
|
</aside>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,51 @@
|
|||||||
import { useMemo, useState } from 'react'
|
// 3-panel "Danh sách HĐ" admin — Panel 1 (list compact + filter + pagination)
|
||||||
|
// | Panel 2 (detail content) | Panel 3 (workflow + lịch sử duyệt). Selected
|
||||||
|
// HĐ giữ qua URL `?id=` để bookmarkable + back/forward navigation.
|
||||||
|
//
|
||||||
|
// URL params:
|
||||||
|
// - type=X — filter loại HĐ (sidebar menu hoặc landing card pass)
|
||||||
|
// - pendingMe=1 — chỉ HĐ chờ role tôi
|
||||||
|
// - id=... — selected contract (Panel 2/3)
|
||||||
|
// - q=... — search term (synced URL để share link)
|
||||||
|
// - phase=N — filter phase
|
||||||
|
// - page=N — pagination page
|
||||||
|
//
|
||||||
|
// Mobile fallback (< lg): click row → fullpage /contracts/:id.
|
||||||
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { FileText, Search, X } from 'lucide-react'
|
||||||
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||||
|
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
import type { Paged } from '@/types/master'
|
import type { Paged } from '@/types/master'
|
||||||
import { ContractPhase, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
|
import {
|
||||||
|
ContractPhase,
|
||||||
|
ContractPhaseLabel,
|
||||||
|
type ContractDetail,
|
||||||
|
type ContractListItem,
|
||||||
|
} from '@/types/contracts'
|
||||||
import { ContractTypeLabel } from '@/types/forms'
|
import { ContractTypeLabel } from '@/types/forms'
|
||||||
|
|
||||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||||
|
|
||||||
export function ContractsListPage() {
|
export function ContractsListPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
// URL-driven filters — sidebar menu links pass type + pendingMe via query.
|
|
||||||
// In-page filters (search, phase) are local state so admin can tweak freely.
|
|
||||||
const urlType = searchParams.get('type')
|
const urlType = searchParams.get('type')
|
||||||
const urlPendingMe = searchParams.get('pendingMe') === '1'
|
const urlPendingMe = searchParams.get('pendingMe') === '1'
|
||||||
const typeFilter = urlType ? Number(urlType) : null
|
const typeFilter = urlType ? Number(urlType) : null
|
||||||
|
const search = searchParams.get('q') ?? ''
|
||||||
const [page, setPage] = useState(1)
|
const phase = searchParams.get('phase') ?? ''
|
||||||
const [search, setSearch] = useState('')
|
const page = Number(searchParams.get('page') ?? '1')
|
||||||
const [phase, setPhase] = useState<string>('')
|
const selectedId = searchParams.get('id')
|
||||||
|
|
||||||
const list = useQuery({
|
const list = useQuery({
|
||||||
queryKey: ['contracts', { page, search, phase, typeFilter, urlPendingMe }],
|
queryKey: ['contracts', { page, search, phase, typeFilter, urlPendingMe }],
|
||||||
@ -34,20 +53,23 @@ export function ContractsListPage() {
|
|||||||
const res = await api.get<Paged<ContractListItem>>('/contracts', {
|
const res = await api.get<Paged<ContractListItem>>('/contracts', {
|
||||||
params: {
|
params: {
|
||||||
page,
|
page,
|
||||||
pageSize: 20,
|
pageSize: 30,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
phase: phase || undefined,
|
phase: phase || undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
// BE doesn't filter by type in listContracts yet — do it client-side.
|
|
||||||
// Same for pendingMe; backend has /inbox but that returns different shape.
|
|
||||||
// This keeps the existing API surface small; can promote to BE later.
|
|
||||||
let items = res.data.items
|
let items = res.data.items
|
||||||
if (typeFilter != null) items = items.filter(c => c.type === typeFilter)
|
if (typeFilter != null) items = items.filter(c => c.type === typeFilter)
|
||||||
return { ...res.data, items }
|
return { ...res.data, items }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ['contract', selectedId],
|
||||||
|
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
|
||||||
|
enabled: !!selectedId,
|
||||||
|
})
|
||||||
|
|
||||||
const headerTitle = useMemo(() => {
|
const headerTitle = useMemo(() => {
|
||||||
if (typeFilter != null) {
|
if (typeFilter != null) {
|
||||||
const label = ContractTypeLabel[typeFilter] ?? 'HĐ'
|
const label = ContractTypeLabel[typeFilter] ?? 'HĐ'
|
||||||
@ -56,58 +78,177 @@ export function ContractsListPage() {
|
|||||||
return 'Hợp đồng'
|
return 'Hợp đồng'
|
||||||
}, [typeFilter, urlPendingMe])
|
}, [typeFilter, urlPendingMe])
|
||||||
|
|
||||||
const headerDesc = useMemo(() => {
|
function setParam(key: string, value: string | null) {
|
||||||
if (urlPendingMe) return 'Các HĐ đang ở phase cần vai trò của bạn duyệt.'
|
const next = new URLSearchParams(searchParams)
|
||||||
if (typeFilter != null) return `Danh sách ${ContractTypeLabel[typeFilter] ?? 'HĐ'}.`
|
if (value == null || value === '') next.delete(key)
|
||||||
return 'Danh sách toàn bộ HĐ — filter theo phase, NCC, dự án.'
|
else next.set(key, value)
|
||||||
}, [typeFilter, urlPendingMe])
|
if (key !== 'id' && key !== 'page') next.delete('page') // reset paging khi đổi filter
|
||||||
|
setSearchParams(next, { replace: key === 'q' })
|
||||||
|
}
|
||||||
|
|
||||||
const columns: Column<ContractListItem>[] = [
|
function selectContract(id: string) {
|
||||||
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
|
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||||
{ key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
|
setParam('id', id)
|
||||||
{ key: 'type', header: 'Loại', width: 'w-32', render: c => ContractTypeLabel[c.type] ?? '—' },
|
} else {
|
||||||
{ key: 'phase', header: 'Phase', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
|
navigate(`/contracts/${id}`)
|
||||||
{ 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-40', render: c => <SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} /> },
|
function clearSelection() {
|
||||||
]
|
setParam('id', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = list.data?.items ?? []
|
||||||
|
const total = list.data?.total ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
<PageHeader title={headerTitle} description={headerDesc} />
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-slate-500" />
|
||||||
|
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||||
|
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
{typeFilter != null && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/contracts')}
|
||||||
|
className="ml-2 rounded-md border border-slate-300 bg-white px-2 py-0.5 text-[11px] text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
← Tất cả loại
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="mb-3 flex gap-2">
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
|
||||||
<Input
|
{/* Panel 1 — List + filters + pagination */}
|
||||||
placeholder="Tìm theo mã HĐ, tên, NCC…"
|
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
value={search}
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
<div className="relative">
|
||||||
className="max-w-sm"
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
/>
|
<Input
|
||||||
<Select value={phase} onChange={e => { setPhase(e.target.value); setPage(1) }} className="max-w-xs">
|
value={search}
|
||||||
<option value="">Tất cả phase</option>
|
onChange={e => setParam('q', e.target.value)}
|
||||||
{Object.values(ContractPhase).map(p => (
|
placeholder="Tìm mã / tên / NCC…"
|
||||||
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
|
className="pl-8"
|
||||||
))}
|
/>
|
||||||
</Select>
|
</div>
|
||||||
{typeFilter != null && (
|
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
|
||||||
<button
|
<option value="">Tất cả phase</option>
|
||||||
onClick={() => navigate('/contracts')}
|
{Object.values(ContractPhase).map(p => (
|
||||||
className="rounded-md border border-slate-300 bg-white px-3 text-xs text-slate-600 hover:bg-slate-50"
|
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
|
||||||
>
|
))}
|
||||||
← Tất cả loại
|
</Select>
|
||||||
</button>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{list.isLoading && (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!list.isLoading && rows.length === 0 && (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title="Không có HĐ"
|
||||||
|
description="Đổi filter hoặc chọn loại HĐ khác."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{rows.map(c => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => selectContract(c.id)}
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||||
|
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-slate-900">
|
||||||
|
{c.tenHopDong ?? '(chưa đặt tên)'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
|
<span className="font-mono">{c.maHopDong ?? '—'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{c.supplierName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-slate-500">
|
||||||
|
{c.projectName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>{fmtMoney(c.giaTri)}</span>
|
||||||
|
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination compact */}
|
||||||
|
{total > 30 && (
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-t border-slate-200 px-3 py-2 text-[11px]">
|
||||||
|
<span className="text-slate-500">
|
||||||
|
Trang {page} / {Math.ceil(total / 30)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setParam('page', String(Math.max(1, page - 1)))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setParam('page', String(page + 1))}
|
||||||
|
disabled={page * 30 >= total}
|
||||||
|
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Panel 2 — Detail content */}
|
||||||
|
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||||
|
{!selectedId && (
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title="Chọn 1 HĐ ở danh sách bên trái"
|
||||||
|
description="Chi tiết HĐ + thông tin / góp ý / file đính kèm sẽ hiển thị ở đây."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedId && detail.isLoading && (
|
||||||
|
<div className="text-sm text-slate-500">Đang tải HĐ…</div>
|
||||||
|
)}
|
||||||
|
{selectedId && detail.data && (
|
||||||
|
<ContractDetailContent contract={detail.data} onBack={clearSelection} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Panel 3 — Workflow + history */}
|
||||||
|
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||||
|
{!selectedId && (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||||
|
<X className="mx-auto mb-2 h-5 w-5" />
|
||||||
|
Quy trình duyệt sẽ hiện khi chọn HĐ.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
||||||
|
</aside>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
204
fe-user/src/components/contracts/ContractDetailContent.tsx
Normal file
204
fe-user/src/components/contracts/ContractDetailContent.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
// Reusable detail body — used by full-page ContractDetailPage AND embedded
|
||||||
|
// in MyContractsPage 3-panel layout (Panel 2). Renders header (title + phase
|
||||||
|
// + actions) + Info + Comments + Attachments + transition Dialog. Workflow +
|
||||||
|
// approval history live separately in WorkflowHistoryPanel (Panel 3).
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { ArrowLeft, CheckCircle2, MessageSquare, XCircle } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
|
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||||
|
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'
|
||||||
|
|
||||||
|
export function ContractDetailContent({
|
||||||
|
contract: c,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
contract: ContractDetail
|
||||||
|
/** Optional back handler — shown as arrow button next to title. Pass `navigate(-1)` for fullpage; omit for embedded panel. */
|
||||||
|
onBack?: () => void
|
||||||
|
}) {
|
||||||
|
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 transition = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.post(`/contracts/${c.id}/transitions`, { targetPhase, decision, comment: comment || null })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['contract', c.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['my-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/${c.id}/comments`, { content })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['contract', c.id] })
|
||||||
|
setCommentInput('')
|
||||||
|
toast.success('Đã gửi')
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableTargets = c.workflow?.nextPhases ?? []
|
||||||
|
|
||||||
|
function openAction(decisionType: number) {
|
||||||
|
const targets = c.workflow?.nextPhases ?? []
|
||||||
|
const defaultTarget = decisionType === ApprovalDecision.Reject
|
||||||
|
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
|
||||||
|
: targets[0]
|
||||||
|
setTargetPhase(defaultTarget)
|
||||||
|
setDecision(decisionType)
|
||||||
|
setActionOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header — sticky inside scroll container so actions luôn visible */}
|
||||||
|
<div className="sticky top-0 z-10 -mx-5 -mt-5 border-b border-slate-200 bg-white px-5 pt-5 pb-3 md:-mx-6 md:-mt-6 md:px-6 md:pt-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onBack && (
|
||||||
|
<button onClick={onBack} className="text-slate-400 hover:text-slate-700" aria-label="Quay lại">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h1 className="truncate text-[18px] font-semibold tracking-tight text-slate-900">
|
||||||
|
{c.tenHopDong ?? 'HĐ chưa đặt tên'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs text-slate-500">
|
||||||
|
<span className="font-mono">{c.maHopDong ?? '(chưa có mã)'}</span>
|
||||||
|
<PhaseBadge phase={c.phase} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{availableTargets.length > 0 && (
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 className="col-span-2">
|
||||||
|
<dt className="text-slate-500 mb-1">SLA</dt>
|
||||||
|
<dd><SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} variant="full" /></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>
|
||||||
|
|
||||||
|
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
fe-user/src/components/contracts/WorkflowHistoryPanel.tsx
Normal file
46
fe-user/src/components/contracts/WorkflowHistoryPanel.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Panel 3 cho 3-panel layout — gom WorkflowSummaryCard (timeline policy) +
|
||||||
|
// approval history (ai/khi/quyết định gì) vào 1 stack. Dùng cho cả MyContracts
|
||||||
|
// 3-panel và ContractDetailPage fullpage.
|
||||||
|
import { ArrowRight, Clock } from 'lucide-react'
|
||||||
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
|
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||||
|
import type { ContractDetail } from '@/types/contracts'
|
||||||
|
|
||||||
|
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||||
|
|
||||||
|
export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail }) {
|
||||||
|
return (
|
||||||
|
<div 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ử 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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,43 +1,16 @@
|
|||||||
// Fe-user version identical with fe-admin ContractDetailPage.
|
// Fullpage detail (route /contracts/:id) — entered từ notification, bookmark,
|
||||||
// Duplicate có chủ đích (theo convention dự án).
|
// hoặc khi width < lg: trên 3-panel page. Dùng 2 component reuse:
|
||||||
import { useState, type FormEvent } from 'react'
|
// ContractDetailContent (Panel 2) + WorkflowHistoryPanel (Panel 3).
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { ArrowRight, CheckCircle2, MessageSquare, Clock, ArrowLeft, XCircle } from 'lucide-react'
|
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||||
import { toast } from 'sonner'
|
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||||
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'
|
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import type { ContractDetail } from '@/types/contracts'
|
||||||
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'
|
|
||||||
|
|
||||||
// NEXT_PHASES dynamic từ BE qua contract.workflow.nextPhases.
|
|
||||||
|
|
||||||
export function ContractDetailPage() {
|
export function ContractDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
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({
|
const detail = useQuery({
|
||||||
queryKey: ['contract', id],
|
queryKey: ['contract', id],
|
||||||
@ -45,196 +18,19 @@ export function ContractDetailPage() {
|
|||||||
enabled: !!id,
|
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.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>
|
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 = c.workflow?.nextPhases ?? []
|
|
||||||
|
|
||||||
function openAction(decisionType: number) {
|
|
||||||
const targets = c.workflow?.nextPhases ?? []
|
|
||||||
const defaultTarget = decisionType === ApprovalDecision.Reject
|
|
||||||
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
|
|
||||||
: targets[0]
|
|
||||||
setTargetPhase(defaultTarget)
|
|
||||||
setDecision(decisionType)
|
|
||||||
setActionOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<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="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<div className="space-y-4 lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
<ContractDetailContent contract={detail.data} onBack={() => navigate(-1)} />
|
||||||
<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 className="col-span-2"><dt className="text-slate-500 mb-1">SLA</dt><dd><SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} variant="full" /></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>
|
|
||||||
|
|
||||||
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<aside>
|
||||||
<aside className="space-y-4">
|
<WorkflowHistoryPanel contract={detail.data} />
|
||||||
{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})
|
|
||||||
</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>
|
</aside>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,78 +1,202 @@
|
|||||||
|
// 3-panel "Danh sách HĐ" — Panel 1 (list compact, click chọn) | Panel 2
|
||||||
|
// (detail content) | Panel 3 (workflow + lịch sử duyệt). Selected HĐ giữ qua
|
||||||
|
// URL `?id=` để bookmarkable + back/forward navigation work.
|
||||||
|
//
|
||||||
|
// Mobile fallback (< lg): hiển thị Panel 1 list, click row → fullpage
|
||||||
|
// /contracts/:id (giữ flow cũ, không cố nhồi 3 panel vào màn hình hẹp).
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { FileText, Plus } from 'lucide-react'
|
import { FileText, Plus, Search, X } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||||
import { DataTable, type Column } from '@/components/DataTable'
|
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
import type { Paged } from '@/types/master'
|
import type { Paged } from '@/types/master'
|
||||||
import type { ContractListItem } from '@/types/contracts'
|
import type { ContractDetail, ContractListItem } from '@/types/contracts'
|
||||||
import { ContractTypeLabel } from '@/types/forms'
|
import { ContractTypeLabel } from '@/types/forms'
|
||||||
|
|
||||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||||
|
|
||||||
export function MyContractsPage() {
|
export function MyContractsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
|
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
|
||||||
|
const selectedId = searchParams.get('id')
|
||||||
|
const search = searchParams.get('q') ?? ''
|
||||||
|
|
||||||
const list = useQuery({
|
const list = useQuery({
|
||||||
queryKey: ['my-contracts', typeFilter],
|
queryKey: ['my-contracts', typeFilter],
|
||||||
queryFn: async () => (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
|
queryFn: async () =>
|
||||||
|
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filter client-side by URL type param (sidebar nested menu passes it)
|
const detail = useQuery({
|
||||||
const rows = useMemo(() => {
|
queryKey: ['contract', selectedId],
|
||||||
const items = list.data?.items ?? []
|
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
|
||||||
return typeFilter == null ? items : items.filter(c => c.type === typeFilter)
|
enabled: !!selectedId,
|
||||||
}, [list.data, typeFilter])
|
})
|
||||||
|
|
||||||
const columns: Column<ContractListItem>[] = [
|
const rows = useMemo(() => {
|
||||||
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
|
let items = list.data?.items ?? []
|
||||||
{ key: 'tenHopDong', header: 'Tên', render: c => c.tenHopDong ?? '—' },
|
if (typeFilter != null) items = items.filter(c => c.type === typeFilter)
|
||||||
{ key: 'type', header: 'Loại', width: 'w-32', render: c => ContractTypeLabel[c.type] ?? '—' },
|
if (search.trim()) {
|
||||||
{ key: 'phase', header: 'Phase', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
|
const q = search.toLowerCase()
|
||||||
{ key: 'supplierName', header: 'NCC', render: c => c.supplierName },
|
items = items.filter(c =>
|
||||||
{ key: 'giaTri', header: 'Giá trị', align: 'right', width: 'w-32', render: c => fmtMoney(c.giaTri) },
|
(c.maHopDong ?? '').toLowerCase().includes(q) ||
|
||||||
{ key: 'slaDeadline', header: 'SLA', width: 'w-40', render: c => <SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} /> },
|
(c.tenHopDong ?? '').toLowerCase().includes(q) ||
|
||||||
]
|
(c.supplierName ?? '').toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [list.data, typeFilter, search])
|
||||||
|
|
||||||
|
function selectContract(id: string) {
|
||||||
|
// Desktop ≥ lg: cập nhật URL để render Panel 2/3 cạnh List.
|
||||||
|
// Mobile: Panel 2/3 hidden → navigate fullpage /contracts/:id (UX khả dụng).
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||||
|
const next = new URLSearchParams(searchParams)
|
||||||
|
next.set('id', id)
|
||||||
|
setSearchParams(next, { replace: false })
|
||||||
|
} else {
|
||||||
|
navigate(`/contracts/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
const next = new URLSearchParams(searchParams)
|
||||||
|
next.delete('id')
|
||||||
|
setSearchParams(next, { replace: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearch(value: string) {
|
||||||
|
const next = new URLSearchParams(searchParams)
|
||||||
|
if (value) next.set('q', value)
|
||||||
|
else next.delete('q')
|
||||||
|
setSearchParams(next, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = typeFilter != null ? ContractTypeLabel[typeFilter] : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
<PageHeader
|
{/* Compact page header — chiếm ít chiều cao để 3 panel có max chỗ */}
|
||||||
title={
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5 text-slate-500" />
|
||||||
HĐ của tôi
|
<h1 className="text-base font-semibold tracking-tight text-slate-900">
|
||||||
|
HĐ của tôi {typeLabel && <span className="text-slate-500">· {typeLabel}</span>}
|
||||||
|
</h1>
|
||||||
|
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
|
{rows.length}
|
||||||
</span>
|
</span>
|
||||||
}
|
</div>
|
||||||
description="Danh sách HĐ bạn đã tạo hoặc tham gia."
|
<Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}>
|
||||||
/>
|
<Plus className="h-4 w-4" />
|
||||||
|
Tạo HĐ mới
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 3-panel grid — flex-1 để fill phần còn lại của viewport */}
|
||||||
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr_360px]">
|
||||||
|
{/* Panel 1 — List */}
|
||||||
|
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
|
<div className="border-b border-slate-200 p-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={e => updateSearch(e.target.value)}
|
||||||
|
placeholder="Tìm theo mã / tên / NCC…"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{list.isLoading && (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!list.isLoading && rows.length === 0 && (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title="Không có HĐ"
|
||||||
|
description={typeFilter != null ? 'Loại HĐ này chưa có dữ liệu.' : 'Tạo HĐ mới để bắt đầu.'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{rows.map(c => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => selectContract(c.id)}
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||||
|
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-slate-900">
|
||||||
|
{c.tenHopDong ?? '(chưa đặt tên)'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
|
<span className="font-mono">{c.maHopDong ?? '—'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{c.supplierName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>{fmtMoney(c.giaTri)}</span>
|
||||||
|
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Panel 2 — Detail content */}
|
||||||
|
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||||
|
{!selectedId && (
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title="Chọn 1 HĐ ở danh sách bên trái"
|
||||||
|
description="Chi tiết HĐ + thông tin / góp ý / file đính kèm sẽ hiển thị ở đây."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedId && detail.isLoading && (
|
||||||
|
<div className="text-sm text-slate-500">Đang tải HĐ…</div>
|
||||||
|
)}
|
||||||
|
{selectedId && detail.data && (
|
||||||
|
<ContractDetailContent contract={detail.data} onBack={clearSelection} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Panel 3 — Workflow + history */}
|
||||||
|
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||||
|
{!selectedId && (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||||
|
<X className="mx-auto mb-2 h-5 w-5" />
|
||||||
|
Quy trình duyệt sẽ hiện khi chọn HĐ.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
rows={rows}
|
|
||||||
getRowKey={c => c.id}
|
|
||||||
isLoading={list.isLoading}
|
|
||||||
empty={
|
|
||||||
<EmptyState
|
|
||||||
icon={FileText}
|
|
||||||
title="Bạn chưa có HĐ nào"
|
|
||||||
description="Tạo HĐ mới để bắt đầu quy trình soạn thảo và trình ký."
|
|
||||||
action={
|
|
||||||
<Button onClick={() => navigate('/contracts/new')}>
|
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
|
||||||
Tạo HĐ mới
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onRowClick={c => navigate(`/contracts/${c.id}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user