[CLAUDE] FE-User+FE-Admin: Panel 2 tabs (Tổng quan / Chi tiết / Lịch sử)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m39s
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>
This commit is contained in:
@ -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<number>(ApprovalDecision.Approve)
|
||||
const [comment, setComment] = useState('')
|
||||
const [commentInput, setCommentInput] = useState('')
|
||||
const [tab, setTab] = useState<Tab>('overview')
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async () => {
|
||||
@ -116,62 +122,76 @@ export function ContractDetailContent({
|
||||
</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>
|
||||
{/* Tabs nav — Tổng quan / Chi tiết / Lịch sử */}
|
||||
<nav className="flex gap-1 border-b border-slate-200">
|
||||
<TabButton active={tab === 'overview'} onClick={() => setTab('overview')} icon={Info} label="Tổng quan" />
|
||||
<TabButton active={tab === 'details'} onClick={() => setTab('details')} icon={ListChecks} label={`Chi tiết (${ContractTypeLabel[c.type] ?? ''})`} />
|
||||
<TabButton active={tab === 'history'} onClick={() => setTab('history')} icon={History} label="Lịch sử" />
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
{tab === 'overview' && (
|
||||
<>
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
||||
<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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'details' && <ContractDetailsTab contract={c} />}
|
||||
{tab === 'history' && <ContractChangelogsTab contractId={c.id} />}
|
||||
|
||||
<Dialog
|
||||
open={actionOpen}
|
||||
@ -204,3 +224,27 @@ export function ContractDetailContent({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active, onClick, icon: Icon, label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition',
|
||||
active
|
||||
? 'border-brand-600 font-semibold text-brand-700'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user