Files
TurboHR/frontend/src/pages/Dashboard.tsx
T
selfrelease ef6d4591be fix: 登记线下签署日期时同步创建 EsignRecord
登记签署日期后,签署记录 Tab 中也能看到这条记录。
创建一条 status=COMPLETED 的线下手签 EsignRecord,
避免已签合同在签署记录中无痕可查。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 12:17:39 +08:00

1371 lines
74 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 { usePageSize } from '../hooks/usePageSize'
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, ShieldCheck, Lightbulb, BookOpen, Sparkles, Repeat, XCircle, Settings } from 'lucide-react'
import { dashboardApi, rosterApi, workProcessApi } from '../lib/api-services'
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'
import TurnoverStats from './dashboard/TurnoverStats'
import PageGuide from '../components/ui/PageGuide'
import PerformanceStats from './dashboard/PerformanceStats'
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' },
RETIREMENT: { icon: Clock, color: 'text-orange-600', bg: 'bg-orange-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 todoPageSize = usePageSize()
const [todoPage, setTodoPage] = useState(1)
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'overview' | 'risk' | 'task' | 'cost' | 'workforce'>('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 [showTabSettings, setShowTabSettings] = useState(false)
const [tabVisibility, setTabVisibility] = useState<Record<string, boolean>>(() => {
try {
const saved = localStorage.getItem('dashboard-tab-visibility')
if (saved) return JSON.parse(saved)
} catch {}
return { overview: true, risk: true, task: true, cost: true, workforce: true }
})
const toggleTabVisibility = (key: string) => {
setTabVisibility(prev => {
const next = { ...prev, [key]: !prev[key] }
localStorage.setItem('dashboard-tab-visibility', JSON.stringify(next))
return next
})
}
const [settingsTab, setSettingsTab] = useState<string>('overview')
const [sectionVisibility, setSectionVisibility] = useState<Record<string, boolean>>(() => {
try {
const saved = localStorage.getItem('dashboard-section-visibility')
if (saved) return JSON.parse(saved)
} catch {}
return {}
})
const toggleSectionVisibility = (key: string) => {
setSectionVisibility(prev => {
const next = { ...prev, [key]: !prev[key] }
localStorage.setItem('dashboard-section-visibility', JSON.stringify(next))
return next
})
}
const isSectionVisible = (key: string) => sectionVisibility[key] !== false
const tabSections: Record<string, { key: string; label: string }[]> = {
overview: [
{ key: 'overview_stats', label: '统计卡片' },
{ key: 'overview_compliance', label: '合规评分' },
{ key: 'overview_ai', label: 'AI 建议' },
{ key: 'overview_expiring', label: '合同到期预警' },
{ key: 'overview_nav', label: '快捷导航' },
],
risk: [
{ key: 'risk_distribution', label: '风险分布饼图' },
{ key: 'risk_todos', label: '待办列表' },
{ key: 'risk_resolved', label: '已办事项' },
],
task: [
{ key: 'task_todos', label: '待办列表' },
{ key: 'task_resolved', label: '已办事项' },
],
cost: [
{ key: 'cost_metrics', label: '成本指标卡片' },
{ key: 'cost_overview', label: '月度/年度成本' },
{ key: 'cost_analysis', label: '成本分析' },
{ key: 'cost_payroll', label: '薪税明细' },
],
workforce: [
{ key: 'workforce_activities', label: '本月工作动态' },
{ key: 'workforce_turnover', label: '入离职/绩效统计' },
{ key: 'workforce_distribution', label: '员工分布' },
],
}
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: () => dashboardApi.data(),
})
const { data: expiringContracts } = useQuery<any>({
queryKey: ['expiring-contracts'],
queryFn: () => rosterApi.expiringContracts(),
})
const currentMonth = new Date().toISOString().slice(0, 7)
const { data: costAnalysis } = useQuery<any>({
queryKey: ['cost-analysis', currentMonth],
queryFn: () => dashboardApi.costAnalysis(currentMonth),
})
const { data: complianceScore } = useQuery<any>({
queryKey: ['compliance-score'],
queryFn: () => dashboardApi.healthCheck(),
})
const { data: workforceStats } = useQuery<any>({
queryKey: ['workforce-stats'],
queryFn: () => dashboardApi.workforceStats(),
})
const resolveMutation = useMutation({
mutationFn: (id: string) => dashboardApi.resolveTodo(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
})
const ignoreMutation = useMutation({
mutationFn: (id: string) => dashboardApi.ignoreTodo(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
})
const batchResolveMutation = useMutation({
mutationFn: (ids: string[]) => dashboardApi.batchResolveTodos(ids),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setSelectedIds(new Set())
},
})
const batchIgnoreMutation = useMutation({
mutationFn: (ids: string[]) => dashboardApi.batchIgnoreTodos(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' || t.type === 'RETIREMENT') || []
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 },
{ key: 'cost' as const, label: '人力成本', icon: Wallet, badge: 0 },
{ key: 'workforce' as const, label: '人员分析', icon: Users, badge: 0 },
]
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">
<PageGuide>
</PageGuide>
<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>
<div className="flex items-center gap-2">
<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 ? '刷新中...' : '刷新'}
</Button>
<div className="relative">
<Button variant="secondary" size="sm" onClick={() => setShowTabSettings(!showTabSettings)}>
<Settings className="w-4 h-4" />
</Button>
{showTabSettings && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowTabSettings(false)} />
<div className="absolute right-0 top-full mt-1 z-50 bg-white rounded-lg shadow-lg border p-3 w-[240px] max-h-[80vh] overflow-y-auto">
<div className="text-xs font-medium text-gray-500 mb-2"></div>
{tabs.map(tab => {
const TabIcon = tab.icon
const visible = tabVisibility[tab.key] !== false
const sections = tabSections[tab.key] || []
const expanded = settingsTab === tab.key
return (
<div key={tab.key} className="mb-1">
<div className="flex items-center gap-2">
<label className={`flex items-center gap-2 flex-1 py-1.5 cursor-pointer rounded px-1.5 ${tab.key === 'overview' ? 'opacity-60' : 'hover:bg-gray-50'}`}>
<input
type="checkbox"
checked={visible}
onChange={() => toggleTabVisibility(tab.key)}
disabled={tab.key === 'overview'}
className="w-3.5 h-3.5 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-40"
/>
<TabIcon className="w-3.5 h-3.5 text-gray-400" />
<span className={`text-sm ${tab.key === 'overview' ? 'text-gray-400' : 'text-gray-700'}`}>{tab.label}</span>
{tab.key === 'overview' && <span className="text-[10px] text-gray-400"></span>}
</label>
{visible && sections.length > 0 && (
<button
onClick={() => setSettingsTab(expanded ? '' : tab.key)}
className="text-gray-400 hover:text-gray-600 p-0.5"
>
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
)}
</div>
{expanded && visible && sections.length > 0 && (
<div className="ml-6 mt-0.5 mb-1 space-y-0.5">
{sections.map(section => (
<label key={section.key} className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 rounded px-1.5">
<input
type="checkbox"
checked={isSectionVisible(section.key)}
onChange={() => toggleSectionVisibility(section.key)}
className="w-3.5 h-3.5 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-xs text-gray-600">{section.label}</span>
</label>
))}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
</div>
</div>
{/* Tab 导航 */}
<div className="flex gap-1 border-b">
{tabs.filter(tab => tabVisibility[tab.key] !== false).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">
<PageGuide>
/
</PageGuide>
{/* 统计卡片 */}
{isSectionVisible('overview_stats') && (
<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>
)}
{/* 合规健康度评分 + AI 建议卡片流 */}
{isSectionVisible('overview_compliance') && isSectionVisible('overview_ai') && complianceScore && (
<div className="space-y-3">
{/* 评分环 + 维度 + 合同到期预警 */}
<Card>
<div className="flex items-center gap-4">
{/* SVG 环形评分 */}
<div className="relative w-32 h-32 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
innerRadius="70%"
outerRadius="100%"
data={[{ value: complianceScore?.overallScore ?? complianceScore?.totalScore ?? 0, 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 px-2 overflow-hidden">
<span className={`text-2xl font-bold leading-none ${complianceScore?.level === 'safe' ? 'text-safe' : complianceScore?.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore?.overallScore ?? complianceScore?.totalScore ?? 0}</span>
<span className="text-xs text-gray-500 mt-1 truncate max-w-full">{complianceScore?.levelLabel ?? ''}</span>
</div>
</div>
{/* 维度评分 */}
<div className="flex-1 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 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 flex-col items-center p-1.5 rounded-lg hover:bg-gray-50 transition-colors">
<span className={`text-lg font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
<span className="text-xs text-gray-600">{dim.name}</span>
{dim.todoCount > 0 && <span className="text-[10px] text-gray-400">{dim.todoCount}</span>}
</Link>
))}
</div>
</div>
{/* 合同到期预警内联 */}
{isSectionVisible('overview_expiring') && expiringContracts && expiringContracts.length > 0 && !dismissedExpiring && (
<div className="mt-3 pt-2 border-t flex items-center justify-between">
<div
className="flex items-center gap-2 cursor-pointer flex-1"
onClick={() => setShowExpiringModal(true)}
>
<AlertCircle className="w-4 h-4 text-danger" />
<span className="text-sm font-medium text-danger"></span>
<span className="text-xs text-gray-500">
{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>}
</span>
</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-3.5 h-3.5" />
</button>
</div>
</div>
)}
</Card>
{/* AI 建议卡片流 */}
{complianceScore?.suggestions && 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>
)}
{/* 快捷导航 */}
{isSectionVisible('overview_nav') && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<button onClick={() => setActiveTab('risk')} className="card flex items-center gap-2.5 p-3 hover:shadow-md transition-shadow text-left">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-red-50 text-danger">
<AlertTriangle className="w-4 h-4" />
</div>
<div>
<div className="text-base font-bold text-danger">{riskTodos.length}</div>
<div className="text-xs text-gray-500"></div>
</div>
</button>
<button onClick={() => setActiveTab('task')} className="card flex items-center gap-2.5 p-3 hover:shadow-md transition-shadow text-left">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-50 text-warning">
<ListTodo className="w-4 h-4" />
</div>
<div>
<div className="text-base font-bold text-warning">{taskTodos.length}</div>
<div className="text-xs text-gray-500"></div>
</div>
</button>
<button onClick={() => setActiveTab('cost')} className="card flex items-center gap-2.5 p-3 hover:shadow-md transition-shadow text-left">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 text-blue-600">
<Wallet className="w-4 h-4" />
</div>
<div>
<div className="text-base font-bold text-blue-600">{fmt(monthTotalCost)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</button>
<button onClick={() => setActiveTab('workforce')} className="card flex items-center gap-2.5 p-3 hover:shadow-md transition-shadow text-left">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-50 text-purple-600">
<Users className="w-4 h-4" />
</div>
<div>
<div className="text-base font-bold text-purple-600">{data.stats.employeeCount}</div>
<div className="text-xs text-gray-500"></div>
</div>
</button>
</div>
)}
</div>
)}
{/* 人力成本 Tab */}
{activeTab === 'cost' && (
<div className="space-y-3">
<PageGuide>
</PageGuide>
{/* 成本专属指标卡片 */}
{isSectionVisible('cost_metrics') && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 text-blue-600"><Wallet className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold">{fmt(monthTotalCost)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-green-50 text-safe"><TrendingUp className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold">{fmt(yearTotalCost)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-50 text-purple-600"><Users className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold">{fmt(costAnalysis?.current?.perCapita || 0)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
<Card className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-50 text-warning"><Receipt className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold">{payroll?.payslipCount ?? 0}</div>
<div className="text-xs text-gray-500"></div>
</div>
</Card>
</div>
)}
{/* 人力成本概览:当月 + 年度累计 */}
{isSectionVisible('cost_overview') && (
<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>
)}
{/* 人力成本分析 */}
{isSectionVisible('cost_analysis') && (
<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?.prevTotal > 0 ? (costAnalysis.monthOnMonth?.change >= 0 ? 'bg-red-50' : 'bg-green-50') : 'bg-gray-50'}`}>
<div className="text-xs text-gray-500"></div>
{costAnalysis.monthOnMonth?.prevTotal > 0 ? (
<>
<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 className="text-sm text-gray-400"></div>
)}
</div>
<div className={`p-2 rounded-lg ${costAnalysis.yearOnYear?.lastYearTotal > 0 ? (costAnalysis.yearOnYear?.change >= 0 ? 'bg-red-50' : 'bg-green-50') : 'bg-gray-50'}`}>
<div className="text-xs text-gray-500"></div>
{costAnalysis.yearOnYear?.lastYearTotal > 0 ? (
<>
<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 className="text-sm text-gray-400"></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>
)}
{/* 薪税明细 */}
{isSectionVisible('cost_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>
)}
</div>
)}
{/* 人员分析 Tab */}
{activeTab === 'workforce' && (
<div className="space-y-3">
<PageGuide>
</PageGuide>
{/* 本月工作动态 */}
{isSectionVisible('workforce_activities') && (
<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 lg:grid-cols-6 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>
)}
{/* 入离职统计 + 绩效统计 */}
{isSectionVisible('workforce_turnover') && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<TurnoverStats />
<PerformanceStats />
</div>
)}
{/* 员工分布统计 */}
{isSectionVisible('workforce_distribution') && 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>
)}
</div>
)}
{/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-3">
<PageGuide>
{activeTab === 'risk'
? '风险提醒页汇总合同到期、未签合同、离职风险、退休预警等高风险待办事项。可批量处理或逐项解决,处理完成后自动归档至已办事项。'
: '月度任务页汇总社保办理、发薪批次等周期性待办事项。可批量处理或逐项解决,处理完成后自动归档至已办事项。'}
</PageGuide>
{/* 风险分布饼图(仅风险提醒Tab) */}
{activeTab === 'risk' && isSectionVisible('risk_distribution') && (
<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>
)}
{/* 待办列表 */}
{isSectionVisible(activeTab === 'risk' ? 'risk_todos' : 'task_todos') && (
<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>
<div className="space-y-2">
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => {
const hasValidUrl = todo.actionUrl && todo.actionUrl !== '/' && todo.actionUrl !== ''
return (
<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"
/>
{hasValidUrl ? (
<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 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>
</div>
)}
</div>
<div className="flex items-center gap-1">
{hasValidUrl && (
<Link
to={todo.actionUrl}
className="px-2 py-1 rounded text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap"
>
</Link>
)}
<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>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
</>
)}
</Card>
)}
{/* 已办事项 */}
{isSectionVisible(activeTab === 'risk' ? 'risk_resolved' : 'task_resolved') && (() => {
const riskTypes = ['CONTRACT', 'TERMINATION', 'ONBOARDING', 'RETIREMENT']
const taskTypes = ['MONTHLY', 'SALARY']
const resolvedFiltered = (data.resolvedTodos || []).filter(t =>
activeTab === 'risk' ? riskTypes.includes(t.type) : taskTypes.includes(t.type)
)
if (resolvedFiltered.length === 0) return null
return (
<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">{resolvedFiltered.length} </span>
</div>
<div className="space-y-1.5">
{resolvedFiltered.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={() => {
workProcessApi.create({
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: any) => {
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={() => {
workProcessApi.create({
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: any) => {
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>
)
}