[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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { PageHeader } from '@/components/PageHeader'
|
||||||
import { DataTable, type Column } from '@/components/DataTable'
|
import { DataTable, type Column } from '@/components/DataTable'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
@ -12,6 +13,30 @@ import { ContractTypeLabel } from '@/types/forms'
|
|||||||
|
|
||||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
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() {
|
export function InboxPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@ -21,14 +46,58 @@ export function InboxPage() {
|
|||||||
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data,
|
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>[] = [
|
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: '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: '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 (
|
return (
|
||||||
@ -43,9 +112,16 @@ export function InboxPage() {
|
|||||||
description={`HĐ chờ vai trò ${user?.roles.join(', ') ?? ''} xử lý. Click row để xem chi tiết.`}
|
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
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={list.data ?? []}
|
rows={rows}
|
||||||
getRowKey={c => c.id}
|
getRowKey={c => c.id}
|
||||||
isLoading={list.isLoading}
|
isLoading={list.isLoading}
|
||||||
empty="Không có HĐ nào chờ bạn xử lý."
|
empty="Không có HĐ nào chờ bạn xử lý."
|
||||||
|
|||||||
Reference in New Issue
Block a user