feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强

- 新增工作日历页面(月历视图、事件管理、自定义事件)
- 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录)
- AI顾问新增人力报告Tab,支持流式生成+Word导出
- 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分
- 花名册/合同/解聘补偿新增部门和状态筛选
- 薪税管理新增工资表导入模板下载、银行代发CSV导出
- 社保公积金支持多公积金账户类型显示
- 数据导出新增花名册/解聘记录导出,中文文件名编码修复
- 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出
- 移除工作台日历卡片(已迁移至独立工作日历页面)
- 新增20260728/20260729更新测试指导文档
This commit is contained in:
freedakgmail
2026-07-29 08:35:29 +08:00
parent d020d04a8a
commit fb36b10402
45 changed files with 3756 additions and 169 deletions
+154 -40
View File
@@ -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">