2a09d31ccc
- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页 - Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度) - 待办分为「风险提醒」「月度任务」两个顶层 tab - 归档与工资条生成解耦:归档只锁定批次,工资条单独生成 - 工资条管理新增「从批次汇总生成」按钮 - Dashboard 总览优先从已归档批次 BatchEntry 汇总数据 - 新增工资条生成待办提醒,生成后自动标记完成 - 修复高风险统计只含 CONTRACT/TERMINATION 类型 - 修复月度任务去重逻辑覆盖 SALARY 类型
387 lines
19 KiB
TypeScript
387 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|