Files
TurboHR/frontend/src/pages/Dashboard.tsx
T
freedakgmail 2a09d31ccc feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化
- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页
- Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度)
- 待办分为「风险提醒」「月度任务」两个顶层 tab
- 归档与工资条生成解耦:归档只锁定批次,工资条单独生成
- 工资条管理新增「从批次汇总生成」按钮
- Dashboard 总览优先从已归档批次 BatchEntry 汇总数据
- 新增工资条生成待办提醒,生成后自动标记完成
- 修复高风险统计只含 CONTRACT/TERMINATION 类型
- 修复月度任务去重逻辑覆盖 SALARY 类型
2026-07-23 13:42:28 +08:00

387 lines
19 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 } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import type { DashboardData } from '../types'
function fmt(n: number) {
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
const TODO_ICON_CONFIG: Record<string, { icon: typeof FileText; color: string; bg: string }> = {
CONTRACT: { icon: FileText, color: 'text-blue-600', bg: 'bg-blue-50' },
SALARY: { icon: DollarSign, color: 'text-amber-600', bg: 'bg-amber-50' },
TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' },
}
function TodoIcon({ type, level }: { type: string; level: string }) {
const config = TODO_ICON_CONFIG[type] || TODO_ICON_CONFIG.MONTHLY
const Icon = config.icon
return (
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
<Icon className="w-4 h-4" />
</div>
)
}
export default function Dashboard() {
const [todoPage, setTodoPage] = useState(1)
const [todoPageSize, setTodoPageSize] = useState(10)
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'risk' | 'task'>('overview')
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => {
const res = await api.get('/dashboard') as any
return res.data
},
})
const resolveMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
})
const ignoreMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/ignore`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
})
const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION') || []
const taskTodos = data?.todos.filter((t) => t.type === 'MONTHLY' || t.type === 'SALARY') || []
const filteredTodos = activeTab === 'risk' ? riskTodos : taskTodos
if (isLoading) {
return <div className="text-center py-8 text-gray-400">...</div>
}
if (!data) return null
const stats = [
{ label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary' },
{ label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger' },
{ label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning' },
{ label: '月加班费', value: fmt(data.stats.monthlyOvertimePay), icon: DollarSign, color: 'text-safe' },
]
const payroll = data.payrollSummary
const activities = data.monthlyActivities
const activityItems = [
{ label: '新签合同', value: activities?.newContracts ?? 0, icon: FileText, color: 'text-primary' },
{ label: '解聘人数', value: activities?.terminations ?? 0, icon: Users, color: 'text-danger' },
{ label: '违纪处理', value: activities?.disciplinaryActions ?? 0, icon: AlertTriangle, color: 'text-warning' },
{ label: '考勤记录', value: activities?.attendanceRecords ?? 0, icon: Calendar, color: 'text-gray-600' },
{ label: '加班时长', value: `${activities?.overtimeHours ?? 0}h`, icon: TrendingUp, color: 'text-safe' },
{ label: '加班费', value: fmt(activities?.overtimePay ?? 0), icon: DollarSign, color: 'text-safe' },
]
const payrollItems = [
{ label: '基本工资', value: payroll?.baseSalary ?? 0, icon: Wallet, color: 'text-gray-700' },
{ label: '加班费', value: payroll?.overtimePay ?? 0, icon: TrendingUp, color: 'text-gray-700' },
{ label: '津贴补贴', value: payroll?.allowance ?? 0, icon: Wallet, color: 'text-gray-700' },
{ label: '扣款', value: -(payroll?.deduction ?? 0), icon: Wallet, color: 'text-danger' },
]
const deductionItems = [
{ label: '个人社保', value: -(payroll?.socialEmp ?? 0) },
{ label: '个人公积金', value: -(payroll?.housingEmp ?? 0) },
{ label: '个人所得税', value: -(payroll?.estimatedTax ?? 0) },
]
const tabs = [
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
{ key: 'payroll' as const, label: '薪税', icon: Calculator, badge: payroll?.payslipCount ?? 0 },
{ key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
]
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">{data.greeting}</h1>
<p className="text-sm text-gray-500 mt-0.5">{payroll?.month} </p>
</div>
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? '刷新中...' : '刷新'}
</Button>
</div>
{/* Tab 导航 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
{tab.badge > 0 && (
<span className={`ml-1 px-1.5 py-0.5 rounded-full text-xs ${activeTab === tab.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'}`}>
{tab.badge}
</span>
)}
</button>
)
})}
</div>
{/* 概览 Tab */}
{activeTab === 'overview' && (
<div className="space-y-4">
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{stats.map((stat) => {
const Icon = stat.icon
return (
<Card key={stat.label} className="flex items-center gap-3">
<Icon className={`w-8 h-8 ${stat.color}`} />
<div>
<div className="text-xl font-bold">{stat.value}</div>
<div className="text-xs text-gray-500">{stat.label}</div>
</div>
</Card>
)
})}
</div>
{/* 本月工作动态 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2"><Briefcase className="w-5 h-5" /></h2>
<span className="text-sm text-gray-400">{activities?.month}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{activityItems.map((item) => {
const Icon = item.icon
return (
<div key={item.label} className="flex flex-col items-center p-3 rounded-lg bg-gray-50">
<Icon className={`w-5 h-5 mb-1 ${item.color}`} />
<div className="text-lg font-bold">{item.value}</div>
<div className="text-xs text-gray-500">{item.label}</div>
</div>
)
})}
</div>
</Card>
{/* 风险分布 */}
<Card>
<h2 className="font-semibold mb-4"></h2>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary">{data.riskDistribution.contract}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-warning">{data.riskDistribution.salary}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-danger">{data.riskDistribution.termination}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
</div>
</Card>
</div>
)}
{/* 薪税 Tab */}
{activeTab === 'payroll' && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
<Link to="/money" className="text-sm text-primary hover:underline flex items-center gap-1">
<ArrowRight className="w-3 h-3" />
</Link>
</div>
{payroll && payroll.payslipCount > 0 ? (
<div className="space-y-4">
{/* 工资构成 */}
<div>
<div className="text-sm font-medium text-gray-600 mb-2"></div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{payrollItems.map((item) => {
const Icon = item.icon
return (
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-gray-50">
<div className="flex items-center gap-1.5">
<Icon className={`w-4 h-4 ${item.color}`} />
<span className="text-xs text-gray-500">{item.label}</span>
</div>
<span className={`text-sm font-medium ${item.value < 0 ? 'text-danger' : ''}`}>{fmt(item.value)}</span>
</div>
)
})}
</div>
</div>
{/* 应发合计 */}
<div className="flex items-center justify-between border-t border-b py-2">
<span className="font-medium"></span>
<span className="text-lg font-bold text-primary">{fmt(payroll.totalPay)}</span>
</div>
{/* 扣减项 */}
<div>
<div className="text-sm font-medium text-gray-600 mb-2"></div>
<div className="grid grid-cols-3 gap-2">
{deductionItems.map((item) => (
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-red-50">
<span className="text-xs text-gray-500">{item.label}</span>
<span className="text-sm font-medium text-danger">{fmt(item.value)}</span>
</div>
))}
</div>
</div>
{/* 员工实发 */}
<div className="flex items-center justify-between py-2">
<span className="font-medium flex items-center gap-2"><Wallet className="w-4 h-4 text-safe" /></span>
<span className="text-lg font-bold text-safe">{fmt(payroll.empNetPay)}</span>
</div>
{/* 企业成本 */}
<div className="border-t pt-3 space-y-2">
<div className="text-sm font-medium text-gray-600 mb-1"></div>
<div className="grid grid-cols-3 gap-2">
<div className="flex items-center justify-between p-2 rounded-md bg-blue-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" /></span>
<span className="text-sm font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-purple-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" /></span>
<span className="text-sm font-medium text-purple-700">{fmt(payroll.housingOrg)}</span>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-green-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Receipt className="w-3 h-3" /></span>
<span className="text-sm font-medium text-green-700">{fmt(payroll.totalPay)}</span>
</div>
</div>
{payroll.severancePay > 0 && (
<div className="flex items-center justify-between p-2 rounded-md bg-orange-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><DollarSign className="w-3 h-3" /></span>
<span className="text-sm font-medium text-orange-700">{fmt(payroll.severancePay)}</span>
</div>
)}
<div className="flex items-center justify-between py-2">
<span className="font-medium flex items-center gap-2"><DollarSign className="w-4 h-4 text-danger" /></span>
<span className="text-lg font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
</div>
</div>
{/* 工资条确认状态 */}
<div className="flex items-center gap-4 text-sm border-t pt-3">
<span className="text-gray-500"></span>
<span className="text-safe"> {payroll.confirmedPayslips}</span>
<span className="text-warning"> {payroll.unconfirmedPayslips}</span>
<span className="text-gray-400"> {payroll.payslipCount} </span>
</div>
</div>
) : (
<EmptyState title="本月暂无工资数据" description="请先在薪税页面生成本月工资条" />
)}
</Card>
)}
{/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-4">
{/* 待办列表 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<span className="text-sm text-gray-400">{filteredTodos.length} </span>
</div>
{filteredTodos.length === 0 ? (
<EmptyState title="暂无待办" description="所有事项已处理完毕" />
) : (
<>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
<div className="space-y-2">
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-3 py-3 rounded-md hover:bg-gray-50 transition-colors"
>
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-sm text-gray-800">{todo.title}</span>
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div>
</Link>
<div className="flex items-center gap-1">
<button
onClick={() => resolveMutation.mutate(todo.id)}
disabled={resolveMutation.isPending}
className="p-1.5 rounded hover:bg-safe/10 text-safe"
title="标记完成"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => ignoreMutation.mutate(todo.id)}
disabled={ignoreMutation.isPending}
className="p-1.5 rounded hover:bg-gray-200 text-gray-400"
title="忽略"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</>
)}
</Card>
{/* 已办事项 */}
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2"><CheckSquare className="w-5 h-5 text-safe" /></h2>
<span className="text-sm text-gray-400">{data.resolvedTodos.length} </span>
</div>
<div className="space-y-2">
{data.resolvedTodos.map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-3 py-3 rounded-md bg-gray-50"
>
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-sm text-gray-600 line-through">{todo.title}</span>
<span className="text-xs text-gray-400">{todo.description}</span>
</div>
</Link>
<span className="text-xs text-gray-400">
{todo.resolvedAt ? new Date(todo.resolvedAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
))}
</div>
</Card>
)}
</div>
)}
</div>
)
}