feat: Sprint 1 — 设计Token系统 + 新导航5分组 + AppShell/PageHeader/FilterBar/DataTable组件 + 花名册导出扩充19字段 + 社保/公积金不缴纳选项 + 个税申报表导出 + 入离职/绩效统计看板
This commit is contained in:
@@ -11,6 +11,8 @@ import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import type { DashboardData } from '../types'
|
||||
import TurnoverStats from './dashboard/TurnoverStats'
|
||||
import PerformanceStats from './dashboard/PerformanceStats'
|
||||
|
||||
function fmt(n: number) {
|
||||
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
@@ -679,6 +681,12 @@ export default function Dashboard() {
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 入离职统计 + 绩效统计 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
|
||||
<TurnoverStats />
|
||||
<PerformanceStats />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 绩效统计看板 — 展示绩效等级分布和部门/周期对比
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, Legend } from 'recharts'
|
||||
import { Award, TrendingUp } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
|
||||
const GRADE_COLORS: Record<string, string> = {
|
||||
'A': '#237A57',
|
||||
'B': '#356A8A',
|
||||
'C': '#A76113',
|
||||
'D': '#B83232',
|
||||
'S': '#C7442E',
|
||||
'未评级': '#9CA39B',
|
||||
}
|
||||
|
||||
export default function PerformanceStats() {
|
||||
const [period, setPeriod] = useState(new Date().getFullYear().toString())
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['performance-stats', period],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/dashboard/performance-stats?period=${period}`)
|
||||
return res.data.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 bg-surface-muted rounded w-32" />
|
||||
<div className="h-48 bg-surface-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.total === 0) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-ink-700 mb-2">绩效统计</h3>
|
||||
<div className="flex items-center justify-center h-32 text-ink-400 text-sm">暂无数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-ink-700">绩效统计</h3>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
className="text-xs px-2 py-1 rounded border border-border-default bg-surface-card text-ink-700 focus:outline-none focus:ring-2 focus:ring-brand-600/30"
|
||||
>
|
||||
{[new Date().getFullYear(), new Date().getFullYear() - 1].map(y => (
|
||||
<option key={y} value={y}>{y} 年</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 汇总 */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">参评人数</span>
|
||||
<span className="text-lg font-semibold text-ink-900 flex items-center gap-1">
|
||||
<Award className="w-4 h-4" />{data.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">平均分</span>
|
||||
<span className="text-lg font-semibold text-info flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4" />{data.overallAvgScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 等级分布饼图 */}
|
||||
<div>
|
||||
<p className="text-xs text-ink-500 mb-2">等级分布</p>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.gradeDistribution}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={60}
|
||||
label={({ name, value }: any) => `${name}: ${value}`}
|
||||
labelLine={false}
|
||||
>
|
||||
{data.gradeDistribution.map((entry: any, i: number) => (
|
||||
<Cell key={i} fill={GRADE_COLORS[entry.name] || '#9CA39B'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #DDE1DD' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 部门平均分柱状图 */}
|
||||
<div>
|
||||
<p className="text-xs text-ink-500 mb-2">部门平均分</p>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={data.departmentDistribution} layout="vertical" margin={{ top: 4, right: 8, bottom: 0, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#ECEEEC" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#7A8278' }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10, fill: '#7A8278' }} width={60} />
|
||||
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #DDE1DD' }} />
|
||||
<Bar dataKey="avgScore" name="平均分" fill="#356A8A" radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 入离职统计看板 — 展示按月入职/离职趋势和汇总数据
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { UserPlus, UserMinus, Users, TrendingDown } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
|
||||
export default function TurnoverStats() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['turnover-stats'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard/turnover-stats?months=12')
|
||||
return res.data.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 bg-surface-muted rounded w-32" />
|
||||
<div className="h-48 bg-surface-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.monthly.length === 0) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-ink-700 mb-2">入离职统计</h3>
|
||||
<div className="flex items-center justify-center h-32 text-ink-400 text-sm">暂无数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { monthly, summary } = data
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-medium text-ink-700 mb-3">入离职趋势(近 12 个月)</h3>
|
||||
|
||||
{/* 汇总卡片 */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">入职人数</span>
|
||||
<span className="text-lg font-semibold text-success flex items-center gap-1">
|
||||
<UserPlus className="w-4 h-4" />{summary.totalHired}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">离职人数</span>
|
||||
<span className="text-lg font-semibold text-danger flex items-center gap-1">
|
||||
<UserMinus className="w-4 h-4" />{summary.totalLeft}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">当前人数</span>
|
||||
<span className="text-lg font-semibold text-ink-900 flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />{summary.currentHeadcount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-ink-500">离职率</span>
|
||||
<span className="text-lg font-semibold text-warning flex items-center gap-1">
|
||||
<TrendingDown className="w-4 h-4" />{summary.turnoverRate}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={monthly} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#ECEEEC" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 10, fill: '#7A8278' }}
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 10, fill: '#7A8278' }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #DDE1DD' }}
|
||||
formatter={(v: any) => [v, ''] as [any, any]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="hired" name="入职" fill="#237A57" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="left" name="离职" fill="#B83232" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user