Files
TurboHR/frontend/src/pages/SalaryDashboard.tsx
T
selfrelease 75e08b90c0 ux: 统一PageGuide位置到页面最顶部 + 侧边栏不显示品牌名
- SalaryDashboard/ESign: PageGuide 从标题下方移到标题上方
- Termination: PageGuide 从列表视图块内移到页面顶部
- SocialInsurance/Dashboard/Attendance: 页面顶部新增总览 PageGuide
- 侧边栏左上角不再回退显示品牌名,仅显示企业名称

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:34:51 +08:00

227 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 薪酬分析看板 — 展示薪酬分布、部门对比、同比环比趋势
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3, TrendingUp, TrendingDown, Users, Wallet, DollarSign, Gauge,
} from 'lucide-react'
import Card from '../components/ui/Card'
import { Select } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
import { salaryDashboardApi } from '../lib/api-services'
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const fmtShort = (n: number) => {
if (!n) return '0'
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return n.toFixed(0)
}
export default function SalaryDashboard() {
const [year, setYear] = useState(new Date().getFullYear().toString())
const { data, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['salary-dashboard', year],
queryFn: async () => {
return await salaryDashboardApi.data(Number(year))
},
})
const departments = data?.departments || []
const monthlyTrend = data?.monthlyTrend || []
const summary = data?.summary || {}
const maxDeptAvg = useMemo(() => {
if (departments.length === 0) return 1
return Math.max(...departments.map((d: any) => d.avgSalary || 0), 1)
}, [departments])
const maxMonthly = useMemo(() => {
if (monthlyTrend.length === 0) return 1
return Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1)
}, [monthlyTrend])
const summaryCards = [
{ icon: Users, label: '员工总数', value: summary.totalEmployees || 0, suffix: '', color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-100' },
{ icon: Wallet, label: '月均薪酬', value: `¥${fmt(summary.avgSalary)}`, suffix: '', color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-100' },
{ icon: Gauge, label: '薪酬中位数', value: `¥${fmt(summary.medianSalary)}`, suffix: '', color: 'text-purple-600', bg: 'bg-purple-50', border: 'border-purple-100' },
{ icon: DollarSign, label: '年度总薪酬', value: `¥${fmt(summary.totalAnnual)}`, suffix: '', color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-100' },
]
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-0.5 text-sm text-gray-500"></p>
</div>
</div>
<Select value={year} onChange={(e) => setYear(e.target.value)} className="!w-24">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map(y => (
<option key={y} value={y}>{y}</option>
))}
</Select>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : !data ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
<>
{/* 概览卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{summaryCards.map((card, i) => {
const Icon = card.icon
return (
<Card key={i} className={`p-4 border ${card.border}`}>
<div className="flex items-center justify-between">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${card.bg}`}>
<Icon className={`w-4 h-4 ${card.color}`} />
</div>
</div>
<div className="text-xs text-gray-400 mt-2">{card.label}</div>
<div className={`text-xl font-bold mt-0.5 ${card.color}`}>{card.value}{card.suffix}</div>
</Card>
)
})}
</div>
{/* 同比环比 */}
{summary.yoy !== undefined && (
<div className="grid grid-cols-2 gap-3">
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className={`flex items-center gap-1 text-sm font-bold ${summary.yoy >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.yoy >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.yoy >= 0 ? '+' : ''}{(summary.yoy || 0).toFixed(1)}%
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className={`flex items-center gap-1 text-sm font-bold ${summary.mom >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.mom >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.mom >= 0 ? '+' : ''}{(summary.mom || 0).toFixed(1)}%
</div>
</div>
</Card>
</div>
)}
{/* 部门薪酬对比 + 月度趋势 双栏布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 部门薪酬对比 */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
{departments.length > 0 && (
<span className="text-xs text-gray-400"> {departments.length} </span>
)}
</div>
{departments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-2.5 max-h-[420px] overflow-y-auto pr-1">
{departments.map((dept: any, idx: number) => {
const pct = (dept.avgSalary / maxDeptAvg) * 100
const isTop3 = idx < 3
return (
<div key={dept.name} className="group">
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center gap-2 min-w-0">
{isTop3 && (
<span className={`flex-shrink-0 w-4 h-4 rounded text-[10px] flex items-center justify-center font-bold ${
idx === 0 ? 'bg-amber-100 text-amber-700' : idx === 1 ? 'bg-gray-200 text-gray-600' : 'bg-orange-100 text-orange-700'
}`}>{idx + 1}</span>
)}
<span className="text-gray-600 truncate">{dept.name}</span>
</div>
<div className="flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-gray-400">{dept.count}</span>
<span className="text-gray-400"></span>
<span className="font-semibold text-gray-800">¥{fmt(dept.avgSalary)}</span>
</div>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all group-hover:opacity-80 ${
isTop3 ? 'bg-gradient-to-r from-primary to-primary/70' : 'bg-primary/40'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
</Card>
{/* 月度趋势 */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
{monthlyTrend.length > 0 && (
<span className="text-xs text-gray-400"></span>
)}
</div>
{monthlyTrend.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="relative">
{/* 网格参考线 */}
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none">
{[0, 1, 2, 3].map(i => (
<div key={i} className="border-t border-dashed border-gray-100" />
))}
</div>
<div className="flex items-end gap-1.5 h-48 relative">
{monthlyTrend.map((m: any) => {
const height = ((m.total || 0) / maxMonthly) * 100
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1 group cursor-pointer">
<div className="text-[10px] text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{m.total ? `¥${fmtShort(m.total)}` : ''}
</div>
<div className="w-full bg-gray-50 rounded-t-md flex-1 flex items-end overflow-hidden">
<div
className="w-full bg-gradient-to-t from-primary/60 to-primary rounded-t-md transition-all group-hover:from-primary group-hover:to-primary/80"
style={{ height: `${height}%` }}
/>
</div>
<div className="text-[10px] text-gray-400">{m.month}</div>
</div>
)
})}
</div>
</div>
)}
</Card>
</div>
{summary.totalEmployees === 0 && (
<InlineAlert type="info">
</InlineAlert>
)}
</>
)}
</div>
)
}