[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
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:
@ -4,6 +4,7 @@ import { AuthProvider } from '@/contexts/AuthContext'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { UserDashboardPage } from '@/pages/UserDashboardPage'
|
||||
import { InboxPage } from '@/pages/InboxPage'
|
||||
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
|
||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||
@ -22,11 +23,12 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<UserDashboardPage />} />
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/my-contracts" element={<MyContractsPage />} />
|
||||
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
|
||||
@ -39,7 +39,7 @@ function getCtGroupCode(key: string): string | null {
|
||||
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
|
||||
function resolvePath(key: string): string | null {
|
||||
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',
|
||||
}
|
||||
if (staticMap[key]) return staticMap[key]
|
||||
@ -228,7 +228,7 @@ export function Layout() {
|
||||
<div className="flex h-screen">
|
||||
<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">
|
||||
<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" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">ERP</span>
|
||||
</Link>
|
||||
|
||||
203
fe-user/src/pages/UserDashboardPage.tsx
Normal file
203
fe-user/src/pages/UserDashboardPage.tsx
Normal 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" />
|
||||
HĐ 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 HĐ 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user