feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
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 } 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 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import type { DashboardData } from '../types'
|
||||
|
||||
function fmt(n: number) {
|
||||
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
const TODO_ICON_CONFIG: Record<string, { icon: typeof FileText; color: string; bg: string }> = {
|
||||
CONTRACT: { icon: FileText, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
SALARY: { icon: DollarSign, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
|
||||
MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||
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 { 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 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 = () => {
|
||||
const month = payroll?.month || new Date().toISOString().slice(0, 7)
|
||||
window.open(`/api/v1/export/payroll?month=${month}`, '_blank')
|
||||
}
|
||||
|
||||
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 stats = [
|
||||
{ label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary' },
|
||||
{ label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger' },
|
||||
{ label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning' },
|
||||
{ label: '月加班费', value: fmt(data.stats.monthlyOvertimePay), icon: DollarSign, color: 'text-safe' },
|
||||
]
|
||||
|
||||
const payroll = data.payrollSummary
|
||||
const activities = data.monthlyActivities
|
||||
|
||||
const activityItems = [
|
||||
{ label: '新签合同', value: activities?.newContracts ?? 0, icon: FileText, color: 'text-primary' },
|
||||
{ label: '解聘人数', value: activities?.terminations ?? 0, icon: Users, color: 'text-danger' },
|
||||
{ label: '违纪处理', value: activities?.disciplinaryActions ?? 0, icon: AlertTriangle, color: 'text-warning' },
|
||||
{ label: '考勤记录', value: activities?.attendanceRecords ?? 0, icon: Calendar, color: 'text-gray-600' },
|
||||
{ label: '加班时长', value: `${activities?.overtimeHours ?? 0}h`, icon: TrendingUp, color: 'text-safe' },
|
||||
{ label: '加班费', value: fmt(activities?.overtimePay ?? 0), icon: DollarSign, color: 'text-safe' },
|
||||
]
|
||||
|
||||
const payrollItems = [
|
||||
{ label: '基本工资', value: payroll?.baseSalary ?? 0, icon: Wallet, color: 'text-gray-700' },
|
||||
{ label: '加班费', value: payroll?.overtimePay ?? 0, icon: TrendingUp, color: 'text-gray-700' },
|
||||
{ label: '津贴补贴', value: payroll?.allowance ?? 0, icon: Wallet, color: 'text-gray-700' },
|
||||
{ label: '扣款', value: -(payroll?.deduction ?? 0), icon: Wallet, color: 'text-danger' },
|
||||
]
|
||||
|
||||
const deductionItems = [
|
||||
{ label: '个人社保', value: -(payroll?.socialEmp ?? 0) },
|
||||
{ label: '个人公积金', value: -(payroll?.housingEmp ?? 0) },
|
||||
{ label: '个人所得税', value: -(payroll?.estimatedTax ?? 0) },
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
|
||||
{ key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
|
||||
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xs font-medium">{data.greeting}</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{payroll?.month} 月度总览</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching} className={activeTab === 'risk' || activeTab === 'task' ? 'opacity-50 pointer-events-none' : ''}>
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{isFetching ? '刷新中...' : activeTab === 'payroll' ? '刷新薪税' : '刷新概览'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs 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">
|
||||
{/* 统计卡片 */}
|
||||
<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>
|
||||
|
||||
{/* 合同到期预警 */}
|
||||
{expiringContracts && expiringContracts.length > 0 && (
|
||||
<Link to="/roster?contractStatus=expiring">
|
||||
<Card className="border-danger/30 bg-danger/5 hover:bg-danger/10 transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<ArrowRight className="w-4 h-4 text-danger" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 本月工作动态 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="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>
|
||||
|
||||
{/* 风险分布 */}
|
||||
<Card>
|
||||
<h2 className="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="truncate text-gray-800">{r.title}</div>
|
||||
{r.employeeName && <div className="text-gray-500">{r.employeeName}</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>
|
||||
)}
|
||||
|
||||
{/* 薪税 Tab */}
|
||||
{activeTab === 'payroll' && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="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-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>
|
||||
)}
|
||||
|
||||
{/* 风险提醒 Tab */}
|
||||
{(activeTab === 'risk' || activeTab === 'task') && (
|
||||
<div className="space-y-3">
|
||||
{/* 待办列表 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="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">
|
||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
||||
<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="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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user