diff --git a/fe-admin/src/components/contracts/ContractChangelogsTab.tsx b/fe-admin/src/components/contracts/ContractChangelogsTab.tsx new file mode 100644 index 0000000..abc11a9 --- /dev/null +++ b/fe-admin/src/components/contracts/ContractChangelogsTab.tsx @@ -0,0 +1,156 @@ +// Tab "Lịch sử" — render unified changelog từ /api/contracts/{id}/changelogs. +// Group by ngày, mỗi entry: icon entityType + summary + actor + timestamp + +// optional fieldChanges expand. +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + FileText, ListChecks, GitBranch, MessageSquare, Paperclip, + ChevronRight, ChevronDown, User as UserIcon, +} from 'lucide-react' +import { api } from '@/lib/api' +import { cn } from '@/lib/cn' +import { + ChangelogEntityTypeLabel, + ChangelogActionLabel, + type ContractChangelog, +} from '@/types/contract-details' +import { ContractPhaseLabel } from '@/types/contracts' + +const fmt = (s: string) => new Date(s).toLocaleString('vi-VN') + +const ICON_BY_TYPE: Record> = { + 1: FileText, // Contract + 2: ListChecks, // Detail + 3: GitBranch, // Workflow + 4: MessageSquare, // Comment + 5: Paperclip, // Attachment +} + +const TONE_BY_TYPE: Record = { + 1: 'text-brand-600 bg-brand-50', + 2: 'text-emerald-600 bg-emerald-50', + 3: 'text-fuchsia-600 bg-fuchsia-50', + 4: 'text-amber-600 bg-amber-50', + 5: 'text-slate-600 bg-slate-100', +} + +export function ContractChangelogsTab({ contractId }: { contractId: string }) { + const q = useQuery({ + queryKey: ['contract-changelogs', contractId], + queryFn: async () => (await api.get(`/contracts/${contractId}/changelogs`)).data, + }) + + if (q.isLoading) return
Đang tải lịch sử…
+ if (!q.data || q.data.length === 0) { + return
Chưa có thao tác nào.
+ } + + return ( +
    + {q.data.map(entry => )} +
+ ) +} + +function ChangelogItem({ entry }: { entry: ContractChangelog }) { + const [expanded, setExpanded] = useState(false) + const Icon = ICON_BY_TYPE[entry.entityType] ?? FileText + const tone = TONE_BY_TYPE[entry.entityType] ?? 'text-slate-600 bg-slate-100' + const hasDetails = !!entry.fieldChangesJson || !!entry.contextNote + + return ( +
  • +
    + + + + +
    +
    + + {ChangelogEntityTypeLabel[entry.entityType] ?? '—'} · {ChangelogActionLabel[entry.action] ?? '—'} + + {entry.phaseAtChange != null && ( + + {ContractPhaseLabel[entry.phaseAtChange] ?? `Phase ${entry.phaseAtChange}`} + + )} +
    +
    + {entry.summary ?? '(Không có mô tả)'} +
    + +
    + + + {entry.userName ?? 'Hệ thống'} + + {fmt(entry.createdAt)} + {hasDetails && ( + + )} +
    + + {expanded && ( +
    + {entry.contextNote && ( +
    +
    Ghi chú:
    +
    {entry.contextNote}
    +
    + )} + {entry.fieldChangesJson && } +
    + )} +
    +
    +
  • + ) +} + +function FieldChangesView({ json }: { json: string }) { + type Change = { field: string; old: unknown; new: unknown } + let changes: Change[] = [] + try { + const parsed = JSON.parse(json) + if (Array.isArray(parsed)) { + changes = parsed.map((c: { Field?: string; field?: string; Old?: unknown; old?: unknown; New?: unknown; new?: unknown }) => ({ + field: c.Field ?? c.field ?? '', + old: c.Old ?? c.old, + new: c.New ?? c.new, + })) + } + } catch { + return
    (JSON parse fail)
    + } + if (changes.length === 0) return null + return ( +
    +
    Thay đổi field:
    + + + + + + + + + + {changes.map((c, i) => ( + + + + + + ))} + +
    FieldMới
    {c.field}{String(c.old ?? '—')}{String(c.new ?? '—')}
    +
    + ) +} diff --git a/fe-admin/src/components/contracts/ContractDetailContent.tsx b/fe-admin/src/components/contracts/ContractDetailContent.tsx index 982d64f..80c6620 100644 --- a/fe-admin/src/components/contracts/ContractDetailContent.tsx +++ b/fe-admin/src/components/contracts/ContractDetailContent.tsx @@ -4,17 +4,20 @@ // 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 { ArrowLeft, CheckCircle2, MessageSquare, XCircle, Info, ListChecks, History } from 'lucide-react' import { toast } from 'sonner' import { PhaseBadge } from '@/components/PhaseBadge' import { SlaTimer } from '@/components/SlaTimer' import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection' +import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab' +import { ContractChangelogsTab } from '@/components/contracts/ContractChangelogsTab' 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 { cn } from '@/lib/cn' import { ApprovalDecision, ContractPhase, @@ -26,6 +29,8 @@ import { ContractTypeLabel } from '@/types/forms' const fmt = (s: string) => new Date(s).toLocaleString('vi-VN') const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND' +type Tab = 'overview' | 'details' | 'history' + export function ContractDetailContent({ contract: c, onBack, @@ -40,6 +45,7 @@ export function ContractDetailContent({ const [decision, setDecision] = useState(ApprovalDecision.Approve) const [comment, setComment] = useState('') const [commentInput, setCommentInput] = useState('') + const [tab, setTab] = useState('overview') const transition = useMutation({ mutationFn: async () => { @@ -116,62 +122,76 @@ export function ContractDetailContent({ -
    -

    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}
    -
    - )} -
    + {/* Tabs nav — Tổng quan / Chi tiết / Lịch sử */} + -
    -

    - - 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]} + {tab === 'overview' && ( + <> +
    +

    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
    +
    -
    {cm.content}
    -
    - ))} -
    -
    { - e.preventDefault() - if (!commentInput.trim()) return - addComment.mutate(commentInput.trim()) - }} - > -