55286819ae
P0-3: 修复合同状态判断逻辑,有合同记录但signDate为null时不再误判未签 P0-5: 修复参保城市默认北京问题,导入和预览均改为null P0-11: 添加全局ErrorBoundary防止白屏,三处布局均包裹 P1-2: 合同附件改为可选,允许先保存再补充上传 P1-7.2: 排班弹窗增加员工搜索(姓名/部门) P1-8.2: 加班费导入支持Excel(xlsx/xls)格式,兼容中英文列名 P1-9: 社保/公积金基数月度办理支持逐人修改,后端返回recordId
937 lines
42 KiB
TypeScript
937 lines
42 KiB
TypeScript
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 } from 'lucide-react'
|
||
import api from '../lib/api'
|
||
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'
|
||
|
||
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 [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||
const [filterDepartment, setFilterDepartment] = 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 fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const { data: list, isLoading } = useQuery<any>({
|
||
queryKey: ['attendance', month, filterDepartment],
|
||
queryFn: async () => {
|
||
const params: any = { month }
|
||
if (filterDepartment) params.department = filterDepartment
|
||
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: async () => {
|
||
const res = await api.get('/roster/departments') as any
|
||
return res.data || []
|
||
},
|
||
})
|
||
|
||
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 currentPublish = publishRecords?.find((r: any) => r.month === month && r.status === 'PUBLISHED')
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2 justify-end">
|
||
{currentPublish ? (
|
||
<Button size="sm" variant="secondary" onClick={() => cancelPublishMutation.mutate(currentPublish.id)}>
|
||
<X className="w-3.5 h-3.5 mr-1" />取消发布
|
||
</Button>
|
||
) : (
|
||
<Button size="sm" onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
|
||
{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={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>
|
||
|
||
{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
|
||
return (
|
||
<Card key={item.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-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-1 px-2 py-1 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
|
||
<StatusIcon className="w-3.5 h-3.5" />
|
||
<span className="text-xs font-medium">{config.label}</span>
|
||
</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: ['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>
|
||
)
|
||
}
|