From b3762afbc3315df68eb461b819aa8f8d0f8734e6 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 23 Apr 2026 10:24:00 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-User+FE-Admin:=20Panel=202=20tabs?= =?UTF-8?q?=20(T=E1=BB=95ng=20quan=20/=20Chi=20ti=E1=BA=BFt=20/=20L?= =?UTF-8?q?=E1=BB=8Bch=20s=E1=BB=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phần 2.6 — UI cho 4-bảng data model overhaul. Panel 2 trong 3-panel layout giờ tabbed thay flat, expose 7 Details + Changelog cho user. ## Files mới (4 per app — cố ý duplicate theo project convention) ### types/contract-details.ts 7 typed interfaces (ThauPhuDetail, GiaoKhoanDetail, NhaCungCapDetail, DichVuDetail, MuaBanDetail, NguyenTacNccDetail, NguyenTacDvDetail) + ContractDetailsBundle wrapper (chỉ 1 list có data theo Type) + ContractChangelog + 4 enum const-objects với Vietnamese label. ### components/contracts/ContractDetailsTab.tsx (~400 dòng) - Auto-pick render component theo bundle.type (7 table renderers) - Mỗi table: header per type + columns thanhTien total + delete row btn - AddRowForm sử dụng FIELDS_BY_TYPE config (5-7 field per type) - buildPayload auto compute thanhTien (SL × DonGia × (1+VAT/100) cho MuaBan) - canEdit chỉ khi Phase = DangSoanThao (sau khi nộp HĐ → khóa edit details) - Banner amber cảnh báo khi không edit được ### components/contracts/ContractChangelogsTab.tsx (~150 dòng) - Render unified changelog list desc CreatedAt - Icon + tone color theo EntityType (5 loại: Contract/Detail/Workflow/ Comment/Attachment) - Expandable detail row hiển thị FieldChangesJson (parse JSON, render table Field|Cũ|Mới với strike-through old + emerald new) - Show contextNote khi có ## Files sửa (2 per app) ### components/contracts/ContractDetailContent.tsx - Thêm useState('overview') - Tabs nav (TabButton helper, border-b underline active brand-700) - 3 tab: Tổng quan (Info + Comments + Attachments) | Chi tiết (Details table + add form) | Lịch sử (changelog timeline) - Wrap Tổng quan content trong fragment, conditionally render ## Build verify - fe-user: tsc + vite pass (521ms, 1888 modules) - fe-admin: tsc + vite pass (997ms, 1903 modules) ## Test flow desktop 1. /my-contracts → click HĐ → Panel 2 mở tab "Tổng quan" (default) 2. Click tab "Chi tiết (HĐ Mua bán)" → table line items + add row form 3. Add row "Xi măng / kg / 1000 / 50000" → POST /contracts/{id}/details/mua-ban → table refresh + changelog tự log "Thêm SP: Xi măng" 4. Click tab "Lịch sử" → thấy entries: Tạo HĐ, Thêm SP Xi măng, ... 5. Tab giữ state khi switch HĐ khác (per-component state) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../contracts/ContractChangelogsTab.tsx | 156 ++++++ .../contracts/ContractDetailContent.tsx | 152 ++++-- .../contracts/ContractDetailsTab.tsx | 477 ++++++++++++++++++ fe-admin/src/types/contract-details.ts | 158 ++++++ .../contracts/ContractChangelogsTab.tsx | 156 ++++++ .../contracts/ContractDetailContent.tsx | 148 ++++-- .../contracts/ContractDetailsTab.tsx | 477 ++++++++++++++++++ fe-user/src/types/contract-details.ts | 158 ++++++ 8 files changed, 1776 insertions(+), 106 deletions(-) create mode 100644 fe-admin/src/components/contracts/ContractChangelogsTab.tsx create mode 100644 fe-admin/src/components/contracts/ContractDetailsTab.tsx create mode 100644 fe-admin/src/types/contract-details.ts create mode 100644 fe-user/src/components/contracts/ContractChangelogsTab.tsx create mode 100644 fe-user/src/components/contracts/ContractDetailsTab.tsx create mode 100644 fe-user/src/types/contract-details.ts 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()) - }} - > -