[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 { 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={
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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