feat: Sprint 1 — 设计Token系统 + 新导航5分组 + AppShell/PageHeader/FilterBar/DataTable组件 + 花名册导出扩充19字段 + 社保/公积金不缴纳选项 + 个税申报表导出 + 入离职/绩效统计看板

This commit is contained in:
selfrelease
2026-07-31 17:40:49 +08:00
parent c15e11ec22
commit 821a62e3f8
16 changed files with 3177 additions and 55 deletions
+8
View File
@@ -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>
)
}