[CLAUDE] FE-User: tách Tổng quan thành /dashboard riêng (fix bug trùng /inbox)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m47s

Bug: Layout resolvePath map "Dashboard" key → "/inbox" cũ (coi inbox là
home), khiến menu "Tổng quan" và "Hộp thư" cùng navigate về /inbox →
user thấy interface giống nhau, không phân biệt được.

Fix:
- Tạo UserDashboardPage.tsx — overview cá nhân:
  * Greeting với fullName
  * 5-card "Của tôi" row (HĐ đang soạn / Chờ tôi duyệt / Sắp quá hạn /
    Đã quá hạn / Tổng giá trị nháp) — dùng /api/reports/my-dashboard có sẵn
  * Card click navigate vào page tương ứng (/my-contracts hoặc /inbox)
  * Section HĐ gần đây — list 5 row với click → /my-contracts?id=X
- App.tsx: thêm route /dashboard + redirect "/" sang /dashboard
- Layout.tsx: Dashboard → /dashboard, logo link cũng chuyển về /dashboard

Build: tsc + vite pass (439ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 10:01:54 +07:00
parent 7ea3957acc
commit d326e80082
3 changed files with 208 additions and 3 deletions

View File

@ -4,6 +4,7 @@ import { AuthProvider } from '@/contexts/AuthContext'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Layout } from '@/components/Layout' import { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { UserDashboardPage } from '@/pages/UserDashboardPage'
import { InboxPage } from '@/pages/InboxPage' import { InboxPage } from '@/pages/InboxPage'
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage' import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
@ -22,11 +23,12 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
> >
<Route path="/dashboard" element={<UserDashboardPage />} />
<Route path="/inbox" element={<InboxPage />} /> <Route path="/inbox" element={<InboxPage />} />
<Route path="/contracts/new" element={<ContractCreatePage />} /> <Route path="/contracts/new" element={<ContractCreatePage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} /> <Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/my-contracts" element={<MyContractsPage />} /> <Route path="/my-contracts" element={<MyContractsPage />} />
<Route path="/" element={<Navigate to="/inbox" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route <Route
path="*" path="*"
element={ element={

View File

@ -39,7 +39,7 @@ function getCtGroupCode(key: string): string | null {
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval). // /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
function resolvePath(key: string): string | null { function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = { const staticMap: Record<string, string> = {
Dashboard: '/inbox', // user home = inbox Dashboard: '/dashboard', // Tổng quan riêng — KHÔNG trùng /inbox (Hộp thư)
Contracts: '/my-contracts', Contracts: '/my-contracts',
} }
if (staticMap[key]) return staticMap[key] if (staticMap[key]) return staticMap[key]
@ -228,7 +228,7 @@ export function Layout() {
<div className="flex h-screen"> <div className="flex h-screen">
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white"> <aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
<div className="flex h-16 items-center border-b border-slate-200 px-5"> <div className="flex h-16 items-center border-b border-slate-200 px-5">
<Link to="/inbox" className="flex items-center gap-2.5"> <Link to="/dashboard" className="flex items-center gap-2.5">
<img src="/logo.png" alt="Solutions" className="h-8 w-auto" /> <img src="/logo.png" alt="Solutions" className="h-8 w-auto" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">ERP</span> <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">ERP</span>
</Link> </Link>

View File

@ -0,0 +1,203 @@
// "Tổng quan" cho user — overview KPI cá nhân + recent contracts. Khác Inbox
// (chờ duyệt) — Dashboard cho user nhìn nhanh tất cả HĐ liên quan.
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { FileText, CheckCircle2, AlertTriangle, Pencil, Clock, Inbox, Coins } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import type { Paged } from '@/types/master'
import type { ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
type MyDashboard = {
draftsInProgress: number
pendingMyApproval: number
dueSoon: number
overdue: number
draftsTotalValue: number
}
const fmtMoney = (v: number) => {
if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + ' tỷ'
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + ' tr'
return v.toLocaleString('vi-VN')
}
function StatCard({
icon: Icon,
label,
value,
hint,
tone = 'default',
onClick,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
hint?: string
tone?: 'default' | 'warn' | 'good' | 'danger'
onClick?: () => void
}) {
const toneClass =
tone === 'warn' ? 'text-amber-600 bg-amber-50' :
tone === 'good' ? 'text-emerald-600 bg-emerald-50' :
tone === 'danger' ? 'text-red-600 bg-red-50' :
'text-brand-600 bg-brand-50'
return (
<button
onClick={onClick}
disabled={!onClick}
className="rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:shadow-md disabled:cursor-default disabled:hover:shadow-sm"
>
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-slate-500">{label}</div>
<span className={`flex h-6 w-6 items-center justify-center rounded ${toneClass}`}>
<Icon className="h-3.5 w-3.5" />
</span>
</div>
<div className="mt-2 text-2xl font-bold text-slate-900">{value}</div>
{hint && <div className="mt-0.5 text-xs text-slate-400">{hint}</div>}
</button>
)
}
export function UserDashboardPage() {
const navigate = useNavigate()
const { user } = useAuth()
const stats = useQuery({
queryKey: ['my-dashboard'],
queryFn: async () => (await api.get<MyDashboard>('/reports/my-dashboard')).data,
})
const recent = useQuery({
queryKey: ['my-contracts-recent'],
queryFn: async () =>
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 5 } })).data,
})
const s = stats.data
return (
<div className="p-6">
<PageHeader
title={`Xin chào, ${user?.fullName ?? user?.email ?? 'bạn'}`}
description="Tổng quan công việc HĐ của bạn — click vào card để xem chi tiết."
/>
<section className="mb-6">
<h2 className="mb-3 text-sm font-semibold text-slate-700">Của tôi</h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<StatCard
icon={Pencil}
label="HĐ đang soạn"
value={s?.draftsInProgress ?? '—'}
hint="Chưa hoàn tất"
onClick={() => navigate('/my-contracts')}
/>
<StatCard
icon={Inbox}
label="Chờ tôi duyệt"
value={s?.pendingMyApproval ?? '—'}
hint="Vào Hộp thư"
tone="good"
onClick={() => navigate('/inbox')}
/>
<StatCard
icon={Clock}
label="Sắp quá hạn"
value={s?.dueSoon ?? '—'}
hint="< 24h"
tone="warn"
onClick={() => navigate('/inbox')}
/>
<StatCard
icon={AlertTriangle}
label="Đã quá hạn"
value={s?.overdue ?? '—'}
tone="danger"
onClick={() => navigate('/inbox')}
/>
<StatCard
icon={Coins}
label="Tổng giá trị nháp"
value={s ? fmtMoney(s.draftsTotalValue) : '—'}
onClick={() => navigate('/my-contracts')}
/>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white">
<header className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<FileText className="h-4 w-4" />
gần đây
</h2>
<Button variant="outline" onClick={() => navigate('/my-contracts')}>
Xem tất cả
</Button>
</header>
{recent.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!recent.isLoading && (recent.data?.items?.length ?? 0) === 0 && (
<div className="p-6">
<EmptyState
icon={FileText}
title="Chưa có HĐ nào"
description="Tạo HĐ đầu tiên để bắt đầu."
action={
<Button onClick={() => navigate('/contracts/new')}>
<CheckCircle2 className="mr-1 h-4 w-4" />
Tạo mới
</Button>
}
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{recent.data?.items?.map(c => (
<li key={c.id}>
<button
onClick={() => navigate(`/my-contracts?id=${c.id}`)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-slate-50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-slate-900">
{c.tenHopDong ?? '(chưa đặt tên)'}
</span>
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono">{c.maHopDong ?? '—'}</span>
<span>·</span>
<span>{ContractTypeLabel[c.type] ?? '—'}</span>
<span>·</span>
<span className="truncate">{c.supplierName}</span>
</div>
</div>
<div className="ml-3 shrink-0 text-right">
<div className="text-sm text-slate-700">{fmtMoney(c.giaTri)}</div>
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div>
</button>
</li>
))}
</ul>
</section>
</div>
)
}