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
+674 -16
View File
@@ -1,10 +1,12 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Upload } from 'lucide-react'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
@@ -13,17 +15,88 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string;
DISPUTED: { label: '有异议', color: 'text-red-700', bg: 'bg-red-100', icon: AlertCircle },
}
/**
* 考勤确认管理页面
*/
const ATTENDANCE_STATUS: Record<string, string> = {
NORMAL: '正常',
LATE: '迟到',
EARLY_LEAVE: '早退',
ABSENT: '缺勤',
LEAVE: '请假',
BUSINESS_TRIP: '出差',
UNREGISTERED: '未打卡',
}
const LEAVE_TYPES: Record<string, string> = {
SICK: '病假',
PERSONAL: '事假',
ANNUAL: '年假',
MATERNITY: '产假',
OTHER: '其他',
}
const TABS = [
{ key: 'confirm', label: '考勤确认', icon: CalendarCheck },
{ key: 'shifts', label: '班次管理', icon: Clock },
{ key: 'schedule', label: '排班', icon: Calendar },
{ key: 'daily', label: '每日出勤', icon: Users },
{ key: 'monthly', label: '月度报表', icon: BarChart3 },
{ key: 'leaves', label: '休假记录', icon: Plane },
]
export default function Attendance() {
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState('confirm')
return (
<div className="space-y-4">
<div>
<div className="flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
{/* Tab 导航 */}
<div className="flex flex-wrap gap-1 border-b border-gray-200">
{TABS.map(tab => {
const Icon = tab.icon
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
)
})}
</div>
{activeTab === 'confirm' && <ConfirmTab />}
{activeTab === 'shifts' && <ShiftsTab />}
{activeTab === 'schedule' && <ScheduleTab />}
{activeTab === 'daily' && <DailyTab />}
{activeTab === 'monthly' && <MonthlyTab />}
{activeTab === 'leaves' && <LeavesTab />}
</div>
)
}
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [filterDepartment, setFilterDepartment] = useState('')
const { data: list, isLoading } = useQuery<any>({
queryKey: ['attendance', month],
queryKey: ['attendance', month, filterDepartment],
queryFn: async () => {
const res = await api.get(`/attendance?month=${month}`) as any
const params: any = { month }
if (filterDepartment) params.department = filterDepartment
const res = await api.get('/attendance', { params }) as any
return res.data
},
})
@@ -36,16 +109,25 @@ export default function Attendance() {
},
})
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div className="flex items-center gap-2 justify-end">
<select
value={filterDepartment}
onChange={e => setFilterDepartment(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<input
type="month"
value={month}
@@ -54,7 +136,6 @@ export default function Attendance() {
/>
</div>
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
@@ -117,3 +198,580 @@ export default function Attendance() {
</div>
)
}
// ========== 班次管理 Tab ==========
function ShiftsTab() {
const queryClient = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [editShift, setEditShift] = useState<any>(null)
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
const { data: shifts, isLoading } = useQuery<any>({
queryKey: ['shifts'],
queryFn: async () => {
const res = await api.get('/attendance/shifts') as any
return res.data
},
})
const saveMutation = useMutation({
mutationFn: async (data: any) => {
if (editShift) {
return api.put(`/attendance/shifts/${editShift.id}`, data)
}
return api.post('/attendance/shifts', data)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shifts'] })
setShowAdd(false)
setEditShift(null)
setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/shifts/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shifts'] }),
})
const handleSubmit = () => {
if (!form.name.trim()) return toast.error('请输入班次名称')
saveMutation.mutate(form)
}
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !shifts || shifts.length === 0 ? (
<EmptyState title="暂无班次" description="请先创建班次" />
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{shifts.map((s: any) => (
<Card key={s.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ background: s.color }} />
<span className="font-medium text-sm">{s.name}</span>
</div>
<div className="flex gap-1">
<button className="text-xs text-gray-400 hover:text-primary px-1" onClick={() => { setEditShift(s); setForm(s); setShowAdd(true) }}></button>
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(s.id) }}></button>
</div>
</div>
<div className="mt-2 text-xs text-gray-500 space-y-0.5">
<div>{s.startTime} {s.endTime}</div>
<div>{s.flexibleMinutes} {s.restMinutes} </div>
</div>
</Card>
))}
</div>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title={editShift ? '编辑班次' : '新增班次'}>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="如:早班、白班、夜班" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="time" value={form.startTime} onChange={e => setForm({ ...form, startTime: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="time" value={form.endTime} onChange={e => setForm({ ...form, endTime: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.flexibleMinutes} onChange={e => setForm({ ...form, flexibleMinutes: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.restMinutes} onChange={e => setForm({ ...form, restMinutes: Number(e.target.value) })} />
</div>
</div>
<div>
<Label></Label>
<input type="color" value={form.color} onChange={e => setForm({ ...form, color: e.target.value })} className="h-9 w-16 rounded border border-gray-200" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAdd(false)}></Button>
<Button onClick={handleSubmit} disabled={saveMutation.isPending}>{saveMutation.isPending ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
</div>
)
}
// ========== 排班 Tab ==========
function ScheduleTab() {
const queryClient = useQueryClient()
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [showAssign, setShowAssign] = useState(false)
const [selectedShiftId, setSelectedShiftId] = useState('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
const { data: shifts } = useQuery<any>({
queryKey: ['shifts'],
queryFn: async () => {
const res = await api.get('/attendance/shifts') as any
return res.data
},
})
const { data: assignments, isLoading } = useQuery<any>({
queryKey: ['shift-assignments', date],
queryFn: async () => {
const res = await api.get(`/attendance/shift-assignments?date=${date}`) as any
return res.data
},
})
const { data: dailyData } = useQuery<any>({
queryKey: ['daily-attendance', date],
queryFn: async () => {
const res = await api.get(`/attendance/daily?date=${date}`) as any
return res.data
},
})
const batchAssignMutation = useMutation({
mutationFn: (items: any[]) => api.post('/attendance/shift-assignments/batch', { items }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
setShowAssign(false)
setSelectedEmployeeIds(new Set())
setSelectedShiftId('')
toast.success('排班成功')
},
})
const deleteAssignmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/shift-assignments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
},
})
const handleBatchAssign = () => {
if (!selectedShiftId) return toast.error('请选择班次')
if (selectedEmployeeIds.size === 0) return toast.error('请选择员工')
const items = Array.from(selectedEmployeeIds).map(empId => ({ employeeId: empId, shiftId: selectedShiftId, date }))
batchAssignMutation.mutate(items)
}
const employees = dailyData || []
const assignmentMap: Map<string, any> = new Map((assignments || []).map((a: any) => [a.employeeId, a]))
const toggleEmployee = (id: string) => {
const next = new Set(selectedEmployeeIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setSelectedEmployeeIds(next)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<Button onClick={() => setShowAssign(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : employees.length === 0 ? (
<EmptyState title="暂无员工" description="没有可排班的员工" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{employees.map((emp: any) => {
const assignment = assignmentMap.get(emp.employeeId)
return (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3">
{assignment ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs" style={{ background: (assignment.shift as any)?.color + '20', color: (assignment.shift as any)?.color }}>
<div className="w-2 h-2 rounded-full" style={{ background: (assignment.shift as any)?.color }} />
{(assignment.shift as any)?.name} {(assignment.shift as any)?.startTime}-{(assignment.shift as any)?.endTime}
</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-center">
{assignment && (
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}></button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</Card>
)}
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="批量排班">
<div className="space-y-3">
<div>
<Label></Label>
<Select value={selectedShiftId} onChange={e => setSelectedShiftId(e.target.value)}>
<option value=""></option>
{(shifts || []).map((s: any) => (
<option key={s.id} value={s.id}>{s.name} ({s.startTime}-{s.endTime})</option>
))}
</Select>
</div>
<div>
<Label>{selectedEmployeeIds.size} </Label>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{employees.map((emp: any) => (
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
<span className="text-sm">{emp.name}</span>
<span className="text-xs text-gray-400">{emp.department}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAssign(false)}></Button>
<Button onClick={handleBatchAssign} disabled={batchAssignMutation.isPending}>{batchAssignMutation.isPending ? '排班中...' : '确认排班'}</Button>
</div>
</div>
</Modal>
</div>
)
}
// ========== 每日出勤 Tab ==========
function DailyTab() {
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const { data, isLoading } = useQuery<any>({
queryKey: ['daily-attendance', date],
queryFn: async () => {
const res = await api.get(`/attendance/daily?date=${date}`) as any
return res.data
},
})
const statusColors: Record<string, string> = {
NORMAL: 'bg-green-50 text-green-700',
LATE: 'bg-amber-50 text-amber-700',
EARLY_LEAVE: 'bg-orange-50 text-orange-700',
ABSENT: 'bg-red-50 text-red-700',
LEAVE: 'bg-blue-50 text-blue-700',
BUSINESS_TRIP: 'bg-purple-50 text-purple-700',
UNREGISTERED: 'bg-gray-100 text-gray-500',
}
return (
<div className="space-y-3">
<div className="flex justify-end">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
<EmptyState title="暂无员工" description="没有出勤数据" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left">退</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody>
{data.map((emp: any) => (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-xs text-gray-500">{emp.shift ? `${emp.shift.name}` : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime || '—'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[emp.status] || 'bg-gray-100 text-gray-500'}`}>
{ATTENDANCE_STATUS[emp.status] || emp.status}
</span>
</td>
<td className="px-4 py-3 text-right text-xs">{emp.workHours > 0 ? `${emp.workHours}h` : '—'}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
)
}
// ========== 月度报表 Tab ==========
function MonthlyTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const { data, isLoading } = useQuery<any>({
queryKey: ['monthly-report', month],
queryFn: async () => {
const res = await api.get(`/attendance/monthly-report?month=${month}`) as any
return res.data
},
})
const handleExport = () => {
if (!data || data.length === 0) return
const headers = ['姓名', '部门', '出勤天数', '迟到次数', '早退次数', '缺勤天数', '请假天数', '加班工时', '加班费', '确认状态']
const rows = data.map((r: any) => [
r.name, r.department, r.workDays, r.lateCount, r.earlyLeaveCount, r.absentDays, r.leaveDays,
r.overtimeHours, r.overtimePay, r.confirmationStatus === 'CONFIRMED' ? '已确认' : r.confirmationStatus === 'PENDING' ? '待确认' : r.confirmationStatus === 'DISPUTED' ? '有异议' : '未创建',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `attendance-report-${month}.csv`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<Button variant="secondary" onClick={handleExport} disabled={!data || data.length === 0}>
<BarChart3 className="w-4 h-4 mr-1" /> CSV
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
<EmptyState title="暂无报表数据" description="该月份没有出勤数据" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center">退</th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center">(h)</th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.map((r: any) => (
<tr key={r.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{r.name}</td>
<td className="px-4 py-3 text-gray-500">{r.department}</td>
<td className="px-4 py-3 text-center">{r.workDays}</td>
<td className="px-4 py-3 text-center">{r.lateCount > 0 ? <span className="text-amber-600">{r.lateCount}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.earlyLeaveCount > 0 ? <span className="text-orange-600">{r.earlyLeaveCount}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.absentDays > 0 ? <span className="text-red-600">{r.absentDays}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.leaveDays > 0 ? <span className="text-blue-600">{r.leaveDays}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.overtimeHours > 0 ? r.overtimeHours.toFixed(1) : '—'}</td>
<td className="px-4 py-3 text-right">{r.overtimePay > 0 ? `¥${r.overtimePay.toFixed(2)}` : '—'}</td>
<td className="px-4 py-3 text-center">
{r.confirmationStatus === 'CONFIRMED' ? <span className="text-xs text-green-600"></span>
: r.confirmationStatus === 'PENDING' ? <span className="text-xs text-amber-600"></span>
: r.confirmationStatus === 'DISPUTED' ? <span className="text-xs text-red-600"></span>
: <span className="text-xs text-gray-400"></span>}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
)
}
// ========== 休假记录 Tab ==========
function LeavesTab() {
const queryClient = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
const { data: leaves, isLoading } = useQuery<any>({
queryKey: ['leave-records'],
queryFn: async () => {
const res = await api.get('/attendance/leaves') as any
return res.data
},
})
const { data: rosterData } = useQuery<any>({
queryKey: ['roster-employees'],
queryFn: async () => {
const res = await api.get('/roster?pageSize=200') as any
return res.data
},
})
const createMutation = useMutation({
mutationFn: (data: any) => api.post('/attendance/leaves', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-records'] })
setShowAdd(false)
setForm({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
toast.success('休假记录已添加')
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/leaves/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['leave-records'] }),
})
const handleSubmit = () => {
if (!form.employeeId) return toast.error('请选择员工')
if (!form.startDate || !form.endDate) return toast.error('请选择日期')
createMutation.mutate(form)
}
const employees = rosterData || []
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !leaves || leaves.length === 0 ? (
<EmptyState title="暂无休假记录" description="点击右上角添加休假记录" />
) : (
<div className="space-y-2">
{leaves.map((lv: any) => (
<Card key={lv.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 flex-shrink-0">
<Plane className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{lv.employee?.name}</span>
<span className="text-xs text-gray-500">{lv.employee?.department}</span>
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-700">{LEAVE_TYPES[lv.leaveType] || lv.leaveType}</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
{lv.startDate?.toString().slice(0, 10)} ~ {lv.endDate?.toString().slice(0, 10)}{lv.days}
{lv.reason && <span className="ml-2">{lv.reason}</span>}
</div>
</div>
</div>
<button className="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(lv.id) }}>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</Card>
))}
</div>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title="新增休假记录">
<div className="space-y-3">
<div>
<Label></Label>
<Select value={form.employeeId} onChange={e => setForm({ ...form, employeeId: e.target.value })}>
<option value=""></option>
{employees.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Select value={form.leaveType} onChange={e => setForm({ ...form, leaveType: e.target.value })}>
{Object.entries(LEAVE_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.startDate} onChange={e => setForm({ ...form, startDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={e => setForm({ ...form, endDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input type="number" step="0.5" value={form.days} onChange={e => setForm({ ...form, days: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={e => setForm({ ...form, reason: e.target.value })} placeholder="请简述请假原因" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAdd(false)}></Button>
<Button onClick={handleSubmit} disabled={createMutation.isPending}>{createMutation.isPending ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
</div>
)
}