[CLAUDE] FE-User: stat cards trên Inbox (total, sắp hạn, quá hạn, tổng giá trị)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { Inbox, AlertTriangle, Clock, FileText } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { DataTable, type Column } from '@/components/DataTable'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
@ -12,6 +13,30 @@ import { ContractTypeLabel } from '@/types/forms'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = 'default',
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
tone?: 'default' | 'warn' | 'danger'
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-brand-600'
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-slate-500">{label}</div>
|
||||
<Icon className={`h-4 w-4 ${toneClass}`} />
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InboxPage() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
@ -21,14 +46,58 @@ export function InboxPage() {
|
||||
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data,
|
||||
})
|
||||
|
||||
const rows = list.data ?? []
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const now = Date.now()
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
let overdue = 0
|
||||
let dueSoon = 0
|
||||
let totalValue = 0
|
||||
for (const c of rows) {
|
||||
totalValue += c.giaTri ?? 0
|
||||
if (!c.slaDeadline) continue
|
||||
const diff = new Date(c.slaDeadline).getTime() - now
|
||||
if (diff < 0) overdue++
|
||||
else if (diff < dayMs) dueSoon++
|
||||
}
|
||||
return { total: rows.length, overdue, dueSoon, totalValue }
|
||||
}, [rows])
|
||||
|
||||
const columns: Column<ContractListItem>[] = [
|
||||
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
|
||||
{
|
||||
key: 'maHopDong',
|
||||
header: 'Mã HĐ',
|
||||
width: 'w-48',
|
||||
render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span>,
|
||||
},
|
||||
{ key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
|
||||
{ key: 'type', header: 'Loại', width: 'w-32', render: c => ContractTypeLabel[c.type] ?? '—' },
|
||||
{ key: 'phase', header: 'Phase hiện tại', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Loại',
|
||||
width: 'w-32',
|
||||
render: c => ContractTypeLabel[c.type] ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'phase',
|
||||
header: 'Phase hiện tại',
|
||||
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} /> },
|
||||
{
|
||||
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} />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@ -43,9 +112,16 @@ export function InboxPage() {
|
||||
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>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={list.data ?? []}
|
||||
rows={rows}
|
||||
getRowKey={c => c.id}
|
||||
isLoading={list.isLoading}
|
||||
empty="Không có HĐ nào chờ bạn xử lý."
|
||||
|
||||
Reference in New Issue
Block a user