Sprint 4-5: 员工自助+考勤+合规+AI+搜索
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user