16f22e6622
- 新建 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 成功
396 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|