42e0c650a4
- AI文件审查:.docx上传提取文本,支持多种文档类型 - 用工办理工作流:WorkProcess页面+后端API,支持入职/续签/终止等流程 - 企业自建文本库:Templates页面Tab切换,企业模板CRUD+渲染+下载Word - 考勤发布:Attendance发布/取消发布按钮,员工端MyAttendance页面 - 工资条发布:Money发布/定时发送按钮+弹窗,portal端publishStatus过滤 - 合同到期弹窗:Dashboard合同到期预警可点击打开弹窗,支持续签/终止操作 - Prisma schema新增WorkProcess/EnterpriseTemplate/AttendancePublish模型 - 前后端编译验证全部通过
1127 lines
59 KiB
TypeScript
1127 lines
59 KiB
TypeScript
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>
|
||
|
||
{/* 紧急风险横幅 */}
|
||
{data.urgentRisk && (
|
||
<Link to={data.urgentRisk.actionUrl}>
|
||
<Card className="border-red-300 bg-red-50 hover:bg-red-100 transition-colors cursor-pointer">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-200 flex-shrink-0">
|
||
<AlertTriangle className="w-5 h-5 text-red-700" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-bold text-red-800">紧急风险</span>
|
||
<span className="px-1.5 py-0.5 rounded text-xs bg-red-200 text-red-800">预估损失 {fmt(data.urgentRisk.estimatedLoss)}</span>
|
||
{data.urgentRisk.daysUntilDeadline !== null && (
|
||
<span className={`px-1.5 py-0.5 rounded text-xs ${data.urgentRisk.daysUntilDeadline <= 0 ? 'bg-red-300 text-red-900' : 'bg-red-200 text-red-800'}`}>
|
||
{data.urgentRisk.daysUntilDeadline <= 0 ? '已逾期' : `${data.urgentRisk.daysUntilDeadline}天后截止`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-sm text-red-700 mt-0.5 truncate">{data.urgentRisk.title}</div>
|
||
<div className="text-xs text-red-600 mt-0.5">{data.urgentRisk.description}</div>
|
||
</div>
|
||
<ArrowRight className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||
</div>
|
||
</Card>
|
||
</Link>
|
||
)}
|
||
|
||
{/* 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">
|
||
<span className="text-xs text-gray-800">{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>
|
||
)
|
||
}
|