Files
TurboHR/frontend/src/pages/Calendar.tsx
T
selfrelease 16f22e6622 refactor: 全量迁移前端 API 调用到统一 api-services 服务层
- 新建 api-services-raw.ts 导出原始 axios 方法供特殊端点使用
- 完成 api-services.ts 全领域覆盖(auth/employee/roster/dashboard/attendance/payroll/socialInsurance/commercialInsurance/termination/policies/evidence/audit/calendar/companyFiles/notifications/settings/ai/platform/portal/survey/search)
- 迁移所有 47+ 页面文件:pages/、pages/roster/、pages/portal/、pages/platform/、pages/auth/、pages/dashboard/、pages/compliance/
- 移除所有直接 import api from '../../lib/api' 引用
- 修复 Termination.tsx / WorkProcess.tsx 中 string|null 类型错误
- 修复 SocialInsurance.tsx 中 api-services-raw delete 导入名
- 修复 PlatformLogin.tsx 变量遮蔽问题
- tsc --noEmit 零错误,vite build 成功
2026-08-01 16:13:19 +08:00

396 lines
17 KiB
TypeScript

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 { dashboardApi, calendarApi } from '../lib/api-services'
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 = ['日', '一', '二', '三', '四', '五', '六']
/** 本地日期格式化为 YYYY-MM-DD,避免 toISOString() 的 UTC 时区偏移 */
const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
/** 本地月份格式化为 YYYY-MM */
const fmtMonth = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
export default function Calendar() {
const queryClient = useQueryClient()
const [calendarMonth, setCalendarMonth] = useState(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
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().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}`,
type: 'CUSTOM',
priority: 'medium',
location: '',
description: '',
})
const { data: calendarData } = useQuery<any>({
queryKey: ['calendar', calendarMonth],
queryFn: () => dashboardApi.calendar(calendarMonth),
})
const { data: customEvents } = useQuery<any[]>({
queryKey: ['custom-events', calendarMonth],
queryFn: () => calendarApi.events(calendarMonth),
})
const createEventMutation = useMutation({
mutationFn: (data: any) => calendarApi.createEvent(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-events'] })
queryClient.invalidateQueries({ queryKey: ['calendar'] })
setShowEventForm(false)
setEventForm({ title: '', date: selectedDate || fmtDate(new Date()), type: 'CUSTOM', priority: 'medium', location: '', description: '' })
toast.success('事件已创建')
},
onError: () => toast.error('创建事件失败'),
})
const deleteEventMutation = useMutation({
mutationFn: (id: string) => calendarApi.removeEvent(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 = fmtDate(new Date())
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(fmtMonth(d))
}
const nextMonth = () => {
const [y, m] = calendarMonth.split('-').map(Number)
const d = new Date(y, m, 1)
setCalendarMonth(fmtMonth(d))
}
const goToday = () => setCalendarMonth(fmtMonth(new Date()))
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>
<input
type="month"
value={calendarMonth}
onChange={(e) => setCalendarMonth(e.target.value)}
className="text-sm font-medium rounded-md border border-input bg-background px-2 py-1 min-w-[120px] text-center"
/>
<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: fmtDate(new Date()) }); 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>
)
}