[CLAUDE] FE-User+FE-Admin: 3-panel layout cho danh sách HĐ
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m50s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m50s
Redesign trang Danh sách HĐ (Ct_*_List menu fe-user + /contracts admin)
thành 3-panel: List | Detail content | Workflow + lịch sử duyệt. Selected
HĐ giữ qua URL ?id= (bookmarkable + back/forward navigation work).
## Components mới (reuse cho cả 3-panel embedded + fullpage detail)
### fe-user/src/components/contracts/
- ContractDetailContent.tsx — Panel 2 body: header sticky (title + phase
+ actions Yêu cầu sửa/Duyệt) + Info section + Comments thread + form
thêm góp ý + Attachments. Transition Dialog inline. Prop optional
onBack — render arrow back button (fullpage) hoặc skip (embedded).
- WorkflowHistoryPanel.tsx — Panel 3: WorkflowSummaryCard (timeline
policy current+next) + Lịch sử duyệt (approvals: phase from→to + actor
+ timestamp + comment).
### fe-admin/src/components/contracts/
- ContractDetailContent.tsx — variant admin có thêm Phòng ban + Bypass
CCM trong Info section. Invalidate ['contracts'] khi transition.
- WorkflowHistoryPanel.tsx — identical fe-user.
## Trang refactored
### fe-user
- MyContractsPage.tsx — bỏ DataTable, dùng 3-panel grid
lg:grid-cols-[320px_1fr_360px] h-[calc(100vh-4rem)]:
Panel 1: search box + list compact (mã/tên/NCC/phase/SLA/giá), click
update ?id= active highlight ring-brand
Panel 2: detail content embedded
Panel 3: workflow + history
Mobile (<lg): chỉ Panel 1 visible, click row navigate fullpage
/contracts/:id (UX khả dụng, không nhồi 3 panel màn hình hẹp).
URL state: ?type=X (filter loại) + ?id= (selected) + ?q= (search).
- ContractDetailPage.tsx — slim version dùng ContractDetailContent +
WorkflowHistoryPanel, giữ deep link /contracts/:id work.
### fe-admin
- ContractsListPage.tsx — 3-panel + filter phase + pagination compact
trong Panel 1 footer. URL state: ?type, ?pendingMe, ?id, ?q, ?phase,
?page (full bookmarkable). Title hiển thị loại HĐ + count badge.
- ContractDetailPage.tsx — slim version giống fe-user.
## Build verified
- fe-user: tsc -b + vite build pass (1888 modules, 1.08MB JS)
- fe-admin: tsc -b + vite build pass (1903 modules, 1.15MB JS)
Note: npm install resolved @microsoft/signalr 8.0.7 → 8.0.17 (within
^8.0.7 caret), reverted package.json + lock changes do bump không phải
scope task này. Dev tiếp theo run npm install sẽ tự re-resolve.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,78 +1,202 @@
|
||||
// 3-panel "Danh sách HĐ" — Panel 1 (list compact, click chọn) | Panel 2
|
||||
// (detail content) | Panel 3 (workflow + lịch sử duyệt). Selected HĐ giữ qua
|
||||
// URL `?id=` để bookmarkable + back/forward navigation work.
|
||||
//
|
||||
// Mobile fallback (< lg): hiển thị Panel 1 list, click row → fullpage
|
||||
// /contracts/:id (giữ flow cũ, không cố nhồi 3 panel vào màn hình hẹp).
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { FileText, Plus } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { DataTable, type Column } from '@/components/DataTable'
|
||||
import { FileText, Plus, Search, X } from 'lucide-react'
|
||||
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { Paged } from '@/types/master'
|
||||
import type { ContractListItem } from '@/types/contracts'
|
||||
import type { ContractDetail, ContractListItem } from '@/types/contracts'
|
||||
import { ContractTypeLabel } from '@/types/forms'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
export function MyContractsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
|
||||
const selectedId = searchParams.get('id')
|
||||
const search = searchParams.get('q') ?? ''
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['my-contracts', typeFilter],
|
||||
queryFn: async () => (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
|
||||
})
|
||||
|
||||
// Filter client-side by URL type param (sidebar nested menu passes it)
|
||||
const rows = useMemo(() => {
|
||||
const items = list.data?.items ?? []
|
||||
return typeFilter == null ? items : items.filter(c => c.type === typeFilter)
|
||||
}, [list.data, typeFilter])
|
||||
const detail = useQuery({
|
||||
queryKey: ['contract', selectedId],
|
||||
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
const columns: Column<ContractListItem>[] = [
|
||||
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
|
||||
{ key: 'tenHopDong', header: 'Tên', render: c => c.tenHopDong ?? '—' },
|
||||
{ key: 'type', header: 'Loại', width: 'w-32', render: c => ContractTypeLabel[c.type] ?? '—' },
|
||||
{ key: 'phase', header: 'Phase', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
|
||||
{ key: 'supplierName', header: 'NCC', render: c => c.supplierName },
|
||||
{ key: 'giaTri', header: 'Giá trị', align: 'right', width: 'w-32', render: c => fmtMoney(c.giaTri) },
|
||||
{ key: 'slaDeadline', header: 'SLA', width: 'w-40', render: c => <SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} /> },
|
||||
]
|
||||
const rows = useMemo(() => {
|
||||
let items = list.data?.items ?? []
|
||||
if (typeFilter != null) items = items.filter(c => c.type === typeFilter)
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase()
|
||||
items = items.filter(c =>
|
||||
(c.maHopDong ?? '').toLowerCase().includes(q) ||
|
||||
(c.tenHopDong ?? '').toLowerCase().includes(q) ||
|
||||
(c.supplierName ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return items
|
||||
}, [list.data, typeFilter, search])
|
||||
|
||||
function selectContract(id: string) {
|
||||
// Desktop ≥ lg: cập nhật URL để render Panel 2/3 cạnh List.
|
||||
// Mobile: Panel 2/3 hidden → navigate fullpage /contracts/:id (UX khả dụng).
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.set('id', id)
|
||||
setSearchParams(next, { replace: false })
|
||||
} else {
|
||||
navigate(`/contracts/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('id')
|
||||
setSearchParams(next, { replace: false })
|
||||
}
|
||||
|
||||
function updateSearch(value: string) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (value) next.set('q', value)
|
||||
else next.delete('q')
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
|
||||
const typeLabel = typeFilter != null ? ContractTypeLabel[typeFilter] : null
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
HĐ của tôi
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
{/* Compact page header — chiếm ít chiều cao để 3 panel có max chỗ */}
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-slate-500" />
|
||||
<h1 className="text-base font-semibold tracking-tight text-slate-900">
|
||||
HĐ của tôi {typeLabel && <span className="text-slate-500">· {typeLabel}</span>}
|
||||
</h1>
|
||||
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
{rows.length}
|
||||
</span>
|
||||
}
|
||||
description="Danh sách HĐ bạn đã tạo hoặc tham gia."
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo HĐ mới
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* 3-panel grid — flex-1 để fill phần còn lại của viewport */}
|
||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr_360px]">
|
||||
{/* Panel 1 — List */}
|
||||
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => updateSearch(e.target.value)}
|
||||
placeholder="Tìm theo mã / tên / NCC…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{list.isLoading && (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!list.isLoading && rows.length === 0 && (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="Không có HĐ"
|
||||
description={typeFilter != null ? 'Loại HĐ này chưa có dữ liệu.' : 'Tạo HĐ mới để bắt đầu.'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{rows.map(c => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
onClick={() => selectContract(c.id)}
|
||||
className={cn(
|
||||
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-slate-900">
|
||||
{c.tenHopDong ?? '(chưa đặt tên)'}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span className="font-mono">{c.maHopDong ?? '—'}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{c.supplierName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span>{fmtMoney(c.giaTri)}</span>
|
||||
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Panel 2 — Detail content */}
|
||||
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||
{!selectedId && (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="Chọn 1 HĐ ở danh sách bên trái"
|
||||
description="Chi tiết HĐ + thông tin / góp ý / file đính kèm sẽ hiển thị ở đây."
|
||||
/>
|
||||
)}
|
||||
{selectedId && detail.isLoading && (
|
||||
<div className="text-sm text-slate-500">Đang tải HĐ…</div>
|
||||
)}
|
||||
{selectedId && detail.data && (
|
||||
<ContractDetailContent contract={detail.data} onBack={clearSelection} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Panel 3 — Workflow + history */}
|
||||
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||
{!selectedId && (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||
<X className="mx-auto mb-2 h-5 w-5" />
|
||||
Quy trình duyệt sẽ hiện khi chọn HĐ.
|
||||
</div>
|
||||
)}
|
||||
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowKey={c => c.id}
|
||||
isLoading={list.isLoading}
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="Bạn chưa có HĐ nào"
|
||||
description="Tạo HĐ mới để bắt đầu quy trình soạn thảo và trình ký."
|
||||
action={
|
||||
<Button onClick={() => navigate('/contracts/new')}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Tạo HĐ mới
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
onRowClick={c => navigate(`/contracts/${c.id}`)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user