All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s
Task 1 — Seed master data unblock UAT/demo: - DbInitializer.SeedDepartmentsAsync: 9 departments từ QT-TP-NCC.docx (PM/QS/CCM/PRO/FIN/ACT/EQU/HRA/BOD) — reference data không phải demo. - DbInitializer.SeedDemoMasterDataAsync: 5 demo suppliers (NCC VLXD, NTP Xây dựng, TĐ Hoàng Nam, DV Clean, CĐT Vingroup — covers cả 5 SupplierType) + 3 demo projects (FLOCK01, SkyGarden, Industrial). Chỉ seed nếu tables empty — respect admin's real data khi họ add. Task 2 — Roles CRUD đã có sẵn trong UsersPage (Shield icon button mở dialog gán 12 roles từ AppRoles.cs). Skip. Task 3 — MyDashboard role-specific widgets: - GetMyDashboardQuery (Reports): returns DraftsInProgress (tôi là Drafter + phase soạn thảo), PendingMyApproval (phase eligible role tôi + không phải tôi drafter), DueSoon 24h, Overdue, DraftsTotalValue. - Endpoint GET /api/reports/my-dashboard. - FE MyDashboardRow ở đầu DashboardPage: 4 card hover → navigate. Admin ẩn row nếu tất cả = 0 (ERP noise reduction). 'Đang soạn thảo' + 'Chờ tôi duyệt' clickable → /contracts?filter=... (filter param để wire lần sau; row hiện chưa implement). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
8.8 KiB
TypeScript
212 lines
8.8 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { FileText, CheckCircle2, AlertTriangle, TrendingUp, Coins, Pencil, Clock, Inbox } from 'lucide-react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { PageHeader } from '@/components/PageHeader'
|
|
import { BarChart } from '@/components/BarChart'
|
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
|
import { useAuth } from '@/contexts/AuthContext'
|
|
import { api } from '@/lib/api'
|
|
import type { DashboardStats } from '@/types/reports'
|
|
|
|
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' }: {
|
|
icon: React.ComponentType<{ className?: string }>
|
|
label: string
|
|
value: React.ReactNode
|
|
hint?: string
|
|
tone?: 'default' | 'warn' | 'good'
|
|
}) {
|
|
const toneClass = tone === 'warn' ? 'text-amber-600' : tone === 'good' ? 'text-emerald-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>
|
|
{hint && <div className="mt-0.5 text-xs text-slate-400">{hint}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MyDashboardRow() {
|
|
const navigate = useNavigate()
|
|
const { user } = useAuth()
|
|
const q = useQuery({
|
|
queryKey: ['my-dashboard'],
|
|
queryFn: async () => (await api.get<MyDashboard>('/reports/my-dashboard')).data,
|
|
staleTime: 30_000,
|
|
})
|
|
const d = q.data
|
|
if (!d) return null
|
|
|
|
// Admin thấy everything nhưng card "Tôi đang soạn thảo" + "Chờ tôi duyệt"
|
|
// thường = 0 cho admin. Chỉ show row nếu có ít nhất 1 card có giá trị.
|
|
const anyValue = d.draftsInProgress + d.pendingMyApproval + d.dueSoon + d.overdue > 0
|
|
if (!anyValue && user?.roles.includes('Admin')) return null
|
|
|
|
return (
|
|
<div className="mb-6">
|
|
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">Của tôi</h2>
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
<button
|
|
onClick={() => navigate('/contracts?filter=my-drafts')}
|
|
className="rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:border-brand-300 hover:shadow"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-xs font-medium text-slate-500">Đang soạn thảo</div>
|
|
<Pencil className="h-4 w-4 text-brand-600" />
|
|
</div>
|
|
<div className="mt-2 text-2xl font-bold text-slate-900 tabular-nums">{d.draftsInProgress}</div>
|
|
<div className="mt-0.5 text-xs text-slate-400">{fmtMoney(d.draftsTotalValue)} VND</div>
|
|
</button>
|
|
<button
|
|
onClick={() => navigate('/contracts?filter=pending-me')}
|
|
className="rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:border-brand-300 hover:shadow"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-xs font-medium text-slate-500">Chờ tôi duyệt</div>
|
|
<Inbox className="h-4 w-4 text-brand-600" />
|
|
</div>
|
|
<div className="mt-2 text-2xl font-bold text-slate-900 tabular-nums">{d.pendingMyApproval}</div>
|
|
<div className="mt-0.5 text-xs text-slate-400">Click để xem hộp thư</div>
|
|
</button>
|
|
<div className="rounded-xl 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">Sắp quá hạn (24h)</div>
|
|
<Clock className="h-4 w-4 text-amber-600" />
|
|
</div>
|
|
<div className="mt-2 text-2xl font-bold text-amber-600 tabular-nums">{d.dueSoon}</div>
|
|
</div>
|
|
<div className="rounded-xl 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">Đã quá hạn</div>
|
|
<AlertTriangle className="h-4 w-4 text-red-600" />
|
|
</div>
|
|
<div className="mt-2 text-2xl font-bold text-red-600 tabular-nums">{d.overdue}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function DashboardPage() {
|
|
const stats = useQuery({
|
|
queryKey: ['dashboard-stats'],
|
|
queryFn: async () => (await api.get<DashboardStats>('/reports/dashboard')).data,
|
|
staleTime: 60_000,
|
|
})
|
|
|
|
if (stats.isLoading) {
|
|
return (
|
|
<div className="p-6">
|
|
<PageHeader title="Tổng quan" />
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
|
{[1, 2, 3, 4, 5].map(i => (
|
|
<div key={i} className="h-24 animate-pulse rounded-lg bg-slate-100" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const d = stats.data
|
|
if (!d) return null
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<PageHeader title="Tổng quan" description="Tình hình HĐ toàn hệ thống — cập nhật real-time khi refresh." />
|
|
|
|
<MyDashboardRow />
|
|
|
|
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">Toàn hệ thống</h2>
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
|
<StatCard icon={FileText} label="Tổng HĐ" value={d.totalContracts} />
|
|
<StatCard icon={TrendingUp} label="HĐ đang xử lý" value={d.activeContracts} tone="good" />
|
|
<StatCard icon={AlertTriangle} label="HĐ quá hạn SLA" value={d.overdueContracts} tone="warn" hint="SLA đã trôi" />
|
|
<StatCard icon={CheckCircle2} label="Phát hành tháng này" value={d.publishedThisMonth} tone="good" />
|
|
<StatCard icon={Coins} label="Tổng giá trị active" value={fmtMoney(d.totalValueActive)} hint="VND" />
|
|
</div>
|
|
|
|
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* By Phase */}
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="mb-4 text-sm font-semibold text-slate-700">HĐ theo phase</h2>
|
|
<div className="space-y-2">
|
|
{d.byPhase.length === 0 && <div className="py-6 text-center text-sm text-slate-400">Chưa có HĐ nào</div>}
|
|
{d.byPhase
|
|
.slice()
|
|
.sort((a, b) => b.count - a.count)
|
|
.map(p => {
|
|
const total = d.byPhase.reduce((s, x) => s + x.count, 0) || 1
|
|
const pct = (p.count / total) * 100
|
|
return (
|
|
<div key={p.phase} className="flex items-center gap-3">
|
|
<div className="w-36">
|
|
<PhaseBadge phase={p.phase} />
|
|
</div>
|
|
<div className="h-2 flex-1 rounded-full bg-slate-100">
|
|
<div className="h-full rounded-full bg-brand-500" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<div className="w-12 text-right font-mono text-xs text-slate-600">{p.count}</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Monthly value */}
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="mb-4 text-sm font-semibold text-slate-700">Giá trị HĐ theo tháng (12 tháng gần nhất)</h2>
|
|
<BarChart
|
|
data={d.monthlyValue.map(m => ({
|
|
label: `${String(m.month).padStart(2, '0')}/${m.year}`,
|
|
value: m.totalValue,
|
|
sublabel: `${m.count} HĐ`,
|
|
}))}
|
|
formatValue={fmtMoney}
|
|
/>
|
|
</section>
|
|
|
|
{/* Top suppliers */}
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="mb-4 text-sm font-semibold text-slate-700">Top NCC theo số HĐ</h2>
|
|
<BarChart
|
|
data={d.topSuppliers.map(s => ({
|
|
label: s.supplierName,
|
|
value: s.count,
|
|
sublabel: `Tổng ${fmtMoney(s.totalValue)} VND`,
|
|
}))}
|
|
/>
|
|
</section>
|
|
|
|
{/* Top projects */}
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="mb-4 text-sm font-semibold text-slate-700">Top dự án theo số HĐ</h2>
|
|
<BarChart
|
|
data={d.topProjects.map(p => ({
|
|
label: p.projectName,
|
|
value: p.count,
|
|
sublabel: `Tổng ${fmtMoney(p.totalValue)} VND`,
|
|
}))}
|
|
/>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|