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>
207 lines
8.9 KiB
TypeScript
207 lines
8.9 KiB
TypeScript
// 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>
|
|
)
|
|
}
|