import { useState, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2, CheckCheck } from 'lucide-react' import api from '../lib/api' import { employeeApi, rosterApi } from '../lib/api-services' import { useAuthStore } from '../store/authStore' 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' import { InlineAlert } from '../components/ui/InlineAlert' import { useConfirm } from '../hooks/useConfirm' const STATUS_CONFIG: Record = { PENDING: { label: '待确认', color: 'text-amber-700', bg: 'bg-amber-100', icon: Clock }, CONFIRMED: { label: '已确认', color: 'text-green-700', bg: 'bg-green-100', icon: CheckCircle }, DISPUTED: { label: '有异议', color: 'text-red-700', bg: 'bg-red-100', icon: AlertCircle }, } const ATTENDANCE_STATUS: Record = { NORMAL: '正常', LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '缺勤', LEAVE: '请假', BUSINESS_TRIP: '出差', UNREGISTERED: '未打卡', } const LEAVE_TYPES: Record = { 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 [activeTab, setActiveTab] = useState('confirm') return (

考勤管理

班次设定、排班、出勤查询、月度报表、休假记录

{/* Tab 导航 */}
{TABS.map(tab => { const Icon = tab.icon return ( ) })}
{activeTab === 'confirm' && } {activeTab === 'shifts' && } {activeTab === 'schedule' && } {activeTab === 'daily' && } {activeTab === 'monthly' && } {activeTab === 'leaves' && }
) } // ========== 考勤确认 Tab ========== function ConfirmTab() { const queryClient = useQueryClient() const confirm = useConfirm() const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const [filterDepartment, setFilterDepartment] = useState('') const [filterStatus, setFilterStatus] = useState('') const [showImport, setShowImport] = useState(false) const [importFile, setImportFile] = useState(null) const [importResult, setImportResult] = useState(null) const [importing, setImporting] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const fileInputRef = useRef(null) const { data: list, isLoading } = useQuery({ queryKey: ['attendance', month, filterDepartment, filterStatus], queryFn: async () => { const params: any = { month } if (filterDepartment) params.department = filterDepartment if (filterStatus) params.status = filterStatus const res = await api.get('/attendance', { params }) as any return res.data }, }) const { data: stats } = useQuery({ queryKey: ['attendance-stats', month], queryFn: async () => { const res = await api.get(`/attendance/stats?month=${month}`) as any return res.data }, }) const { data: departmentList } = useQuery({ queryKey: ['roster-departments'], queryFn: () => rosterApi.departments(), }) const { data: publishRecords } = useQuery({ queryKey: ['attendance-publish-records'], queryFn: async () => { const res = await api.get('/attendance/publish-records') as any return res.data }, }) const publishMutation = useMutation({ mutationFn: async () => { const res = await api.post('/attendance/publish', { month }) as any return res.data }, onSuccess: () => { toast.success(`${month}月考勤表已发布`) queryClient.invalidateQueries({ queryKey: ['attendance-publish-records'] }) }, onError: (err: any) => toast.error(err?.response?.data?.error?.message || '发布失败'), }) const cancelPublishMutation = useMutation({ mutationFn: async (id: string) => { const res = await api.post(`/attendance/publish/${id}/cancel`) as any return res.data }, onSuccess: () => { toast.success('已取消发布') queryClient.invalidateQueries({ queryKey: ['attendance-publish-records'] }) }, onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消失败'), }) const batchConfirmMutation = useMutation({ mutationFn: async (params: { all?: boolean; ids?: string[] }) => { const res = await api.post('/attendance/batch-confirm', { month, ...params }) as any return res.data }, onSuccess: (data: any) => { toast.success(`已批量确认 ${data.count} 条考勤记录`) queryClient.invalidateQueries({ queryKey: ['attendance'] }) queryClient.invalidateQueries({ queryKey: ['attendance-stats'] }) setSelectedIds(new Set()) }, onError: (err: any) => toast.error(err?.response?.data?.error?.message || '批量确认失败'), }) const singleConfirmMutation = useMutation({ mutationFn: async (id: string) => { const res = await api.post('/attendance/confirm', { employeeId: list.find((i: any) => i.id === id)?.employeeId, month }) as any return res.data }, onSuccess: () => { toast.success('已确认') queryClient.invalidateQueries({ queryKey: ['attendance'] }) queryClient.invalidateQueries({ queryKey: ['attendance-stats'] }) }, onError: (err: any) => toast.error(err?.response?.data?.error?.message || '确认失败'), }) const currentPublish = publishRecords?.find((r: any) => r.month === month && r.status === 'PUBLISHED') const pendingCount = stats?.pending || 0 const pendingItems = (list || []).filter((i: any) => i.status === 'PENDING') const allPendingSelected = pendingItems.length > 0 && pendingItems.every((i: any) => selectedIds.has(i.id)) const toggleSelect = (id: string) => { const next = new Set(selectedIds) if (next.has(id)) next.delete(id) else next.add(id) setSelectedIds(next) } const toggleSelectAllPending = () => { if (allPendingSelected) { const next = new Set(selectedIds) pendingItems.forEach((i: any) => next.delete(i.id)) setSelectedIds(next) } else { const next = new Set(selectedIds) pendingItems.forEach((i: any) => next.add(i.id)) setSelectedIds(next) } } // 流程步骤 const FLOW_STEPS = [ { label: '导入考勤', desc: 'Excel 批量导入', done: (list?.length || 0) > 0 }, { label: 'HR 确认', desc: `待确认 ${pendingCount} 人`, done: pendingCount === 0 && (list?.length || 0) > 0 }, { label: '发布考勤表', desc: currentPublish ? '已发布' : '未发布', done: !!currentPublish }, { label: '员工确认', desc: stats ? `已确认 ${stats.confirmed}/${stats.total}` : '', done: stats?.confirmed === stats?.total && stats?.total > 0 }, ] return (
{/* 流程指示器 */}
{FLOW_STEPS.map((step, i) => (
{step.done ? : } {step.label} {step.desc}
{i < FLOW_STEPS.length - 1 && }
))}
{/* 指引提示 */} {!currentPublish && pendingCount > 0 && ( 请核对考勤数据后批量确认,确认无误后点击「发布考勤表」推送给员工端确认。员工有异议时可提交异议说明。 )} {currentPublish && ( {month}月考勤表已发布至员工端,员工可自行查看并确认。已确认 {stats?.confirmed || 0}/{stats?.total || 0} 人。 )} {stats?.disputed > 0 && ( {stats.disputed} 名员工对考勤数据有异议,请查看异议说明并处理。 )}
{pendingCount > 0 && ( <> {selectedIds.size > 0 && ( )} )}
{currentPublish ? ( ) : ( )} setMonth(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" />
{stats && (
{[ { label: '总计', value: stats.total, color: 'text-gray-700' }, { label: '待确认', value: stats.pending, color: 'text-amber-600' }, { label: '已确认', value: stats.confirmed, color: 'text-green-600' }, { label: '有异议', value: stats.disputed, color: 'text-red-600' }, ].map(s => (
{s.value}
{s.label}
))}
)} {isLoading ? (
加载中...
) : !list || list.length === 0 ? ( ) : (
{list.map((item: any) => { const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING const StatusIcon = config.icon const isSelected = selectedIds.has(item.id) return (
{item.status === 'PENDING' && ( toggleSelect(item.id)} className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary shrink-0" /> )}
{item.employee?.name} {item.employee?.department}
出勤 {item.workDays} 天 工作日加班 {item.weekdayHours}h 周末加班 {item.weekendHours}h 法定加班 {item.holidayHours}h 加班费 ¥{item.overtimePay?.toFixed(2)}
{item.disputeNote && (
异议说明:{item.disputeNote}
)}
{item.status === 'PENDING' && ( )}
{config.label}
) })}
)} {/* 导入考勤弹窗 */} {showImport && (
setShowImport(false)}>
e.stopPropagation()} className="p-4">

导入考勤数据 — {month}

模板中「考勤记录」Sheet 包含:姓名、身份证号、日期、考勤状态、上下班时间。身份证号优先匹配,未填时用姓名匹配。
{ setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
{importResult && (
导入完成
{importResult.attendance > 0 &&
考勤记录:{importResult.attendance} 条
} {importResult.overtime > 0 &&
加班记录:{importResult.overtime} 条
} {importResult.employees > 0 &&
员工:{importResult.employees} 人
} {importResult.contracts > 0 &&
合同:{importResult.contracts} 份
} {importResult.skipped > 0 &&
跳过 {importResult.skipped} 条
} {importResult.errors?.length > 0 && (
{importResult.errors.slice(0, 5).map((e: string, i: number) =>
{e}
)} {importResult.errors.length > 5 &&
...还有 {importResult.errors.length - 5} 条
}
)}
)}
)}
) } // ========== 班次管理 Tab ========== function ShiftsTab() { const queryClient = useQueryClient() const [showAdd, setShowAdd] = useState(false) const [editShift, setEditShift] = useState(null) const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }) const { data: shifts, isLoading } = useQuery({ 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 (
{isLoading ? (
加载中...
) : !shifts || shifts.length === 0 ? ( ) : (
{shifts.map((s: any) => (
{s.name}
上班时间:{s.startTime} — 下班时间:{s.endTime}
弹性时长:{s.flexibleMinutes} 分钟 — 休息时长:{s.restMinutes} 分钟
))}
)} setShowAdd(false)} title={editShift ? '编辑班次' : '新增班次'}>
setForm({ ...form, name: e.target.value })} placeholder="如:早班、白班、夜班" />
setForm({ ...form, startTime: e.target.value })} />
setForm({ ...form, endTime: e.target.value })} />
setForm({ ...form, flexibleMinutes: Number(e.target.value) })} />
setForm({ ...form, restMinutes: Number(e.target.value) })} />
setForm({ ...form, color: e.target.value })} className="h-9 w-16 rounded border border-gray-200" />
) } // ========== 排班 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>(new Set()) const [searchQuery, setSearchQuery] = useState('') const { data: shifts } = useQuery({ queryKey: ['shifts'], queryFn: async () => { const res = await api.get('/attendance/shifts') as any return res.data }, }) const { data: assignments, isLoading } = useQuery({ 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({ 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 = 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 (
setDate(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" />
{isLoading ? (
加载中...
) : employees.length === 0 ? ( ) : ( {employees.map((emp: any) => { const assignment = assignmentMap.get(emp.employeeId) return ( ) })}
姓名 部门 班次 操作
{emp.name} {emp.department} {assignment ? (
{(assignment.shift as any)?.name} {(assignment.shift as any)?.startTime}-{(assignment.shift as any)?.endTime} ) : ( 未排班 )}
{assignment && ( )}
)} setShowAssign(false)} title="批量排班">
setSearchQuery(e.target.value)} className="w-full px-3 py-2 mb-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" />
{employees.filter((emp: any) => { if (!searchQuery.trim()) return true const q = searchQuery.trim().toLowerCase() return emp.name?.toLowerCase().includes(q) || emp.department?.toLowerCase().includes(q) }).map((emp: any) => ( ))}
) } // ========== 每日出勤 Tab ========== function DailyTab() { const [date, setDate] = useState(new Date().toISOString().slice(0, 10)) const { data, isLoading } = useQuery({ queryKey: ['daily-attendance', date], queryFn: async () => { const res = await api.get(`/attendance/daily?date=${date}`) as any return res.data }, }) const statusColors: Record = { 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 (
setDate(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" />
{isLoading ? (
加载中...
) : !data || data.length === 0 ? ( ) : ( {data.map((emp: any) => ( ))}
姓名 部门 班次 签到 签退 状态 工时
{emp.name} {emp.department} {emp.shift ? `${emp.shift.name}` : '—'} {emp.checkInTime || '—'} {emp.checkOutTime || '—'} {ATTENDANCE_STATUS[emp.status] || emp.status} {emp.workHours > 0 ? `${emp.workHours}h` : '—'}
)}
) } // ========== 月度报表 Tab ========== function MonthlyTab() { const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const { data, isLoading } = useQuery({ 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 (
setMonth(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" />
{isLoading ? (
加载中...
) : !data || data.length === 0 ? ( ) : ( {data.map((r: any) => ( ))}
姓名 部门 出勤 迟到 早退 缺勤 请假 加班(h) 加班费 确认
{r.name} {r.department} {r.workDays} {r.lateCount > 0 ? {r.lateCount} : '0'} {r.earlyLeaveCount > 0 ? {r.earlyLeaveCount} : '0'} {r.absentDays > 0 ? {r.absentDays} : '0'} {r.leaveDays > 0 ? {r.leaveDays} : '0'} {r.overtimeHours > 0 ? r.overtimeHours.toFixed(1) : '—'} {r.overtimePay > 0 ? `¥${r.overtimePay.toFixed(2)}` : '—'} {r.confirmationStatus === 'CONFIRMED' ? 已确认 : r.confirmationStatus === 'PENDING' ? 待确认 : r.confirmationStatus === 'DISPUTED' ? 有异议 : }
)}
) } // ========== 休假记录 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({ queryKey: ['leave-records'], queryFn: async () => { const res = await api.get('/attendance/leaves') as any return res.data }, }) const { data: rosterData } = useQuery({ queryKey: ['employee-list'], queryFn: () => employeeApi.list({ status: 'ACTIVE' }), }) 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 (
{isLoading ? (
加载中...
) : !leaves || leaves.length === 0 ? ( ) : (
{leaves.map((lv: any) => (
{lv.employee?.name} {lv.employee?.department} {LEAVE_TYPES[lv.leaveType] || lv.leaveType}
{lv.startDate?.toString().slice(0, 10)} ~ {lv.endDate?.toString().slice(0, 10)}({lv.days}天) {lv.reason && 原因:{lv.reason}}
))}
)} setShowAdd(false)} title="新增休假记录">
setForm({ ...form, startDate: e.target.value })} />
setForm({ ...form, endDate: e.target.value })} />
setForm({ ...form, days: Number(e.target.value) })} />
setForm({ ...form, reason: e.target.value })} placeholder="请简述请假原因" />
) }