From 0e5b5cd670505e98c7772f28294ae613e55bec3f Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 15:05:42 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-User:=20stat=20cards=20tr=C3=AAn?= =?UTF-8?q?=20Inbox=20(total,=20s=E1=BA=AFp=20h=E1=BA=A1n,=20qu=C3=A1=20h?= =?UTF-8?q?=E1=BA=A1n,=20t=E1=BB=95ng=20gi=C3=A1=20tr=E1=BB=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fe-user/src/pages/InboxPage.tsx | 90 ++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/fe-user/src/pages/InboxPage.tsx b/fe-user/src/pages/InboxPage.tsx index d70e779..ed491b8 100644 --- a/fe-user/src/pages/InboxPage.tsx +++ b/fe-user/src/pages/InboxPage.tsx @@ -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 ( +
+
+
{label}
+ +
+
{value}
+
+ ) +} + export function InboxPage() { const navigate = useNavigate() const { user } = useAuth() @@ -21,14 +46,58 @@ export function InboxPage() { queryFn: async () => (await api.get('/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[] = [ - { key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => {c.maHopDong ?? '—'} }, + { + key: 'maHopDong', + header: 'Mã HĐ', + width: 'w-48', + render: c => {c.maHopDong ?? '—'}, + }, { 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 => }, + { + 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 => , + }, { 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 => }, + { + key: 'giaTri', + header: 'Giá trị', + align: 'right', + width: 'w-32', + render: c => fmtMoney(c.giaTri), + }, + { + key: 'slaDeadline', + header: 'SLA', + width: 'w-40', + render: c => , + }, ] return ( @@ -43,9 +112,16 @@ export function InboxPage() { description={`HĐ chờ vai trò ${user?.roles.join(', ') ?? ''} xử lý. Click row để xem chi tiết.`} /> +
+ + + + +
+ c.id} isLoading={list.isLoading} empty="Không có HĐ nào chờ bạn xử lý."