[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

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:
pqhuy1987
2026-04-23 10:24:00 +07:00
parent e6844553a4
commit b3762afbc3
8 changed files with 1776 additions and 106 deletions

View File

@ -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<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 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"></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>
)
}

View File

@ -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 </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 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 </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 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>
)
}

View File

@ -0,0 +1,477 @@
// Tab "Chi tiết" — hiện table line items theo loại HĐ (7 schema khác nhau,
// auto pick render component theo bundle.type). Add row form ở footer (chỉ
// khi Phase=DangSoanThao và user là drafter — owner mới được edit details).
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { ContractPhase, type ContractDetail } from '@/types/contracts'
import type {
ContractDetailsBundle,
ThauPhuDetail,
GiaoKhoanDetail,
NhaCungCapDetail,
DichVuDetail,
MuaBanDetail,
NguyenTacNccDetail,
NguyenTacDvDetail,
} from '@/types/contract-details'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Map ContractType → URL slug + render config
type TypeKey = 'thau-phu' | 'giao-khoan' | 'nha-cung-cap' | 'dich-vu' | 'mua-ban' | 'nguyen-tac-ncc' | 'nguyen-tac-dv'
const TYPE_TO_SLUG: Record<number, TypeKey> = {
1: 'thau-phu',
2: 'giao-khoan',
3: 'nha-cung-cap',
4: 'dich-vu',
5: 'mua-ban',
6: 'nguyen-tac-ncc',
7: 'nguyen-tac-dv',
}
export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
const qc = useQueryClient()
const canEdit = contract.phase === ContractPhase.DangSoanThao
const bundleQuery = useQuery({
queryKey: ['contract-details', contract.id],
queryFn: async () => (await api.get<ContractDetailsBundle>(`/contracts/${contract.id}/details`)).data,
})
const deleteRow = useMutation({
mutationFn: async (detailId: string) => {
await api.delete(`/contracts/${contract.id}/details/${detailId}`)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
toast.success('Đã xóa')
},
onError: err => toast.error(getErrorMessage(err)),
})
if (bundleQuery.isLoading) return <div className="text-sm text-slate-500">Đang tải chi tiết</div>
if (!bundleQuery.data) return <div className="text-sm text-slate-500">Không chi tiết.</div>
const bundle = bundleQuery.data
return (
<div className="space-y-3">
{bundle.type === 1 && <ThauPhuTable rows={bundle.thauPhu} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 2 && <GiaoKhoanTable rows={bundle.giaoKhoan} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 3 && <NhaCungCapTable rows={bundle.nhaCungCap} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 4 && <DichVuTable rows={bundle.dichVu} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 5 && <MuaBanTable rows={bundle.muaBan} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 6 && <NguyenTacNccTable rows={bundle.nguyenTacNcc} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 7 && <NguyenTacDvTable rows={bundle.nguyenTacDv} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{canEdit && (
<AddRowForm
contractId={contract.id}
contractType={contract.type}
existingCount={getRowCount(bundle)}
onAdded={() => {
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
}}
/>
)}
{!canEdit && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
Chỉ sửa đưc chi tiết khi phase "Đang soạn thảo". Hiện tại đã chuyển sang phase khác.
</div>
)}
</div>
)
}
function getRowCount(bundle: ContractDetailsBundle): number {
return (
bundle.thauPhu.length + bundle.giaoKhoan.length + bundle.nhaCungCap.length +
bundle.dichVu.length + bundle.muaBan.length + bundle.nguyenTacNcc.length +
bundle.nguyenTacDv.length
)
}
// ===== Per-type table renderers (gộp 1 file để dễ maintain) =====
function TableShell({ headers, totalRow, children }: { headers: string[]; totalRow?: React.ReactNode; children: React.ReactNode }) {
return (
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr>
<th className="w-10 px-2 py-2 text-left">#</th>
{headers.map(h => <th key={h} className="px-2 py-2 text-left">{h}</th>)}
<th className="w-10 px-2 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{children}
</tbody>
{totalRow}
</table>
</div>
)
}
function DeleteBtn({ onDelete }: { onDelete: () => void }) {
return (
<button
onClick={() => { if (confirm('Xóa dòng này?')) onDelete() }}
className="text-slate-400 hover:text-red-600"
aria-label="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)
}
function totalOf(rows: { thanhTien: number }[]): number {
return rows.reduce((s, r) => s + (r.thanhTien ?? 0), 0)
}
function ThauPhuTable({ rows, onDelete, canEdit }: { rows: ThauPhuDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Hạng mục', 'ĐVT', 'Khối lượng', 'Đơn giá', 'Thành tiền', 'Hoàn thành', 'Ghi chú']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={5} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td colSpan={3} /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={9} className="px-3 py-6 text-center text-xs text-slate-400">Chưa hạng mục.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5">{r.hangMuc}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.khoiLuong)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.ghiChu ?? ''}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function GiaoKhoanTable({ rows, onDelete, canEdit }: { rows: GiaoKhoanDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã CV', 'Tên công việc', 'ĐVT', 'KL', 'Đơn giá', 'Thành tiền', 'Hoàn thành']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={6} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td colSpan={2} /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={9} className="px-3 py-6 text-center text-xs text-slate-400">Chưa công việc.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5 font-mono text-xs">{r.maCongViec}</td>
<td className="px-2 py-1.5">{r.tenCongViec}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.khoiLuong)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NhaCungCapTable({ rows, onDelete, canEdit }: { rows: NhaCungCapDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'Thành tiền', 'Giao hàng']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={6} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td colSpan={2} /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={9} className="px-3 py-6 text-center text-xs text-slate-400">Chưa sản phẩm.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5 font-mono text-xs">{r.maSP}</td>
<td className="px-2 py-1.5">{r.tenSP}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.soLuong)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianGiao ? new Date(r.thoiGianGiao).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function DichVuTable({ rows, onDelete, canEdit }: { rows: DichVuDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã DV', 'Tên DV', 'ĐVT', 'Thời gian', 'Đơn giá', 'Thành tiền']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={5} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td colSpan={2} /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={8} className="px-3 py-6 text-center text-xs text-slate-400">Chưa dịch vụ.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5 font-mono text-xs">{r.maDichVu}</td>
<td className="px-2 py-1.5">{r.tenDichVu}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.thoiGian)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function MuaBanTable({ rows, onDelete, canEdit }: { rows: MuaBanDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'VAT (%)', 'Thành tiền']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={7} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={9} className="px-3 py-6 text-center text-xs text-slate-400">Chưa sản phẩm.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5 font-mono text-xs">{r.maSP}</td>
<td className="px-2 py-1.5">{r.tenSP}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.soLuong)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right text-xs">{r.thueVAT}%</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NguyenTacNccTable({ rows, onDelete, canEdit }: { rows: NguyenTacNccDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell headers={['Nhóm SP', 'Tên SP', 'ĐVT', 'Giá min', 'Giá max', 'Điều kiện thanh toán']}>
{rows.length === 0 && <tr><td colSpan={8} className="px-3 py-6 text-center text-xs text-slate-400">Chưa SP.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5">{r.nhomSP}</td>
<td className="px-2 py-1.5">{r.tenSP}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.dieuKienThanhToan ?? '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NguyenTacDvTable({ rows, onDelete, canEdit }: { rows: NguyenTacDvDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell headers={['Loại DV', 'Tên DV', 'ĐVT', 'Giá min', 'Giá max', 'SLA']}>
{rows.length === 0 && <tr><td colSpan={8} className="px-3 py-6 text-center text-xs text-slate-400">Chưa DV.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5">{r.loaiDichVu}</td>
<td className="px-2 py-1.5">{r.tenDichVu}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.sla ?? '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
// ===== Add row form — minimal: chỉ field bắt buộc, advanced edit qua FE
// fullpage form sau (Iter 2). Hiện tại hỗ trợ quick-add 5-7 field per type.
function AddRowForm({
contractId, contractType, existingCount, onAdded,
}: {
contractId: string
contractType: number
existingCount: number
onAdded: () => void
}) {
const [open, setOpen] = useState(false)
const slug = useMemo(() => TYPE_TO_SLUG[contractType], [contractType])
if (!slug) return null
return (
<div>
{!open && (
<Button variant="outline" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
Thêm dòng
</Button>
)}
{open && (
<AddRowFields
contractId={contractId}
slug={slug}
contractType={contractType}
nextOrder={existingCount + 1}
onCancel={() => setOpen(false)}
onAdded={() => { setOpen(false); onAdded() }}
/>
)}
</div>
)
}
function AddRowFields({
contractId, slug, contractType, nextOrder, onCancel, onAdded,
}: {
contractId: string
slug: TypeKey
contractType: number
nextOrder: number
onCancel: () => void
onAdded: () => void
}) {
const [form, setForm] = useState<Record<string, string>>({})
const submit = useMutation({
mutationFn: async () => {
const payload = buildPayload(contractType, nextOrder, form)
await api.post(`/contracts/${contractId}/details/${slug}`, payload)
},
onSuccess: () => { toast.success('Đã thêm dòng'); onAdded() },
onError: err => toast.error(getErrorMessage(err)),
})
const fields = FIELDS_BY_TYPE[contractType] ?? []
return (
<form
onSubmit={e => { e.preventDefault(); submit.mutate() }}
className="space-y-2 rounded-lg border border-brand-200 bg-brand-50/30 p-3"
>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{fields.map(f => (
<div key={f.name} className="space-y-1">
<label className="text-[11px] font-medium text-slate-600">{f.label}</label>
<Input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
value={form[f.name] ?? ''}
onChange={e => setForm(s => ({ ...s, [f.name]: e.target.value }))}
placeholder={f.placeholder}
step={f.type === 'number' ? 'any' : undefined}
required={f.required}
className="text-xs"
/>
</div>
))}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>Hủy</Button>
<Button type="submit" disabled={submit.isPending}>
{submit.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
</div>
</form>
)
}
type FieldDef = { name: string; label: string; type: 'text' | 'number' | 'date'; required?: boolean; placeholder?: string }
const FIELDS_BY_TYPE: Record<number, FieldDef[]> = {
1: [ // ThauPhu
{ name: 'hangMuc', label: 'Hạng mục *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, placeholder: 'm2, kg, ngày...' },
{ name: 'khoiLuong', label: 'Khối lượng *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
{ name: 'ghiChu', label: 'Ghi chú', type: 'text' },
],
2: [ // GiaoKhoan
{ name: 'maCongViec', label: 'Mã CV *', type: 'text', required: true },
{ name: 'tenCongViec', label: 'Tên công việc *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'khoiLuong', label: 'KL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
],
3: [ // NhaCungCap
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianGiao', label: 'Giao hàng', type: 'date' },
{ name: 'xuatXu', label: 'Xuất xứ', type: 'text' },
],
4: [ // DichVu
{ name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'thoiGian', label: 'Thời gian *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
],
5: [ // MuaBan
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thueVAT', label: 'VAT (%)', type: 'number', placeholder: '10' },
],
6: [ // NguyenTacNcc
{ name: 'nhomSP', label: 'Nhóm SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true },
],
7: [ // NguyenTacDv
{ name: 'loaiDichVu', label: 'Loại DV *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true },
],
}
// Build payload — convert string form values to typed fields BE expects.
// thanhTien auto compute = soLuong * donGia (or khoiLuong * donGia).
function buildPayload(contractType: number, order: number, form: Record<string, string>): Record<string, unknown> {
const num = (k: string) => Number(form[k] ?? 0)
const str = (k: string) => form[k] ?? null
const date = (k: string) => form[k] ? new Date(form[k]).toISOString() : null
const common = { id: '00000000-0000-0000-0000-000000000000', order, ghiChu: str('ghiChu') }
switch (contractType) {
case 1: // ThauPhu
return { ...common, hangMuc: form.hangMuc, donViTinh: form.donViTinh, khoiLuong: num('khoiLuong'), donGia: num('donGia'), thanhTien: num('khoiLuong') * num('donGia'), thoiGianHoanThanh: date('thoiGianHoanThanh') }
case 2: // GiaoKhoan
return { ...common, maCongViec: form.maCongViec, tenCongViec: form.tenCongViec, donViTinh: form.donViTinh, khoiLuong: num('khoiLuong'), donGia: num('donGia'), thanhTien: num('khoiLuong') * num('donGia'), thoiGianHoanThanh: date('thoiGianHoanThanh'), yeuCauKyThuat: str('yeuCauKyThuat') }
case 3: // NhaCungCap
return { ...common, maSP: form.maSP, tenSP: form.tenSP, thongSoKyThuat: str('thongSoKyThuat'), donViTinh: form.donViTinh, soLuong: num('soLuong'), donGia: num('donGia'), thanhTien: num('soLuong') * num('donGia'), thoiGianGiao: date('thoiGianGiao'), xuatXu: str('xuatXu') }
case 4: // DichVu
return { ...common, maDichVu: form.maDichVu, tenDichVu: form.tenDichVu, moTa: str('moTa'), donViTinh: form.donViTinh, thoiGian: num('thoiGian'), donGia: num('donGia'), thanhTien: num('thoiGian') * num('donGia'), tuNgay: date('tuNgay'), denNgay: date('denNgay') }
case 5: // MuaBan — thanhTien = SL * DonGia * (1 + VAT/100)
return { ...common, maSP: form.maSP, tenSP: form.tenSP, moTa: str('moTa'), donViTinh: form.donViTinh, soLuong: num('soLuong'), donGia: num('donGia'), thueVAT: num('thueVAT'), thanhTien: num('soLuong') * num('donGia') * (1 + num('thueVAT') / 100), xuatXu: str('xuatXu') }
case 6: // NguyenTacNcc
return { ...common, nhomSP: form.nhomSP, tenSP: form.tenSP, donViTinh: form.donViTinh, donGiaToiThieu: num('donGiaToiThieu'), donGiaToiDa: num('donGiaToiDa'), dieuKienGiaoHang: str('dieuKienGiaoHang'), dieuKienThanhToan: str('dieuKienThanhToan') }
case 7: // NguyenTacDv
return { ...common, loaiDichVu: form.loaiDichVu, tenDichVu: form.tenDichVu, donViTinh: form.donViTinh, donGiaToiThieu: num('donGiaToiThieu'), donGiaToiDa: num('donGiaToiDa'), phamViDichVu: str('phamViDichVu'), sla: str('sla') }
}
return common
}

View File

@ -0,0 +1,158 @@
// Types cho 7 ContractType-specific Details + Changelog. 1-1 với BE DTOs ở
// Application/Contracts/Dtos/ContractDetailDtos.cs.
export type ThauPhuDetail = {
id: string
order: number
hangMuc: string
donViTinh: string
khoiLuong: number
donGia: number
thanhTien: number
thoiGianHoanThanh: string | null
ghiChu: string | null
}
export type GiaoKhoanDetail = {
id: string
order: number
maCongViec: string
tenCongViec: string
donViTinh: string
khoiLuong: number
donGia: number
thanhTien: number
thoiGianHoanThanh: string | null
yeuCauKyThuat: string | null
ghiChu: string | null
}
export type NhaCungCapDetail = {
id: string
order: number
maSP: string
tenSP: string
thongSoKyThuat: string | null
donViTinh: string
soLuong: number
donGia: number
thanhTien: number
thoiGianGiao: string | null
xuatXu: string | null
ghiChu: string | null
}
export type DichVuDetail = {
id: string
order: number
maDichVu: string
tenDichVu: string
moTa: string | null
donViTinh: string
thoiGian: number
donGia: number
thanhTien: number
tuNgay: string | null
denNgay: string | null
ghiChu: string | null
}
export type MuaBanDetail = {
id: string
order: number
maSP: string
tenSP: string
moTa: string | null
donViTinh: string
soLuong: number
donGia: number
thueVAT: number
thanhTien: number
xuatXu: string | null
ghiChu: string | null
}
export type NguyenTacNccDetail = {
id: string
order: number
nhomSP: string
tenSP: string
donViTinh: string
donGiaToiThieu: number
donGiaToiDa: number
dieuKienGiaoHang: string | null
dieuKienThanhToan: string | null
ghiChu: string | null
}
export type NguyenTacDvDetail = {
id: string
order: number
loaiDichVu: string
tenDichVu: string
donViTinh: string
donGiaToiThieu: number
donGiaToiDa: number
phamViDichVu: string | null
sla: string | null
ghiChu: string | null
}
export type ContractDetailsBundle = {
type: number // ContractType int
thauPhu: ThauPhuDetail[]
giaoKhoan: GiaoKhoanDetail[]
nhaCungCap: NhaCungCapDetail[]
dichVu: DichVuDetail[]
muaBan: MuaBanDetail[]
nguyenTacNcc: NguyenTacNccDetail[]
nguyenTacDv: NguyenTacDvDetail[]
}
// ===== Changelog =====
export const ChangelogEntityType = {
Contract: 1,
Detail: 2,
Workflow: 3,
Comment: 4,
Attachment: 5,
} as const
export type ChangelogEntityType = typeof ChangelogEntityType[keyof typeof ChangelogEntityType]
export const ChangelogEntityTypeLabel: Record<number, string> = {
1: 'HĐ',
2: 'Chi tiết',
3: 'Quy trình',
4: 'Góp ý',
5: 'File đính kèm',
}
export const ChangelogAction = {
Insert: 1,
Update: 2,
Delete: 3,
Transition: 4,
} as const
export type ChangelogAction = typeof ChangelogAction[keyof typeof ChangelogAction]
export const ChangelogActionLabel: Record<number, string> = {
1: 'Tạo',
2: 'Cập nhật',
3: 'Xóa',
4: 'Chuyển phase',
}
export type ContractChangelog = {
id: string
entityType: number
entityId: string | null
action: number
phaseAtChange: number | null
userId: string | null
userName: string | null
summary: string | null
fieldChangesJson: string | null
contextNote: string | null
createdAt: string
}

View File

@ -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<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 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"></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>
)
}

View File

@ -4,17 +4,20 @@
// approval history live separately in 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,
@ -23,6 +26,8 @@ import {
} from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
type Tab = 'overview' | 'details' | 'history'
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
@ -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,60 +122,74 @@ 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 </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">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>
</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 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 </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">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>
</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 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}
@ -202,3 +222,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>
)
}

View File

@ -0,0 +1,477 @@
// Tab "Chi tiết" — hiện table line items theo loại HĐ (7 schema khác nhau,
// auto pick render component theo bundle.type). Add row form ở footer (chỉ
// khi Phase=DangSoanThao và user là drafter — owner mới được edit details).
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { ContractPhase, type ContractDetail } from '@/types/contracts'
import type {
ContractDetailsBundle,
ThauPhuDetail,
GiaoKhoanDetail,
NhaCungCapDetail,
DichVuDetail,
MuaBanDetail,
NguyenTacNccDetail,
NguyenTacDvDetail,
} from '@/types/contract-details'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Map ContractType → URL slug + render config
type TypeKey = 'thau-phu' | 'giao-khoan' | 'nha-cung-cap' | 'dich-vu' | 'mua-ban' | 'nguyen-tac-ncc' | 'nguyen-tac-dv'
const TYPE_TO_SLUG: Record<number, TypeKey> = {
1: 'thau-phu',
2: 'giao-khoan',
3: 'nha-cung-cap',
4: 'dich-vu',
5: 'mua-ban',
6: 'nguyen-tac-ncc',
7: 'nguyen-tac-dv',
}
export function ContractDetailsTab({ contract }: { contract: ContractDetail }) {
const qc = useQueryClient()
const canEdit = contract.phase === ContractPhase.DangSoanThao
const bundleQuery = useQuery({
queryKey: ['contract-details', contract.id],
queryFn: async () => (await api.get<ContractDetailsBundle>(`/contracts/${contract.id}/details`)).data,
})
const deleteRow = useMutation({
mutationFn: async (detailId: string) => {
await api.delete(`/contracts/${contract.id}/details/${detailId}`)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
toast.success('Đã xóa')
},
onError: err => toast.error(getErrorMessage(err)),
})
if (bundleQuery.isLoading) return <div className="text-sm text-slate-500">Đang tải chi tiết</div>
if (!bundleQuery.data) return <div className="text-sm text-slate-500">Không chi tiết.</div>
const bundle = bundleQuery.data
return (
<div className="space-y-3">
{bundle.type === 1 && <ThauPhuTable rows={bundle.thauPhu} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 2 && <GiaoKhoanTable rows={bundle.giaoKhoan} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 3 && <NhaCungCapTable rows={bundle.nhaCungCap} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 4 && <DichVuTable rows={bundle.dichVu} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 5 && <MuaBanTable rows={bundle.muaBan} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 6 && <NguyenTacNccTable rows={bundle.nguyenTacNcc} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{bundle.type === 7 && <NguyenTacDvTable rows={bundle.nguyenTacDv} onDelete={deleteRow.mutate} canEdit={canEdit} />}
{canEdit && (
<AddRowForm
contractId={contract.id}
contractType={contract.type}
existingCount={getRowCount(bundle)}
onAdded={() => {
qc.invalidateQueries({ queryKey: ['contract-details', contract.id] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
}}
/>
)}
{!canEdit && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
Chỉ sửa đưc chi tiết khi phase "Đang soạn thảo". Hiện tại đã chuyển sang phase khác.
</div>
)}
</div>
)
}
function getRowCount(bundle: ContractDetailsBundle): number {
return (
bundle.thauPhu.length + bundle.giaoKhoan.length + bundle.nhaCungCap.length +
bundle.dichVu.length + bundle.muaBan.length + bundle.nguyenTacNcc.length +
bundle.nguyenTacDv.length
)
}
// ===== Per-type table renderers (gộp 1 file để dễ maintain) =====
function TableShell({ headers, totalRow, children }: { headers: string[]; totalRow?: React.ReactNode; children: React.ReactNode }) {
return (
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-[11px] uppercase tracking-wider text-slate-500">
<tr>
<th className="w-10 px-2 py-2 text-left">#</th>
{headers.map(h => <th key={h} className="px-2 py-2 text-left">{h}</th>)}
<th className="w-10 px-2 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{children}
</tbody>
{totalRow}
</table>
</div>
)
}
function DeleteBtn({ onDelete }: { onDelete: () => void }) {
return (
<button
onClick={() => { if (confirm('Xóa dòng này?')) onDelete() }}
className="text-slate-400 hover:text-red-600"
aria-label="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)
}
function totalOf(rows: { thanhTien: number }[]): number {
return rows.reduce((s, r) => s + (r.thanhTien ?? 0), 0)
}
function ThauPhuTable({ rows, onDelete, canEdit }: { rows: ThauPhuDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Hạng mục', 'ĐVT', 'Khối lượng', 'Đơn giá', 'Thành tiền', 'Hoàn thành', 'Ghi chú']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={5} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td colSpan={3} /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={9} className="px-3 py-6 text-center text-xs text-slate-400">Chưa hạng mục.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5">{r.hangMuc}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.khoiLuong)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.ghiChu ?? ''}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function GiaoKhoanTable({ rows, onDelete, canEdit }: { rows: GiaoKhoanDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã CV', 'Tên công việc', 'ĐVT', 'KL', 'Đơn giá', 'Thành tiền', 'Hoàn thành']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={6} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td colSpan={2} /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={9} className="px-3 py-6 text-center text-xs text-slate-400">Chưa công việc.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5 font-mono text-xs">{r.maCongViec}</td>
<td className="px-2 py-1.5">{r.tenCongViec}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.khoiLuong)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianHoanThanh ? new Date(r.thoiGianHoanThanh).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NhaCungCapTable({ rows, onDelete, canEdit }: { rows: NhaCungCapDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'Thành tiền', 'Giao hàng']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={6} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td colSpan={2} /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={9} className="px-3 py-6 text-center text-xs text-slate-400">Chưa sản phẩm.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5 font-mono text-xs">{r.maSP}</td>
<td className="px-2 py-1.5">{r.tenSP}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.soLuong)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.thoiGianGiao ? new Date(r.thoiGianGiao).toLocaleDateString('vi-VN') : '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function DichVuTable({ rows, onDelete, canEdit }: { rows: DichVuDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã DV', 'Tên DV', 'ĐVT', 'Thời gian', 'Đơn giá', 'Thành tiền']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={5} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td colSpan={2} /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={8} className="px-3 py-6 text-center text-xs text-slate-400">Chưa dịch vụ.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5 font-mono text-xs">{r.maDichVu}</td>
<td className="px-2 py-1.5">{r.tenDichVu}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.thoiGian)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function MuaBanTable({ rows, onDelete, canEdit }: { rows: MuaBanDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell
headers={['Mã SP', 'Tên SP', 'ĐVT', 'SL', 'Đơn giá', 'VAT (%)', 'Thành tiền']}
totalRow={rows.length > 0 ? <tfoot className="bg-slate-50"><tr><td colSpan={7} className="px-2 py-2 text-right text-xs font-semibold">Tổng:</td><td className="px-2 py-2 font-semibold text-brand-700">{fmtMoney(totalOf(rows))}</td><td /></tr></tfoot> : undefined}
>
{rows.length === 0 && <tr><td colSpan={9} className="px-3 py-6 text-center text-xs text-slate-400">Chưa sản phẩm.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5 font-mono text-xs">{r.maSP}</td>
<td className="px-2 py-1.5">{r.tenSP}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.soLuong)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGia)}</td>
<td className="px-2 py-1.5 text-right text-xs">{r.thueVAT}%</td>
<td className="px-2 py-1.5 text-right font-medium">{fmtMoney(r.thanhTien)}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NguyenTacNccTable({ rows, onDelete, canEdit }: { rows: NguyenTacNccDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell headers={['Nhóm SP', 'Tên SP', 'ĐVT', 'Giá min', 'Giá max', 'Điều kiện thanh toán']}>
{rows.length === 0 && <tr><td colSpan={8} className="px-3 py-6 text-center text-xs text-slate-400">Chưa SP.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5">{r.nhomSP}</td>
<td className="px-2 py-1.5">{r.tenSP}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.dieuKienThanhToan ?? '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
function NguyenTacDvTable({ rows, onDelete, canEdit }: { rows: NguyenTacDvDetail[]; onDelete: (id: string) => void; canEdit: boolean }) {
return (
<TableShell headers={['Loại DV', 'Tên DV', 'ĐVT', 'Giá min', 'Giá max', 'SLA']}>
{rows.length === 0 && <tr><td colSpan={8} className="px-3 py-6 text-center text-xs text-slate-400">Chưa DV.</td></tr>}
{rows.map((r, i) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 text-xs text-slate-500">{i + 1}</td>
<td className="px-2 py-1.5">{r.loaiDichVu}</td>
<td className="px-2 py-1.5">{r.tenDichVu}</td>
<td className="px-2 py-1.5 text-xs">{r.donViTinh}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiThieu)}</td>
<td className="px-2 py-1.5 text-right">{fmtMoney(r.donGiaToiDa)}</td>
<td className="px-2 py-1.5 text-xs text-slate-500">{r.sla ?? '—'}</td>
<td className="px-2 py-1.5">{canEdit && <DeleteBtn onDelete={() => onDelete(r.id)} />}</td>
</tr>
))}
</TableShell>
)
}
// ===== Add row form — minimal: chỉ field bắt buộc, advanced edit qua FE
// fullpage form sau (Iter 2). Hiện tại hỗ trợ quick-add 5-7 field per type.
function AddRowForm({
contractId, contractType, existingCount, onAdded,
}: {
contractId: string
contractType: number
existingCount: number
onAdded: () => void
}) {
const [open, setOpen] = useState(false)
const slug = useMemo(() => TYPE_TO_SLUG[contractType], [contractType])
if (!slug) return null
return (
<div>
{!open && (
<Button variant="outline" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
Thêm dòng
</Button>
)}
{open && (
<AddRowFields
contractId={contractId}
slug={slug}
contractType={contractType}
nextOrder={existingCount + 1}
onCancel={() => setOpen(false)}
onAdded={() => { setOpen(false); onAdded() }}
/>
)}
</div>
)
}
function AddRowFields({
contractId, slug, contractType, nextOrder, onCancel, onAdded,
}: {
contractId: string
slug: TypeKey
contractType: number
nextOrder: number
onCancel: () => void
onAdded: () => void
}) {
const [form, setForm] = useState<Record<string, string>>({})
const submit = useMutation({
mutationFn: async () => {
const payload = buildPayload(contractType, nextOrder, form)
await api.post(`/contracts/${contractId}/details/${slug}`, payload)
},
onSuccess: () => { toast.success('Đã thêm dòng'); onAdded() },
onError: err => toast.error(getErrorMessage(err)),
})
const fields = FIELDS_BY_TYPE[contractType] ?? []
return (
<form
onSubmit={e => { e.preventDefault(); submit.mutate() }}
className="space-y-2 rounded-lg border border-brand-200 bg-brand-50/30 p-3"
>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{fields.map(f => (
<div key={f.name} className="space-y-1">
<label className="text-[11px] font-medium text-slate-600">{f.label}</label>
<Input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
value={form[f.name] ?? ''}
onChange={e => setForm(s => ({ ...s, [f.name]: e.target.value }))}
placeholder={f.placeholder}
step={f.type === 'number' ? 'any' : undefined}
required={f.required}
className="text-xs"
/>
</div>
))}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>Hủy</Button>
<Button type="submit" disabled={submit.isPending}>
{submit.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
</div>
</form>
)
}
type FieldDef = { name: string; label: string; type: 'text' | 'number' | 'date'; required?: boolean; placeholder?: string }
const FIELDS_BY_TYPE: Record<number, FieldDef[]> = {
1: [ // ThauPhu
{ name: 'hangMuc', label: 'Hạng mục *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true, placeholder: 'm2, kg, ngày...' },
{ name: 'khoiLuong', label: 'Khối lượng *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
{ name: 'ghiChu', label: 'Ghi chú', type: 'text' },
],
2: [ // GiaoKhoan
{ name: 'maCongViec', label: 'Mã CV *', type: 'text', required: true },
{ name: 'tenCongViec', label: 'Tên công việc *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'khoiLuong', label: 'KL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianHoanThanh', label: 'Hoàn thành', type: 'date' },
],
3: [ // NhaCungCap
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thoiGianGiao', label: 'Giao hàng', type: 'date' },
{ name: 'xuatXu', label: 'Xuất xứ', type: 'text' },
],
4: [ // DichVu
{ name: 'maDichVu', label: 'Mã DV *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'thoiGian', label: 'Thời gian *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
],
5: [ // MuaBan
{ name: 'maSP', label: 'Mã SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'soLuong', label: 'SL *', type: 'number', required: true },
{ name: 'donGia', label: 'Đơn giá *', type: 'number', required: true },
{ name: 'thueVAT', label: 'VAT (%)', type: 'number', placeholder: '10' },
],
6: [ // NguyenTacNcc
{ name: 'nhomSP', label: 'Nhóm SP *', type: 'text', required: true },
{ name: 'tenSP', label: 'Tên SP *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true },
],
7: [ // NguyenTacDv
{ name: 'loaiDichVu', label: 'Loại DV *', type: 'text', required: true },
{ name: 'tenDichVu', label: 'Tên DV *', type: 'text', required: true },
{ name: 'donViTinh', label: 'ĐVT *', type: 'text', required: true },
{ name: 'donGiaToiThieu', label: 'Giá min *', type: 'number', required: true },
{ name: 'donGiaToiDa', label: 'Giá max *', type: 'number', required: true },
],
}
// Build payload — convert string form values to typed fields BE expects.
// thanhTien auto compute = soLuong * donGia (or khoiLuong * donGia).
function buildPayload(contractType: number, order: number, form: Record<string, string>): Record<string, unknown> {
const num = (k: string) => Number(form[k] ?? 0)
const str = (k: string) => form[k] ?? null
const date = (k: string) => form[k] ? new Date(form[k]).toISOString() : null
const common = { id: '00000000-0000-0000-0000-000000000000', order, ghiChu: str('ghiChu') }
switch (contractType) {
case 1: // ThauPhu
return { ...common, hangMuc: form.hangMuc, donViTinh: form.donViTinh, khoiLuong: num('khoiLuong'), donGia: num('donGia'), thanhTien: num('khoiLuong') * num('donGia'), thoiGianHoanThanh: date('thoiGianHoanThanh') }
case 2: // GiaoKhoan
return { ...common, maCongViec: form.maCongViec, tenCongViec: form.tenCongViec, donViTinh: form.donViTinh, khoiLuong: num('khoiLuong'), donGia: num('donGia'), thanhTien: num('khoiLuong') * num('donGia'), thoiGianHoanThanh: date('thoiGianHoanThanh'), yeuCauKyThuat: str('yeuCauKyThuat') }
case 3: // NhaCungCap
return { ...common, maSP: form.maSP, tenSP: form.tenSP, thongSoKyThuat: str('thongSoKyThuat'), donViTinh: form.donViTinh, soLuong: num('soLuong'), donGia: num('donGia'), thanhTien: num('soLuong') * num('donGia'), thoiGianGiao: date('thoiGianGiao'), xuatXu: str('xuatXu') }
case 4: // DichVu
return { ...common, maDichVu: form.maDichVu, tenDichVu: form.tenDichVu, moTa: str('moTa'), donViTinh: form.donViTinh, thoiGian: num('thoiGian'), donGia: num('donGia'), thanhTien: num('thoiGian') * num('donGia'), tuNgay: date('tuNgay'), denNgay: date('denNgay') }
case 5: // MuaBan — thanhTien = SL * DonGia * (1 + VAT/100)
return { ...common, maSP: form.maSP, tenSP: form.tenSP, moTa: str('moTa'), donViTinh: form.donViTinh, soLuong: num('soLuong'), donGia: num('donGia'), thueVAT: num('thueVAT'), thanhTien: num('soLuong') * num('donGia') * (1 + num('thueVAT') / 100), xuatXu: str('xuatXu') }
case 6: // NguyenTacNcc
return { ...common, nhomSP: form.nhomSP, tenSP: form.tenSP, donViTinh: form.donViTinh, donGiaToiThieu: num('donGiaToiThieu'), donGiaToiDa: num('donGiaToiDa'), dieuKienGiaoHang: str('dieuKienGiaoHang'), dieuKienThanhToan: str('dieuKienThanhToan') }
case 7: // NguyenTacDv
return { ...common, loaiDichVu: form.loaiDichVu, tenDichVu: form.tenDichVu, donViTinh: form.donViTinh, donGiaToiThieu: num('donGiaToiThieu'), donGiaToiDa: num('donGiaToiDa'), phamViDichVu: str('phamViDichVu'), sla: str('sla') }
}
return common
}

View File

@ -0,0 +1,158 @@
// Types cho 7 ContractType-specific Details + Changelog. 1-1 với BE DTOs ở
// Application/Contracts/Dtos/ContractDetailDtos.cs.
export type ThauPhuDetail = {
id: string
order: number
hangMuc: string
donViTinh: string
khoiLuong: number
donGia: number
thanhTien: number
thoiGianHoanThanh: string | null
ghiChu: string | null
}
export type GiaoKhoanDetail = {
id: string
order: number
maCongViec: string
tenCongViec: string
donViTinh: string
khoiLuong: number
donGia: number
thanhTien: number
thoiGianHoanThanh: string | null
yeuCauKyThuat: string | null
ghiChu: string | null
}
export type NhaCungCapDetail = {
id: string
order: number
maSP: string
tenSP: string
thongSoKyThuat: string | null
donViTinh: string
soLuong: number
donGia: number
thanhTien: number
thoiGianGiao: string | null
xuatXu: string | null
ghiChu: string | null
}
export type DichVuDetail = {
id: string
order: number
maDichVu: string
tenDichVu: string
moTa: string | null
donViTinh: string
thoiGian: number
donGia: number
thanhTien: number
tuNgay: string | null
denNgay: string | null
ghiChu: string | null
}
export type MuaBanDetail = {
id: string
order: number
maSP: string
tenSP: string
moTa: string | null
donViTinh: string
soLuong: number
donGia: number
thueVAT: number
thanhTien: number
xuatXu: string | null
ghiChu: string | null
}
export type NguyenTacNccDetail = {
id: string
order: number
nhomSP: string
tenSP: string
donViTinh: string
donGiaToiThieu: number
donGiaToiDa: number
dieuKienGiaoHang: string | null
dieuKienThanhToan: string | null
ghiChu: string | null
}
export type NguyenTacDvDetail = {
id: string
order: number
loaiDichVu: string
tenDichVu: string
donViTinh: string
donGiaToiThieu: number
donGiaToiDa: number
phamViDichVu: string | null
sla: string | null
ghiChu: string | null
}
export type ContractDetailsBundle = {
type: number // ContractType int
thauPhu: ThauPhuDetail[]
giaoKhoan: GiaoKhoanDetail[]
nhaCungCap: NhaCungCapDetail[]
dichVu: DichVuDetail[]
muaBan: MuaBanDetail[]
nguyenTacNcc: NguyenTacNccDetail[]
nguyenTacDv: NguyenTacDvDetail[]
}
// ===== Changelog =====
export const ChangelogEntityType = {
Contract: 1,
Detail: 2,
Workflow: 3,
Comment: 4,
Attachment: 5,
} as const
export type ChangelogEntityType = typeof ChangelogEntityType[keyof typeof ChangelogEntityType]
export const ChangelogEntityTypeLabel: Record<number, string> = {
1: 'HĐ',
2: 'Chi tiết',
3: 'Quy trình',
4: 'Góp ý',
5: 'File đính kèm',
}
export const ChangelogAction = {
Insert: 1,
Update: 2,
Delete: 3,
Transition: 4,
} as const
export type ChangelogAction = typeof ChangelogAction[keyof typeof ChangelogAction]
export const ChangelogActionLabel: Record<number, string> = {
1: 'Tạo',
2: 'Cập nhật',
3: 'Xóa',
4: 'Chuyển phase',
}
export type ContractChangelog = {
id: string
entityType: number
entityId: string | null
action: number
phaseAtChange: number | null
userId: string | null
userName: string | null
summary: string | null
fieldChangesJson: string | null
contextNote: string | null
createdAt: string
}