diff --git a/fe-admin/src/components/contracts/ContractDetailContent.tsx b/fe-admin/src/components/contracts/ContractDetailContent.tsx new file mode 100644 index 0000000..982d64f --- /dev/null +++ b/fe-admin/src/components/contracts/ContractDetailContent.tsx @@ -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(0) + const [decision, setDecision] = useState(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 ( +
+ {/* Header — sticky inside scroll container */} +
+
+
+
+ {onBack && ( + + )} +

+ {c.tenHopDong ?? 'HĐ chưa đặt tên'} +

+
+
+ {c.maHopDong ?? '(chưa có mã)'} + +
+
+ {availableTargets.length > 0 && ( +
+ + +
+ )} +
+
+ +
+

Thông tin HĐ

+
+
Loại
{ContractTypeLabel[c.type] ?? '—'}
+
Giá trị
{fmtMoney(c.giaTri)}
+
NCC
{c.supplierName}
+
Dự án
{c.projectName}
+
Phòng ban
{c.departmentName ?? '—'}
+
Người soạn
{c.drafterName ?? '—'}
+
+
SLA
+
+
+
Bypass CCM
{c.bypassProcurementAndCCM ? 'Có (HĐ Chủ đầu tư)' : 'Không'}
+
+ {c.noiDung && ( +
+
Nội dung
+
{c.noiDung}
+
+ )} +
+ +
+

+ + Góp ý ({c.comments.length}) +

+
+ {c.comments.length === 0 &&
Chưa có góp ý.
} + {c.comments.map(cm => ( +
+
+ {cm.userName} + {fmt(cm.createdAt)} · {ContractPhaseLabel[cm.phase]} +
+
{cm.content}
+
+ ))} +
+
{ + e.preventDefault() + if (!commentInput.trim()) return + addComment.mutate(commentInput.trim()) + }} + > +