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