All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m39s
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<Tab>('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) <noreply@anthropic.com>
157 lines
5.9 KiB
TypeScript
157 lines
5.9 KiB
TypeScript
// 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<number, React.ComponentType<{ className?: string }>> = {
|
|
1: FileText, // Contract
|
|
2: ListChecks, // Detail
|
|
3: GitBranch, // Workflow
|
|
4: MessageSquare, // Comment
|
|
5: Paperclip, // Attachment
|
|
}
|
|
|
|
const TONE_BY_TYPE: Record<number, string> = {
|
|
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<ContractChangelog[]>(`/contracts/${contractId}/changelogs`)).data,
|
|
})
|
|
|
|
if (q.isLoading) return <div className="text-sm text-slate-500">Đang tải lịch sử…</div>
|
|
if (!q.data || q.data.length === 0) {
|
|
return <div className="rounded-md border border-slate-200 bg-slate-50 p-6 text-center text-sm text-slate-400">Chưa có thao tác nào.</div>
|
|
}
|
|
|
|
return (
|
|
<ol className="space-y-2">
|
|
{q.data.map(entry => <ChangelogItem key={entry.id} entry={entry} />)}
|
|
</ol>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<li className="rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300">
|
|
<div className="flex items-start gap-3">
|
|
<span className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-full', tone)}>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
</span>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-baseline gap-2">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-500">
|
|
{ChangelogEntityTypeLabel[entry.entityType] ?? '—'} · {ChangelogActionLabel[entry.action] ?? '—'}
|
|
</span>
|
|
{entry.phaseAtChange != null && (
|
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
|
|
{ContractPhaseLabel[entry.phaseAtChange] ?? `Phase ${entry.phaseAtChange}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 text-sm font-medium text-slate-900">
|
|
{entry.summary ?? '(Không có mô tả)'}
|
|
</div>
|
|
|
|
<div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
|
|
<span className="flex items-center gap-1">
|
|
<UserIcon className="h-3 w-3" />
|
|
{entry.userName ?? 'Hệ thống'}
|
|
</span>
|
|
<span>{fmt(entry.createdAt)}</span>
|
|
{hasDetails && (
|
|
<button
|
|
onClick={() => setExpanded(e => !e)}
|
|
className="flex items-center gap-0.5 text-brand-600 hover:underline"
|
|
>
|
|
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
{expanded ? 'Thu gọn' : 'Chi tiết'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="mt-2 space-y-2 rounded bg-slate-50 p-2 text-xs">
|
|
{entry.contextNote && (
|
|
<div>
|
|
<div className="text-slate-500">Ghi chú:</div>
|
|
<div className="mt-0.5 whitespace-pre-wrap text-slate-700">{entry.contextNote}</div>
|
|
</div>
|
|
)}
|
|
{entry.fieldChangesJson && <FieldChangesView json={entry.fieldChangesJson} />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
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 <div className="text-slate-400">(JSON parse fail)</div>
|
|
}
|
|
if (changes.length === 0) return null
|
|
return (
|
|
<div>
|
|
<div className="text-slate-500">Thay đổi field:</div>
|
|
<table className="mt-1 w-full text-xs">
|
|
<thead className="text-slate-400">
|
|
<tr>
|
|
<th className="text-left">Field</th>
|
|
<th className="text-left">Cũ</th>
|
|
<th className="text-left">Mới</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{changes.map((c, i) => (
|
|
<tr key={i} className="border-t border-slate-200">
|
|
<td className="py-1 pr-2 font-mono text-[10px]">{c.field}</td>
|
|
<td className="py-1 pr-2 text-slate-500 line-through">{String(c.old ?? '—')}</td>
|
|
<td className="py-1 text-emerald-700">{String(c.new ?? '—')}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|