Files
TurboHR/frontend/src/pages/Attendance.tsx
T
selfrelease fb924dea98 refactor: API口径统一 — 统计函数/员工列表/前端服务层
后端:
- /dashboard/compliance-score 改用 getHealthCheck,与体检诊断同口径
- 新增 /employees/list 端点(不分页,供各页面下拉选择)
- /employees/all-lite 增加 position 字段和 status 筛选参数
- getHealthCheck 未签合同改为查无合同记录的员工(非UNSIGNED类型)

前端:
- 新建 lib/api-services.ts 统一 API 服务层
- employeeApi/rosterApi/dashboardApi/attendanceApi 统一封装
- 6个页面迁移到统一 API 调用:
  - Attendance: employeeApi.list + rosterApi.departments
  - Termination: rosterApi.list + rosterApi.departments
  - Money: rosterApi.list + rosterApi.departments
  - PortalQRModal: employeeApi.list
  - AIAssistant: 3处 employeeApi.list 替代 /roster?pageSize=999
  - Contracts: rosterApi.departments
  - Dashboard: rosterApi.expiringContracts + dashboardApi.healthCheck/workforceStats
2026-08-01 15:29:13 +08:00

1098 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
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<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 [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 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<File | null>(null)
const [importResult, setImportResult] = useState<any>(null)
const [importing, setImporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const { data: list, isLoading } = useQuery<any>({
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<any>({
queryKey: ['attendance-stats', month],
queryFn: async () => {
const res = await api.get(`/attendance/stats?month=${month}`) as any
return res.data
},
})
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: () => rosterApi.departments(),
})
const { data: publishRecords } = useQuery<any[]>({
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 (
<div className="space-y-3">
{/* 流程指示器 */}
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{FLOW_STEPS.map((step, i) => (
<div key={i} className="flex items-center gap-1 shrink-0">
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs ${step.done ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-500'}`}>
{step.done ? <CheckCircle className="w-3.5 h-3.5" /> : <Clock className="w-3.5 h-3.5" />}
<span className="font-medium">{step.label}</span>
<span className="text-gray-400">{step.desc}</span>
</div>
{i < FLOW_STEPS.length - 1 && <span className="text-gray-300"></span>}
</div>
))}
</div>
{/* 指引提示 */}
{!currentPublish && pendingCount > 0 && (
<InlineAlert type="info" title="考勤确认流程">
</InlineAlert>
)}
{currentPublish && (
<InlineAlert type="success" title="考勤表已发布">
{month} {stats?.confirmed || 0}/{stats?.total || 0}
</InlineAlert>
)}
{stats?.disputed > 0 && (
<InlineAlert type="warning" title="有员工提出异议">
{stats.disputed}
</InlineAlert>
)}
<div className="flex items-center gap-2 justify-between flex-wrap">
<div className="flex items-center gap-2">
{pendingCount > 0 && (
<>
<Button
size="sm"
variant="secondary"
onClick={toggleSelectAllPending}
>
{allPendingSelected ? '取消全选' : '全选待确认'}
</Button>
{selectedIds.size > 0 && (
<Button
size="sm"
onClick={async () => {
const ok = await confirm({ title: '批量确认考勤', message: `确认将选中的 ${selectedIds.size} 条考勤记录标记为已确认?` })
if (ok) batchConfirmMutation.mutate({ ids: Array.from(selectedIds) })
}}
disabled={batchConfirmMutation.isPending}
>
<CheckCheck className="w-3.5 h-3.5 mr-1" />
({selectedIds.size})
</Button>
)}
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ok = await confirm({ title: '全部确认', message: `确认将全部 ${pendingCount} 条待确认记录标记为已确认?` })
if (ok) batchConfirmMutation.mutate({ all: true })
}}
disabled={batchConfirmMutation.isPending}
>
({pendingCount})
</Button>
</>
)}
</div>
<div className="flex items-center gap-2">
{currentPublish ? (
<Button size="sm" variant="secondary" onClick={async () => {
const ok = await confirm({ title: '取消发布', message: '取消发布后员工端将无法查看该月考勤表,确定操作?' })
if (ok) cancelPublishMutation.mutate(currentPublish.id)
}}>
<X className="w-3.5 h-3.5 mr-1" />
</Button>
) : (
<Button size="sm" onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending || pendingCount > 0}>
{publishMutation.isPending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Send className="w-3.5 h-3.5 mr-1" />}
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<select
value={filterStatus}
onChange={e => setFilterStatus(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>
<option value="PENDING"></option>
<option value="CONFIRMED"></option>
<option value="DISPUTED"></option>
</select>
<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}
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"
/>
</div>
</div>
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ 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 => (
<Card key={s.label} className="text-center">
<div className={`text-lg font-bold ${s.color}`}>{s.value}</div>
<div className="text-xs text-gray-500">{s.label}</div>
</Card>
))}
</div>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="本月暂无考勤确认记录" description="请先批量导入考勤数据" />
) : (
<div className="space-y-2">
{list.map((item: any) => {
const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING
const StatusIcon = config.icon
const isSelected = selectedIds.has(item.id)
return (
<Card key={item.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{item.status === 'PENDING' && (
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(item.id)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary shrink-0"
/>
)}
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-50 flex-shrink-0">
<CalendarCheck className="w-4 h-4 text-gray-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{item.employee?.name}</span>
<span className="text-xs text-gray-500">{item.employee?.department}</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span> {item.workDays} </span>
<span> {item.weekdayHours}h</span>
<span> {item.weekendHours}h</span>
<span> {item.holidayHours}h</span>
<span className="text-gray-700"> ¥{item.overtimePay?.toFixed(2)}</span>
</div>
{item.disputeNote && (
<div className="text-xs text-red-600 mt-1">{item.disputeNote}</div>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{item.status === 'PENDING' && (
<button
className="text-xs text-primary hover:underline"
onClick={() => singleConfirmMutation.mutate(item.id)}
disabled={singleConfirmMutation.isPending}
>
</button>
)}
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color}`}>
<StatusIcon className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{config.label}</span>
</div>
</div>
</div>
</Card>
)
})}
</div>
)}
{/* 导入考勤弹窗 */}
{showImport && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
<Card className="max-w-lg w-full" >
<div onClick={(e) => e.stopPropagation()} className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {month}</h2>
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '员工导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('下载模板失败') }
}}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
Sheet
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="attendance-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
<label htmlFor="attendance-import-file" className="cursor-pointer text-xs text-primary hover:underline">
{importFile ? importFile.name : '点击选择 Excel 文件'}
</label>
</div>
{importResult && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium"></div>
{importResult.attendance > 0 && <div>{importResult.attendance} </div>}
{importResult.overtime > 0 && <div>{importResult.overtime} </div>}
{importResult.employees > 0 && <div>{importResult.employees} </div>}
{importResult.contracts > 0 && <div>{importResult.contracts} </div>}
{importResult.skipped > 0 && <div className="text-amber-600"> {importResult.skipped} </div>}
{importResult.errors?.length > 0 && (
<div className="mt-1 pt-1 border-t border-green-200">
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
{importResult.errors.length > 5 && <div className="text-amber-600">... {importResult.errors.length - 5} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}></Button>
<Button size="sm" onClick={async () => {
if (!importFile) return toast.error('请选择文件')
setImporting(true)
setImportResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', importFile)
const res = await fetch('/api/v1/import/excel', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) { toast.error(data.error?.message || '导入失败') }
else {
setImportResult(data.data)
queryClient.invalidateQueries({ queryKey: ['attendance'] })
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
toast.success('考勤数据导入完成')
}
} catch (e: any) { toast.error(e?.message || '导入失败') }
finally { setImporting(false) }
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
</div>
</div>
</div>
</Card>
</div>
)}
</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 [searchQuery, setSearchQuery] = useState('')
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>
<input
type="text"
placeholder="搜索员工姓名或部门..."
value={searchQuery}
onChange={e => 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"
/>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{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) => (
<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: ['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 (
<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>
)
}