[CLAUDE] FE-User: 3-panel layout cho InboxPage (Hộp thư)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m46s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m46s
Apply cùng pattern Danh sách (commitb75448e) cho Hộp thư để consistent trải nghiệm — Ct_*_List và Ct_*_Pending menu cùng UX 3-panel. ## Thay đổi InboxPage - Bỏ DataTable + StatCard 4-card grid lớn → header compact với StatPill inline (Cần xử lý / Sắp quá hạn / Quá hạn / Giá trị) → Panel 1 max chỗ - Vai trò user banner amber 1 dòng (transparent về scope filtering) - 3-panel grid lg:grid-cols-[320px_1fr_360px] h-[calc(100vh-4rem)]: Panel 1: search + list pending compact, overdue highlight border-l-red, click update ?id= active highlight ring-brand Panel 2: ContractDetailContent embedded (sticky header + Yêu cầu sửa / Duyệt → tiếp buttons sẵn để duyệt từ inbox) Panel 3: WorkflowHistoryPanel (timeline + lịch sử duyệt full) - Mobile (<lg): chỉ Panel 1 visible, click row → fullpage /contracts/:id - URL state: ?type=X (sidebar menu Ct_*_Pending) + ?id (selected) + ?q (search) — bookmarkable ## Reuse components (đã tạo commitb75448e) - ContractDetailContent.tsx — không cần thay đổi - WorkflowHistoryPanel.tsx — không cần thay đổi ## Build verified fe-user: tsc -b + vite build pass (1888 modules, 1.08MB JS, 686ms) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,19 +1,28 @@
|
|||||||
|
// 3-panel "Hộp thư" — HĐ chờ role tôi xử lý. Stats row + Panel 1 (list
|
||||||
|
// pending) | Panel 2 (detail content) | Panel 3 (workflow + lịch sử duyệt).
|
||||||
|
// Selected qua URL ?id= để bookmarkable.
|
||||||
|
//
|
||||||
|
// Khác MyContractsPage: data từ /contracts/inbox (BE đã filter HĐ chờ role
|
||||||
|
// tôi), thêm 4-card stats overdue/dueSoon/total/totalValue ở header.
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { Inbox, AlertTriangle, Clock, FileText } from 'lucide-react'
|
import { Inbox, AlertTriangle, Clock, FileText, Search, X } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
|
||||||
import { DataTable, type Column } from '@/components/DataTable'
|
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import type { ContractListItem } from '@/types/contracts'
|
import { cn } from '@/lib/cn'
|
||||||
|
import type { ContractDetail, ContractListItem } from '@/types/contracts'
|
||||||
import { ContractTypeLabel } from '@/types/forms'
|
import { ContractTypeLabel } from '@/types/forms'
|
||||||
|
|
||||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||||
|
|
||||||
function StatCard({
|
function StatPill({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@ -25,14 +34,16 @@ function StatCard({
|
|||||||
tone?: 'default' | 'warn' | 'danger'
|
tone?: 'default' | 'warn' | 'danger'
|
||||||
}) {
|
}) {
|
||||||
const toneClass =
|
const toneClass =
|
||||||
tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-brand-600'
|
tone === 'danger' ? 'text-red-600 bg-red-50' :
|
||||||
|
tone === 'warn' ? 'text-amber-600 bg-amber-50' :
|
||||||
|
'text-brand-600 bg-brand-50'
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs">
|
||||||
<div className="flex items-center justify-between">
|
<span className={cn('flex h-5 w-5 items-center justify-center rounded', toneClass)}>
|
||||||
<div className="text-xs font-medium text-slate-500">{label}</div>
|
<Icon className="h-3 w-3" />
|
||||||
<Icon className={`h-4 w-4 ${toneClass}`} />
|
</span>
|
||||||
</div>
|
<span className="text-slate-500">{label}</span>
|
||||||
<div className="mt-2 text-2xl font-bold text-slate-900">{value}</div>
|
<span className="font-semibold text-slate-900">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -40,17 +51,38 @@ function StatCard({
|
|||||||
export function InboxPage() {
|
export function InboxPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
|
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
|
||||||
|
const selectedId = searchParams.get('id')
|
||||||
|
const search = searchParams.get('q') ?? ''
|
||||||
|
|
||||||
const list = useQuery({
|
const list = useQuery({
|
||||||
queryKey: ['inbox'],
|
queryKey: ['inbox'],
|
||||||
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data,
|
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ['contract', selectedId],
|
||||||
|
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
|
||||||
|
enabled: !!selectedId,
|
||||||
|
})
|
||||||
|
|
||||||
const allRows = list.data ?? []
|
const allRows = list.data ?? []
|
||||||
// Apply type filter from sidebar nested menu (?type=X)
|
|
||||||
const rows = typeFilter == null ? allRows : allRows.filter(c => c.type === typeFilter)
|
const rows = useMemo(() => {
|
||||||
|
let items = allRows
|
||||||
|
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
|
||||||
|
}, [allRows, typeFilter, search])
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@ -68,69 +100,156 @@ export function InboxPage() {
|
|||||||
return { total: rows.length, overdue, dueSoon, totalValue }
|
return { total: rows.length, overdue, dueSoon, totalValue }
|
||||||
}, [rows])
|
}, [rows])
|
||||||
|
|
||||||
const columns: Column<ContractListItem>[] = [
|
function selectContract(id: string) {
|
||||||
{
|
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||||
key: 'maHopDong',
|
const next = new URLSearchParams(searchParams)
|
||||||
header: 'Mã HĐ',
|
next.set('id', id)
|
||||||
width: 'w-48',
|
setSearchParams(next, { replace: false })
|
||||||
render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span>,
|
} else {
|
||||||
},
|
navigate(`/contracts/${id}`)
|
||||||
{ key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
|
}
|
||||||
{
|
}
|
||||||
key: 'type',
|
|
||||||
header: 'Loại',
|
function clearSelection() {
|
||||||
width: 'w-32',
|
const next = new URLSearchParams(searchParams)
|
||||||
render: c => ContractTypeLabel[c.type] ?? '—',
|
next.delete('id')
|
||||||
},
|
setSearchParams(next, { replace: false })
|
||||||
{
|
}
|
||||||
key: 'phase',
|
|
||||||
header: 'Phase hiện tại',
|
function updateSearch(value: string) {
|
||||||
width: 'w-36',
|
const next = new URLSearchParams(searchParams)
|
||||||
render: c => <PhaseBadge phase={c.phase} />,
|
if (value) next.set('q', value)
|
||||||
},
|
else next.delete('q')
|
||||||
{ key: 'supplierName', header: 'NCC', render: c => c.supplierName },
|
setSearchParams(next, { replace: true })
|
||||||
{
|
}
|
||||||
key: 'giaTri',
|
|
||||||
header: 'Giá trị',
|
const typeLabel = typeFilter != null ? ContractTypeLabel[typeFilter] : null
|
||||||
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} />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
<PageHeader
|
{/* Header — title + stats inline để Panel 1 max chỗ */}
|
||||||
title={
|
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Inbox className="h-5 w-5" />
|
<Inbox className="h-5 w-5 text-slate-500" />
|
||||||
Hộp thư
|
<h1 className="text-base font-semibold tracking-tight text-slate-900">
|
||||||
</span>
|
Hộp thư {typeLabel && <span className="text-slate-500">· {typeLabel}</span>}
|
||||||
}
|
</h1>
|
||||||
description={`HĐ chờ vai trò ${user?.roles.join(', ') ?? ''} xử lý. Click row để xem chi tiết.`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
||||||
<StatCard icon={Inbox} label="Cần xử lý" value={stats.total} />
|
|
||||||
<StatCard icon={Clock} label="Sắp quá hạn (24h)" value={stats.dueSoon} tone="warn" />
|
|
||||||
<StatCard icon={AlertTriangle} label="Đã quá hạn" value={stats.overdue} tone="danger" />
|
|
||||||
<StatCard icon={FileText} label="Tổng giá trị" value={fmtMoney(stats.totalValue)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-auto flex flex-wrap gap-2">
|
||||||
|
<StatPill icon={Inbox} label="Cần xử lý" value={stats.total} />
|
||||||
|
<StatPill icon={Clock} label="Sắp quá hạn" value={stats.dueSoon} tone="warn" />
|
||||||
|
<StatPill icon={AlertTriangle} label="Quá hạn" value={stats.overdue} tone="danger" />
|
||||||
|
<StatPill icon={FileText} label="Giá trị" value={fmtMoney(stats.totalValue)} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<DataTable
|
{user?.roles.length ? (
|
||||||
columns={columns}
|
<div className="shrink-0 border-b border-slate-200 bg-amber-50 px-6 py-1.5 text-[11px] text-amber-700">
|
||||||
rows={rows}
|
Vai trò bạn đang xử lý: <span className="font-medium">{user.roles.join(', ')}</span>
|
||||||
getRowKey={c => c.id}
|
</div>
|
||||||
isLoading={list.isLoading}
|
) : null}
|
||||||
empty="Không có HĐ nào chờ bạn xử lý."
|
|
||||||
onRowClick={c => navigate(`/contracts/${c.id}`)}
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr_360px]">
|
||||||
|
{/* Panel 1 — Pending 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>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{list.isLoading && (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 5 }).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={Inbox}
|
||||||
|
title="Hộp thư trống"
|
||||||
|
description={
|
||||||
|
typeFilter != null
|
||||||
|
? `Không có ${ContractTypeLabel[typeFilter]} nào đang chờ.`
|
||||||
|
: 'Không có HĐ nào chờ bạn xử lý.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{rows.map(c => {
|
||||||
|
const overdue = c.slaDeadline && new Date(c.slaDeadline).getTime() < Date.now()
|
||||||
|
return (
|
||||||
|
<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',
|
||||||
|
overdue && selectedId !== c.id && 'border-l-2 border-l-red-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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={Inbox}
|
||||||
|
title="Chọn 1 HĐ ở danh sách bên trái"
|
||||||
|
description="Chi tiết HĐ + góp ý + đính kèm + nút Duyệt 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>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user