Sprint 4-5: 员工自助+考勤+合规+AI+搜索

This commit is contained in:
selfrelease
2026-08-01 06:45:59 +08:00
parent 9946197d20
commit a88c96299c
16 changed files with 1919 additions and 35 deletions
+198 -32
View File
@@ -1,7 +1,7 @@
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 { 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 { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
@@ -9,6 +9,8 @@ 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 },
@@ -90,19 +92,23 @@ export default function Attendance() {
// ========== 考勤确认 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],
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
},
@@ -156,38 +162,178 @@ function ConfirmTab() {
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-2 justify-end">
{currentPublish ? (
<Button size="sm" variant="secondary" onClick={() => cancelPublishMutation.mutate(currentPublish.id)}>
<X className="w-3.5 h-3.5 mr-1" />
{/* 流程指示器 */}
<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>
) : (
<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"
/>
<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 && (
@@ -215,10 +361,19 @@ function ConfirmTab() {
{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>
@@ -239,9 +394,20 @@ function ConfirmTab() {
)}
</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 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>