feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件) - 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录) - AI顾问新增人力报告Tab,支持流式生成+Word导出 - 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分 - 花名册/合同/解聘补偿新增部门和状态筛选 - 薪税管理新增工资表导入模板下载、银行代发CSV导出 - 社保公积金支持多公积金账户类型显示 - 数据导出新增花名册/解聘记录导出,中文文件名编码修复 - 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出 - 移除工作台日历卡片(已迁移至独立工作日历页面) - 新增20260728/20260729更新测试指导文档
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X, MapPin, User } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
CONTRACT_EXPIRY: 'bg-red-100 text-red-700 border-red-200',
|
||||
PROBATION_END: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
TERMINATION: 'bg-red-100 text-red-700 border-red-200',
|
||||
ANNIVERSARY: 'bg-green-100 text-green-700 border-green-200',
|
||||
RISK_DEADLINE: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
RETIREMENT: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
CUSTOM: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
MEETING: 'bg-cyan-100 text-cyan-700 border-cyan-200',
|
||||
TEAM_BUILDING: 'bg-pink-100 text-pink-700 border-pink-200',
|
||||
TRAINING: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||
INTERVIEW: 'bg-teal-100 text-teal-700 border-teal-200',
|
||||
}
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
CONTRACT_EXPIRY: '合同到期',
|
||||
PROBATION_END: '试用期到期',
|
||||
TERMINATION: '离职/解聘',
|
||||
ANNIVERSARY: '入职周年',
|
||||
RISK_DEADLINE: '风险截止',
|
||||
RETIREMENT: '退休',
|
||||
CUSTOM: '自定义',
|
||||
MEETING: '会议',
|
||||
TEAM_BUILDING: '团建',
|
||||
TRAINING: '培训',
|
||||
INTERVIEW: '面试',
|
||||
}
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
high: 'bg-red-500',
|
||||
medium: 'bg-amber-500',
|
||||
low: 'bg-gray-400',
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
|
||||
|
||||
export default function Calendar() {
|
||||
const queryClient = useQueryClient()
|
||||
const [calendarMonth, setCalendarMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showEventForm, setShowEventForm] = useState(false)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null)
|
||||
const [eventForm, setEventForm] = useState({
|
||||
title: '',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
type: 'CUSTOM',
|
||||
priority: 'medium',
|
||||
location: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const { data: calendarData } = useQuery<any>({
|
||||
queryKey: ['calendar', calendarMonth],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/dashboard/calendar?month=${calendarMonth}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: customEvents } = useQuery<any[]>({
|
||||
queryKey: ['custom-events', calendarMonth],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/calendar?month=${calendarMonth}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/calendar', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-events'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar'] })
|
||||
setShowEventForm(false)
|
||||
setEventForm({ title: '', date: selectedDate || new Date().toISOString().slice(0, 10), type: 'CUSTOM', priority: 'medium', location: '', description: '' })
|
||||
toast.success('事件已创建')
|
||||
},
|
||||
onError: () => toast.error('创建事件失败'),
|
||||
})
|
||||
|
||||
const deleteEventMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/calendar/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-events'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar'] })
|
||||
toast.success('事件已删除')
|
||||
},
|
||||
})
|
||||
|
||||
const calendarGrid = useMemo(() => {
|
||||
const [year, mon] = calendarMonth.split('-').map(Number)
|
||||
const firstDay = new Date(year, mon - 1, 1)
|
||||
const lastDay = new Date(year, mon, 0)
|
||||
const startWeekday = firstDay.getDay()
|
||||
const daysInMonth = lastDay.getDate()
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const cells: Array<{ day: number | null; date: string | null; events: any[]; isToday: boolean }> = []
|
||||
for (let i = 0; i < startWeekday; i++) cells.push({ day: null, date: null, events: [], isToday: false })
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${calendarMonth}-${String(d).padStart(2, '0')}`
|
||||
let dayEvents = (calendarData?.events || []).filter((ev: any) => ev.date === dateStr)
|
||||
if (typeFilter) dayEvents = dayEvents.filter((ev: any) => ev.type === typeFilter)
|
||||
cells.push({ day: d, date: dateStr, events: dayEvents, isToday: dateStr === todayStr })
|
||||
}
|
||||
return cells
|
||||
}, [calendarMonth, calendarData, typeFilter])
|
||||
|
||||
const allEvents = useMemo(() => {
|
||||
let events = calendarData?.events || []
|
||||
if (typeFilter) events = events.filter((ev: any) => ev.type === typeFilter)
|
||||
return events
|
||||
}, [calendarData, typeFilter])
|
||||
|
||||
const customEventMap = useMemo(() => {
|
||||
const map: Record<string, any> = {}
|
||||
for (const ev of (customEvents || [])) {
|
||||
map[ev.id] = ev
|
||||
}
|
||||
return map
|
||||
}, [customEvents])
|
||||
|
||||
const prevMonth = () => {
|
||||
const [y, m] = calendarMonth.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
setCalendarMonth(d.toISOString().slice(0, 7))
|
||||
}
|
||||
const nextMonth = () => {
|
||||
const [y, m] = calendarMonth.split('-').map(Number)
|
||||
const d = new Date(y, m, 1)
|
||||
setCalendarMonth(d.toISOString().slice(0, 7))
|
||||
}
|
||||
const goToday = () => setCalendarMonth(new Date().toISOString().slice(0, 7))
|
||||
|
||||
const handleDayClick = (date: string | null) => {
|
||||
if (!date) return
|
||||
setSelectedDate(date)
|
||||
setEventForm({ ...eventForm, date })
|
||||
setShowEventForm(true)
|
||||
}
|
||||
|
||||
const handleSubmitEvent = () => {
|
||||
if (!eventForm.title.trim()) {
|
||||
toast.error('请输入事件标题')
|
||||
return
|
||||
}
|
||||
createEventMutation.mutate(eventForm)
|
||||
}
|
||||
|
||||
const isCustomEvent = (ev: any) => {
|
||||
return !!customEventMap[ev.id] || ['CUSTOM', 'MEETING', 'TEAM_BUILDING', 'TRAINING', 'INTERVIEW'].includes(ev.type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<CalendarDays className="w-5 h-5 text-primary" />
|
||||
工作日历
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={prevMonth}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium min-w-[80px] text-center">{calendarMonth}</span>
|
||||
<Button variant="secondary" size="sm" onClick={nextMonth}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={goToday}>今天</Button>
|
||||
<Button size="sm" onClick={() => { setEventForm({ ...eventForm, date: new Date().toISOString().slice(0, 10) }); setShowEventForm(true) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建事件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型筛选 */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setTypeFilter(null)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${!typeFilter ? 'bg-gray-700 text-white border-gray-700' : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setTypeFilter(typeFilter === type ? null : type)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${typeFilter === type ? 'bg-gray-700 text-white border-gray-700' : EVENT_TYPE_COLORS[type] || 'bg-gray-100 text-gray-600 border-gray-200'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
{/* 月历网格 */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<div className="grid grid-cols-7 gap-px mb-1">
|
||||
{WEEKDAYS.map(wd => (
|
||||
<div key={wd} className="text-center text-xs font-medium text-gray-400 py-1.5">{wd}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-px">
|
||||
{calendarGrid.map((cell, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => handleDayClick(cell.date)}
|
||||
className={`min-h-[72px] p-1.5 rounded-md cursor-pointer transition-colors border ${
|
||||
cell.day === null
|
||||
? 'bg-gray-50/50 border-transparent cursor-default'
|
||||
: cell.isToday
|
||||
? 'bg-primary/5 border-primary/30 hover:bg-primary/10'
|
||||
: 'border-gray-100 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{cell.day && (
|
||||
<>
|
||||
<div className={`text-xs font-medium mb-0.5 ${cell.isToday ? 'text-primary' : 'text-gray-600'}`}>
|
||||
{cell.day}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{cell.events.slice(0, 3).map((ev: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
|
||||
title={ev.title}
|
||||
>
|
||||
<span className={`inline-block w-1 h-1 rounded-full mr-0.5 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
|
||||
{ev.title}
|
||||
</div>
|
||||
))}
|
||||
{cell.events.length > 3 && (
|
||||
<div className="text-[10px] text-gray-400 px-1">+{cell.events.length - 3} 更多</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 事件列表 */}
|
||||
<div>
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||
<CalendarDays className="w-4 h-4 text-primary" />
|
||||
{calendarMonth} 事件列表
|
||||
<span className="text-xs text-gray-400 font-normal">({allEvents.length})</span>
|
||||
</h3>
|
||||
{allEvents.length > 0 ? (
|
||||
<div className="space-y-1.5 max-h-[500px] overflow-y-auto">
|
||||
{allEvents.map((ev: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 px-2 py-2 rounded-md hover:bg-gray-50 group">
|
||||
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">{ev.date.slice(5)}</span>
|
||||
<span className={`text-[10px] px-1 py-0.5 rounded ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{EVENT_TYPE_LABELS[ev.type] || ev.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-800 mt-0.5 truncate">
|
||||
{ev.title}
|
||||
{ev.employeeName && <span className="text-gray-400 ml-1">— {ev.employeeName}</span>}
|
||||
</div>
|
||||
{ev.actionUrl && ev.actionUrl !== '/dashboard' && (
|
||||
<Link to={ev.actionUrl} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
|
||||
查看详情 →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{isCustomEvent(ev) && customEventMap[ev.id] && (
|
||||
<button
|
||||
onClick={() => deleteEventMutation.mutate(ev.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 text-center py-8">本月暂无事件</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新建事件弹窗 */}
|
||||
{showEventForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowEventForm(false)}>
|
||||
<Card className="w-full max-w-md mx-4" onClick={(e: any) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium flex items-center gap-1.5">
|
||||
<Plus className="w-4 h-4 text-primary" />新建日历事件
|
||||
</h3>
|
||||
<button onClick={() => setShowEventForm(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>标题</Label>
|
||||
<Input
|
||||
value={eventForm.title}
|
||||
onChange={(e) => setEventForm({ ...eventForm, title: e.target.value })}
|
||||
placeholder="如:月度全员会议"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>日期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={eventForm.date}
|
||||
onChange={(e) => setEventForm({ ...eventForm, date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={eventForm.type}
|
||||
onChange={(e) => setEventForm({ ...eventForm, type: e.target.value })}
|
||||
>
|
||||
<option value="CUSTOM">自定义</option>
|
||||
<option value="MEETING">会议</option>
|
||||
<option value="TEAM_BUILDING">团建</option>
|
||||
<option value="TRAINING">培训</option>
|
||||
<option value="INTERVIEW">面试</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>优先级</Label>
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={eventForm.priority}
|
||||
onChange={(e) => setEventForm({ ...eventForm, priority: e.target.value })}
|
||||
>
|
||||
<option value="high">高</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="low">低</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>地点</Label>
|
||||
<Input
|
||||
value={eventForm.location}
|
||||
onChange={(e) => setEventForm({ ...eventForm, location: e.target.value })}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={eventForm.description}
|
||||
onChange={(e) => setEventForm({ ...eventForm, description: e.target.value })}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowEventForm(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleSubmitEvent} disabled={createEventMutation.isPending}>
|
||||
{createEventMutation.isPending ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user