Files
TurboHR/frontend/src/pages/Dashboard.tsx
T

1106 lines
58 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 { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles, Repeat, XCircle, Loader2 } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
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' },
ONBOARDING: { icon: UserPlus, color: 'text-cyan-600', bg: 'bg-cyan-50' },
}
function TodoIcon({ type }: { 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 [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [drillDownType, setDrillDownType] = useState<string | null>(null)
const [showExpiringModal, setShowExpiringModal] = useState(false)
const [dismissedExpiring, setDismissedExpiring] = useState(false)
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => {
const res = await api.get('/dashboard') as any
return res.data
},
})
const { data: expiringContracts } = useQuery<any>({
queryKey: ['expiring-contracts'],
queryFn: async () => {
const res = await api.get('/roster/contracts/expiring') as any
return res.data
},
})
const currentMonth = new Date().toISOString().slice(0, 7)
const { data: costAnalysis } = useQuery<any>({
queryKey: ['cost-analysis', currentMonth],
queryFn: async () => {
const res = await api.get(`/dashboard/cost-analysis?month=${currentMonth}`) as any
return res.data
},
})
const { data: complianceScore } = useQuery<any>({
queryKey: ['compliance-score'],
queryFn: async () => {
const res = await api.get('/dashboard/compliance-score') as any
return res.data
},
})
const { data: workforceStats } = useQuery<any>({
queryKey: ['workforce-stats'],
queryFn: async () => {
const res = await api.get('/dashboard/workforce-stats') 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 batchResolveMutation = useMutation({
mutationFn: (ids: string[]) => api.patch('/dashboard/todos/batch-resolve', { ids }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setSelectedIds(new Set())
},
})
const batchIgnoreMutation = useMutation({
mutationFn: (ids: string[]) => api.patch('/dashboard/todos/batch-ignore', { ids }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setSelectedIds(new Set())
},
})
const handleExportPayroll = async () => {
try {
const month = payroll?.month || new Date().toISOString().slice(0, 7)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/payroll?month=${month}`, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `薪税汇总-${month}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = (ids: string[]) => {
setSelectedIds(prev => {
const allSelected = ids.every(id => prev.has(id))
const next = new Set(prev)
if (allSelected) ids.forEach(id => next.delete(id))
else ids.forEach(id => next.add(id))
return next
})
}
const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'ONBOARDING') || []
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-500">...</div>
}
if (!data) return null
const payroll = data.payrollSummary
const activities = data.monthlyActivities
const yearCost = data.yearCostSummary
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: payroll?.payslipCount ?? 0, icon: Receipt, color: 'text-safe' },
]
// 当月人力成本
const monthCostItems = [
{ label: '工资总额', value: payroll?.totalPay ?? 0, color: 'text-primary' },
{ label: '企业社保', value: payroll?.socialOrg ?? 0, color: 'text-blue-600' },
{ label: '企业公积金', value: payroll?.housingOrg ?? 0, color: 'text-purple-600' },
{ label: '加班费', value: payroll?.overtimePay ?? 0, color: 'text-safe' },
{ label: '补偿金', value: payroll?.severancePay ?? 0, color: 'text-orange-600' },
]
const monthTotalCost = payroll?.orgTotalCost ?? 0
// 年度累计人力成本
const yearCostItems = [
{ label: '工资总额', value: yearCost?.totalPay ?? 0, color: 'text-primary' },
{ label: '企业社保', value: yearCost?.socialOrg ?? 0, color: 'text-blue-600' },
{ label: '企业公积金', value: yearCost?.housingOrg ?? 0, color: 'text-purple-600' },
{ label: '加班费', value: yearCost?.overtimePay ?? 0, color: 'text-safe' },
{ label: '补偿金', value: yearCost?.severancePay ?? 0, color: 'text-orange-600' },
]
const yearTotalCost = yearCost?.orgTotalCost ?? 0
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: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
]
const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
URGENT: { label: '紧急', color: 'text-red-700', bg: 'bg-red-100' },
HIGH: { label: '高', color: 'text-orange-700', bg: 'bg-orange-100' },
MEDIUM: { label: '中', color: 'text-amber-700', bg: 'bg-amber-100' },
LOW: { label: '低', color: 'text-gray-600', bg: 'bg-gray-100' },
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<LayoutDashboard className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500">{data.greeting} · {payroll?.month} </p>
</div>
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching} className={activeTab === 'risk' || activeTab === 'task' ? 'opacity-50 pointer-events-none' : ''}>
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? '刷新中...' : activeTab === 'payroll' ? '刷新薪税' : '刷新概览'}
</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-3">
{/* 合规健康度评分 + AI 建议卡片流 */}
{complianceScore && (
<div className="space-y-3">
{/* 评分环 + 5 维度 */}
<Card>
<div className="flex items-center gap-4">
{/* SVG 环形评分 */}
<div className="relative w-28 h-28 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
innerRadius="70%"
outerRadius="100%"
data={[{ value: complianceScore.overallScore, fill: complianceScore.level === 'safe' ? '#16A34A' : complianceScore.level === 'warning' ? '#D97706' : '#C00000' }]}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar background dataKey="value" cornerRadius={8} />
</RadialBarChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-2xl font-bold ${complianceScore.level === 'safe' ? 'text-safe' : complianceScore.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore.overallScore}</span>
<span className="text-xs text-gray-500">{complianceScore.levelLabel}</span>
</div>
</div>
{/* 5 维度评分 */}
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{complianceScore.dimensions.map((dim: any) => (
<Link key={dim.key} to={dim.key === 'contract' ? '/roster' : dim.key === 'policy' ? '/policies' : dim.key === 'attendance' ? '/attendance' : dim.key === 'salary' ? '/money' : '/social'} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${dim.score >= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} />
<span className="text-xs text-gray-700">{dim.name}</span>
</div>
<div className="flex items-center gap-1.5">
{dim.todoCount > 0 && <span className="text-xs text-gray-400">{dim.todoCount}</span>}
<span className={`text-sm font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
</div>
</Link>
))}
</div>
</div>
</Card>
{/* AI 建议卡片流 */}
{complianceScore.suggestions.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-700">
<Sparkles className="w-4 h-4 text-primary" />
AI
</div>
{complianceScore.suggestions.map((s: any, i: number) => {
const suggestionConfig: Record<string, { icon: typeof AlertTriangle; bg: string; border: string; iconColor: string; label: string; labelBg: string }> = {
danger: { icon: AlertTriangle, bg: 'bg-red-50', border: 'border-red-200', iconColor: 'text-red-600', label: '高风险', labelBg: 'bg-red-100 text-red-700' },
warning: { icon: AlertCircle, bg: 'bg-amber-50', border: 'border-amber-200', iconColor: 'text-amber-600', label: '中风险', labelBg: 'bg-amber-100 text-amber-700' },
value: { icon: ShieldCheck, bg: 'bg-green-50', border: 'border-green-200', iconColor: 'text-safe', label: '价值', labelBg: 'bg-green-100 text-safe' },
knowledge: { icon: BookOpen, bg: 'bg-purple-50', border: 'border-purple-200', iconColor: 'text-purple-600', label: '知识', labelBg: 'bg-purple-100 text-purple-700' },
}
const config = suggestionConfig[s.type] || { icon: Lightbulb, bg: 'bg-gray-50', border: 'border-gray-200', iconColor: 'text-gray-600', label: '', labelBg: '' }
const Icon = config.icon
return (
<Link key={i} to={s.actionUrl}>
<Card className={`${config.bg} ${config.border} border hover:shadow-md transition-shadow cursor-pointer`}>
<div className="flex items-start gap-3">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.iconColor} bg-white/60 flex-shrink-0`}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${config.labelBg}`}>{config.label}</span>
<span className="text-sm font-medium text-gray-900 truncate">{s.title}</span>
{s.estimatedLoss && s.estimatedLoss > 0 && (
<span className="text-xs text-red-600 font-bold"> {fmt(s.estimatedLoss)}</span>
)}
</div>
<p className="text-xs text-gray-600 mt-0.5">{s.description}</p>
</div>
<div className="flex items-center gap-1 text-xs text-primary flex-shrink-0">
{s.actionLabel}
<ArrowRight className="w-3 h-3" />
</div>
</div>
</Card>
</Link>
)
})}
</div>
)}
</div>
)}
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((stat) => {
const Icon = stat.icon
return (
<Card key={stat.label} className="flex items-center gap-2.5">
<Icon className={`w-6 h-6 ${stat.color}`} />
<div>
<div className="text-base font-bold">{stat.value}</div>
<div className="text-xs text-gray-500">{stat.label}</div>
</div>
</Card>
)
})}
</div>
{/* 人力成本概览:当月 + 年度累计 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* 当月人力成本 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{payroll?.month}</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{monthCostItems.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<span className="text-xs text-gray-500">{item.label}</span>
<span className={`text-xs font-semibold ${item.color}`}>{fmt(item.value)}</span>
</div>
))}
</div>
<div className="mt-3 pt-2 border-t flex items-center justify-between bg-danger/5 -mx-4 -mb-4 px-4 py-2.5 rounded-b-lg">
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-lg font-bold text-danger">{fmt(monthTotalCost)}</span>
</div>
</Card>
{/* 年度累计人力成本 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{yearCost?.year}</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{yearCostItems.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<span className="text-xs text-gray-500">{item.label}</span>
<span className={`text-xs font-semibold ${item.color}`}>{fmt(item.value)}</span>
</div>
))}
</div>
<div className="mt-3 pt-2 border-t flex items-center justify-between bg-danger/5 -mx-4 -mb-4 px-4 py-2.5 rounded-b-lg">
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-lg font-bold text-danger">{fmt(yearTotalCost)}</span>
</div>
</Card>
</div>
{/* 合同到期预警 */}
{expiringContracts && expiringContracts.length > 0 && !dismissedExpiring && (
<Card className="border-danger/30 bg-danger/5">
<div className="flex items-center justify-between">
<div
className="flex items-center gap-2 cursor-pointer flex-1"
onClick={() => setShowExpiringModal(true)}
>
<AlertCircle className="w-5 h-5 text-danger" />
<div>
<div className="text-sm font-medium text-danger"></div>
<div className="text-xs text-gray-500 mt-0.5">
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
<span key={c.employeeId}>
{i > 0 && '、'}
{c.employeeName}
<span className="text-danger ml-1">({c.daysLeft})</span>
</span>
))}
{expiringContracts.length > 3 && <span className="text-gray-500"> {expiringContracts.length}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowExpiringModal(true)}
className="text-xs text-primary hover:underline"
>
</button>
<button
onClick={() => setDismissedExpiring(true)}
className="text-gray-400 hover:text-gray-600 p-1"
title="稍后提醒"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</Card>
)}
{/* 本月工作动态 + 风险分布 左右两列 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* 本月工作动态 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" /></h2>
<span className="text-xs text-gray-500">{activities?.month}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{activityItems.map((item) => {
const Icon = item.icon
return (
<div key={item.label} className="flex flex-col items-center p-2 rounded-lg bg-gray-50">
<Icon className={`w-4 h-4 mb-1 ${item.color}`} />
<div className="text-xs font-bold">{item.value}</div>
<div className="text-xs text-gray-500">{item.label}</div>
</div>
)
})}
</div>
</Card>
{/* 风险分布 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="flex items-center gap-4">
<div className="w-32 h-32 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
].filter(d => d.value > 0)}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={30}
outerRadius={55}
paddingAngle={2}
>
{[
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
].filter(d => d.value > 0).map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(v: any) => `${v}`} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-2">
<button
onClick={() => setDrillDownType(drillDownType === 'CONTRACT' ? null : 'CONTRACT')}
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'CONTRACT' ? 'bg-primary/10' : 'hover:bg-gray-50'}`}
>
<span className="flex items-center gap-2 text-sm">
<span className="w-2.5 h-2.5 rounded-full bg-primary" />
</span>
<span className="text-sm font-bold text-primary">{data.riskDistribution.contract}</span>
</button>
<button
onClick={() => setDrillDownType(drillDownType === 'SALARY' ? null : 'SALARY')}
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'SALARY' ? 'bg-warning/10' : 'hover:bg-gray-50'}`}
>
<span className="flex items-center gap-2 text-sm">
<span className="w-2.5 h-2.5 rounded-full bg-warning" />
</span>
<span className="text-sm font-bold text-warning">{data.riskDistribution.salary}</span>
</button>
<button
onClick={() => setDrillDownType(drillDownType === 'TERMINATION' ? null : 'TERMINATION')}
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'TERMINATION' ? 'bg-danger/10' : 'hover:bg-gray-50'}`}
>
<span className="flex items-center gap-2 text-sm">
<span className="w-2.5 h-2.5 rounded-full bg-danger" />
</span>
<span className="text-sm font-bold text-danger">{data.riskDistribution.termination}</span>
</button>
</div>
</div>
{/* 下钻明细 */}
{drillDownType && (
<div className="mt-3 border-t pt-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600">
{drillDownType === 'CONTRACT' ? '合同' : drillDownType === 'SALARY' ? '薪资' : '解聘'}
</span>
<button onClick={() => setDrillDownType(null)} className="text-xs text-gray-500 hover:text-gray-600"></button>
</div>
{data.topRisks.filter(r => r.type === drillDownType).length > 0 ? (
data.topRisks.filter(r => r.type === drillDownType).map((r) => (
<Link key={r.id} to={r.actionUrl} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${r.level === 'high' ? 'text-danger' : 'text-warning'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-gray-800">{r.title}</span>
{r.priority && priorityConfig[r.priority] && (
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[r.priority].bg} ${priorityConfig[r.priority].color}`}>
{priorityConfig[r.priority].label}
</span>
)}
</div>
<div className="flex items-center gap-2 text-gray-500">
{r.employeeName && <span>{r.employeeName}</span>}
{r.estimatedLoss > 0 && <span className="text-red-600"> {fmt(r.estimatedLoss)}</span>}
{r.daysUntilDeadline !== null && r.daysUntilDeadline <= 7 && (
<span className="text-red-600">{r.daysUntilDeadline <= 0 ? '已逾期' : `${r.daysUntilDeadline}`}</span>
)}
</div>
</div>
<ArrowRight className="w-3 h-3 text-gray-500" />
</Link>
))
) : (
<div className="text-xs text-gray-500 text-center py-2"></div>
)}
</div>
)}
</Card>
</div>
{/* 人力成本分析 */}
<div className="grid grid-cols-1 gap-3">
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{currentMonth}</span>
</div>
{costAnalysis ? (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className={`p-2 rounded-lg ${costAnalysis.monthOnMonth?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
<div className="text-xs text-gray-500"></div>
<div className={`text-sm font-bold ${costAnalysis.monthOnMonth?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
{costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}%
</div>
<div className="text-xs text-gray-500">
{costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))}
</div>
</div>
<div className={`p-2 rounded-lg ${costAnalysis.yearOnYear?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
<div className="text-xs text-gray-500"></div>
<div className={`text-sm font-bold ${costAnalysis.yearOnYear?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
{costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}%
</div>
<div className="text-xs text-gray-500">
{costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))}
</div>
</div>
</div>
<div className="flex items-center justify-between border-t pt-2">
<span className="text-xs text-gray-600"></span>
<span className="text-sm font-bold text-primary">{fmt(costAnalysis.current?.perCapita || 0)}</span>
</div>
{costAnalysis.factors && costAnalysis.factors.length > 0 && (
<div className="space-y-1 border-t pt-2">
<div className="text-xs font-medium text-gray-600"></div>
{costAnalysis.factors.map((f: any, i: number) => (
<div key={i} className="flex items-start gap-1.5 text-xs">
<span className={`w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0 ${f.impact >= 0 ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-gray-600 flex-1">{f.description}</span>
</div>
))}
</div>
)}
{costAnalysis.departmentCost && costAnalysis.departmentCost.length > 0 && (
<div className="space-y-1.5 border-t pt-2">
<div className="text-xs font-medium text-gray-600"></div>
<div className="max-h-40 overflow-y-auto space-y-1">
{costAnalysis.departmentCost.map((d: any, i: number) => {
const maxCost = costAnalysis.departmentCost[0].totalCost || 1
return (
<div key={i} className="text-xs">
<div className="flex items-center justify-between mb-0.5">
<span className="text-gray-700">{d.department}{d.headcount}</span>
<span className="font-medium text-gray-800">{fmt(d.totalCost)}</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary/60 rounded-full"
style={{ width: `${(d.totalCost / maxCost) * 100}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mt-0.5">
<span> {fmt(d.totalPay)}</span>
<span> {fmt(d.socialOrg)}</span>
<span> {fmt(d.housingOrg)}</span>
<span> {fmt(d.perCapita)}</span>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-4"></div>
)}
</Card>
</div>
</div>
)}
{/* 薪税 Tab */}
{activeTab === 'payroll' && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" /></h2>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={handleExportPayroll} disabled={!payroll || payroll.payslipCount === 0}>
<Download className="w-4 h-4 mr-1" />
</Button>
<Link to="/money" className="text-xs text-primary hover:underline flex items-center gap-1">
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
{payroll && payroll.payslipCount > 0 ? (
<div className="space-y-3">
{/* 工资构成 */}
<div>
<div className="text-xs font-medium text-gray-600 mb-1.5"></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-xs 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-base font-bold text-primary">{fmt(payroll.totalPay)}</span>
</div>
{/* 扣减项 */}
<div>
<div className="text-xs font-medium text-gray-600 mb-1.5"></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-xs 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-base font-bold text-safe">{fmt(payroll.empNetPay)}</span>
</div>
{/* 企业成本 */}
<div className="border-t pt-2 space-y-2">
<div className="text-xs font-medium text-gray-600 mb-1"></div>
<div className="grid grid-cols-1 sm: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-xs 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-xs 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-xs 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-xs 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-base font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
</div>
</div>
{/* 工资条确认状态 */}
<div className="flex items-center gap-3 text-xs border-t pt-2">
<span className="text-gray-500"></span>
<span className="text-safe"> {payroll.confirmedPayslips}</span>
<span className="text-warning"> {payroll.unconfirmedPayslips}</span>
<span className="text-gray-500"> {payroll.payslipCount} </span>
</div>
</div>
) : (
<EmptyState title="本月暂无工资数据" description="请先在薪税页面生成本月工资条" />
)}
</Card>
)}
{/* 员工分布统计 */}
{activeTab === 'overview' && workforceStats && workforceStats.total > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* 性别分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Users className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.gender} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.gender.map((_: any, i: number) => <Cell key={i} fill={['#3b82f6', '#ec4899', '#9ca3af'][i % 3]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-2 text-xs mt-1">
{workforceStats.gender.map((g: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#3b82f6', '#ec4899', '#9ca3af'][i % 3] }} />
{g.name} {g.value}
</span>
))}
</div>
</Card>
{/* 年龄段分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Users className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.age.filter((a: any) => a.value > 0)} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.age.filter((a: any) => a.value > 0).map((_: any, i: number) => <Cell key={i} fill={['#22c55e', '#10b981', '#3b82f6', '#6366f1', '#f59e0b', '#ef4444'][i % 6]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.age.filter((a: any) => a.value > 0).map((a: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#22c55e', '#10b981', '#3b82f6', '#6366f1', '#f59e0b', '#ef4444'][i % 6] }} />
{a.name} {a.value}
</span>
))}
</div>
</Card>
{/* 学历分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><BookOpen className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.education} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.education.map((_: any, i: number) => <Cell key={i} fill={['#8b5cf6', '#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b', '#9ca3af'][i % 7]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.education.map((e: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#8b5cf6', '#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b', '#9ca3af'][i % 7] }} />
{e.name} {e.value}
</span>
))}
</div>
</Card>
{/* 司龄分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Clock className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.tenure.filter((t: any) => t.value > 0)} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.tenure.filter((t: any) => t.value > 0).map((_: any, i: number) => <Cell key={i} fill={['#a5f3fc', '#67e8f9', '#22d3ee', '#0891b2', '#155e75'][i % 5]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.tenure.filter((t: any) => t.value > 0).map((t: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#a5f3fc', '#67e8f9', '#22d3ee', '#0891b2', '#155e75'][i % 5] }} />
{t.name} {t.value}
</span>
))}
</div>
</Card>
</div>
)}
{/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-3">
{/* 待办列表 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<span className="text-xs text-gray-500">{filteredTodos.length} </span>
</div>
{filteredTodos.length === 0 ? (
<EmptyState title="暂无待办" description="所有事项已处理完毕" />
) : (
<>
{/* 批量操作栏 */}
<div className="flex items-center gap-2 mb-2 pb-2 border-b">
<button
onClick={() => toggleSelectAll(filteredTodos.map(t => t.id))}
className="text-xs text-primary hover:underline"
>
{filteredTodos.every(t => selectedIds.has(t.id)) ? '取消全选' : '全选'}
</button>
{selectedIds.size > 0 && (
<>
<span className="text-xs text-gray-500"> {selectedIds.size} </span>
<Button
size="sm"
variant="secondary"
onClick={() => batchResolveMutation.mutate([...selectedIds])}
disabled={batchResolveMutation.isPending}
>
<Check className="w-3 h-3 mr-1" />
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => batchIgnoreMutation.mutate([...selectedIds])}
disabled={batchIgnoreMutation.isPending}
>
<X className="w-3 h-3 mr-1" />
</Button>
</>
)}
</div>
<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-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-2.5 flex-1">
<input
type="checkbox"
checked={selectedIds.has(todo.id)}
onChange={() => toggleSelect(todo.id)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<div className="flex items-center gap-1.5 flex-wrap">
{todo.employeeName && (
<span className="text-xs font-bold text-gray-900">{todo.employeeName}</span>
)}
{todo.employeeDepartment && (
<span className="text-xs text-gray-400">{todo.employeeDepartment}</span>
)}
<span className="text-xs text-gray-700">{todo.title}</span>
{todo.priority && priorityConfig[todo.priority] && (
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
{priorityConfig[todo.priority].label}
</span>
)}
{todo.estimatedLoss > 0 && (
<span className="text-xs text-red-600 font-medium"> {fmt(todo.estimatedLoss)}</span>
)}
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}`}</span>
)}
</div>
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div>
</Link>
</div>
<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-500"
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-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" /></h2>
<span className="text-xs text-gray-500">{data.resolvedTodos.length} </span>
</div>
<div className="space-y-1.5">
{data.resolvedTodos.map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-2.5 py-2 rounded-md bg-gray-50"
>
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-xs text-gray-600 line-through">{todo.title}</span>
<span className="text-xs text-gray-500">{todo.description}</span>
</div>
</Link>
<span className="text-xs text-gray-500">
{todo.resolvedAt ? new Date(todo.resolvedAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
))}
</div>
</Card>
)}
</div>
)}
{/* 合同到期处理弹窗 */}
{showExpiringModal && expiringContracts && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowExpiringModal(false)}>
<Card className="max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-danger" />
<h3 className="text-sm font-medium"></h3>
<span className="text-xs text-gray-500">({expiringContracts.length})</span>
</div>
<button onClick={() => setShowExpiringModal(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-2">
{expiringContracts.map((c: any) => (
<div key={c.employeeId} className="flex items-center gap-3 p-3 rounded-md border border-gray-200">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{c.employeeName}</div>
<div className="text-xs text-gray-500">
{c.department} · {c.endDate ? new Date(c.endDate).toLocaleDateString('zh-CN') : '未知'}
<span className={`ml-2 ${c.daysLeft <= 7 ? 'text-danger' : c.daysLeft <= 30 ? 'text-warning' : 'text-gray-500'}`}>
{c.daysLeft}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
api.post('/work-processes', {
type: 'RENEW',
title: `合同续签-${c.employeeName}`,
employeeId: c.employeeId,
formData: { employeeId: c.employeeId, oldContractId: c.contractId },
status: 'DRAFT',
}).then(() => {
toast.success(`已创建 ${c.employeeName} 的续签流程`)
setShowExpiringModal(false)
}).catch((err) => {
toast.error(err?.response?.data?.error?.message || '创建失败')
})
}}
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-primary hover:bg-primary/10 transition-colors"
>
<Repeat className="w-3 h-3" />
</button>
<button
onClick={() => {
api.post('/work-processes', {
type: 'TERMINATE',
title: `合同终止-${c.employeeName}`,
employeeId: c.employeeId,
formData: { employeeId: c.employeeId, contractId: c.contractId },
status: 'DRAFT',
}).then(() => {
toast.success(`已创建 ${c.employeeName} 的终止流程`)
setShowExpiringModal(false)
}).catch((err) => {
toast.error(err?.response?.data?.error?.message || '创建失败')
})
}}
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-danger hover:bg-danger/10 transition-colors"
>
<XCircle className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t flex items-center justify-between">
<Link
to="/roster?contractStatus=expiring"
onClick={() => setShowExpiringModal(false)}
className="text-xs text-primary hover:underline"
>
</Link>
<Button size="sm" variant="secondary" onClick={() => setShowExpiringModal(false)}>
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
)
}