feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件) - 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录) - AI顾问新增人力报告Tab,支持流式生成+Word导出 - 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分 - 花名册/合同/解聘补偿新增部门和状态筛选 - 薪税管理新增工资表导入模板下载、银行代发CSV导出 - 社保公积金支持多公积金账户类型显示 - 数据导出新增花名册/解聘记录导出,中文文件名编码修复 - 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出 - 移除工作台日历卡片(已迁移至独立工作日历页面) - 新增20260728/20260729更新测试指导文档
This commit is contained in:
@@ -3,8 +3,9 @@ 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, CalendarDays, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles } from 'lucide-react'
|
||||
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 } 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'
|
||||
@@ -57,13 +58,6 @@ export default function Dashboard() {
|
||||
})
|
||||
|
||||
const currentMonth = new Date().toISOString().slice(0, 7)
|
||||
const { data: calendarData } = useQuery<any>({
|
||||
queryKey: ['calendar', currentMonth],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/dashboard/calendar?month=${currentMonth}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: costAnalysis } = useQuery<any>({
|
||||
queryKey: ['cost-analysis', currentMonth],
|
||||
@@ -81,6 +75,14 @@ export default function Dashboard() {
|
||||
},
|
||||
})
|
||||
|
||||
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'] }),
|
||||
@@ -107,9 +109,21 @@ export default function Dashboard() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleExportPayroll = () => {
|
||||
const month = payroll?.month || new Date().toISOString().slice(0, 7)
|
||||
window.open(`/api/v1/export/payroll?month=${month}`, '_blank')
|
||||
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) => {
|
||||
@@ -583,35 +597,8 @@ export default function Dashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* HR 月度日历 + 人力成本分析 */}
|
||||
<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"><CalendarDays className="w-4 h-4 text-primary" />本月关键日期</h2>
|
||||
<span className="text-xs text-gray-400">{currentMonth}</span>
|
||||
</div>
|
||||
{calendarData?.events && calendarData.events.length > 0 ? (
|
||||
<div className="space-y-1.5 max-h-64 overflow-y-auto">
|
||||
{calendarData.events.slice(0, 10).map((ev: any, i: number) => (
|
||||
<Link key={i} to={ev.actionUrl || '/'} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
|
||||
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
||||
ev.priority === 'high' ? 'bg-red-500' : ev.priority === 'medium' ? 'bg-amber-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<span className="text-gray-500 w-20 flex-shrink-0">{ev.date.slice(5)}</span>
|
||||
<span className="text-gray-800 truncate flex-1">{ev.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
{calendarData.events.length > 10 && (
|
||||
<div className="text-xs text-gray-500 text-center pt-1">还有 {calendarData.events.length - 10} 个事件</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 text-center py-4">本月暂无关键日期</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 人力成本分析 */}
|
||||
{/* 人力成本分析 */}
|
||||
<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>
|
||||
@@ -654,6 +641,36 @@ export default function Dashboard() {
|
||||
))}
|
||||
</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>
|
||||
@@ -767,6 +784,103 @@ export default function Dashboard() {
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user