feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件) - 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录) - AI顾问新增人力报告Tab,支持流式生成+Word导出 - 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分 - 花名册/合同/解聘补偿新增部门和状态筛选 - 薪税管理新增工资表导入模板下载、银行代发CSV导出 - 社保公积金支持多公积金账户类型显示 - 数据导出新增花名册/解聘记录导出,中文文件名编码修复 - 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出 - 移除工作台日历卡片(已迁移至独立工作日历页面) - 新增20260728/20260729更新测试指导文档
This commit is contained in:
@@ -2,8 +2,9 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet } from 'lucide-react'
|
||||
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet, Download } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -39,18 +40,20 @@ export default function Roster() {
|
||||
const [previewData, setPreviewData] = useState<any>(null)
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterContractStatus, setFilterContractStatus] = useState('')
|
||||
const [filterDepartment, setFilterDepartment] = useState('')
|
||||
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
|
||||
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
||||
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
|
||||
|
||||
const { data: rosterData, isLoading } = useQuery<any>({
|
||||
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus],
|
||||
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
|
||||
queryFn: async () => {
|
||||
const params: any = { page, pageSize }
|
||||
if (debouncedSearch) params.search = debouncedSearch
|
||||
if (filterStatus) params.status = filterStatus
|
||||
if (filterContractStatus) params.contractStatus = filterContractStatus
|
||||
if (filterDepartment) params.department = filterDepartment
|
||||
const res = await api.get('/roster', { params }) as any
|
||||
return res
|
||||
},
|
||||
@@ -206,10 +209,19 @@ export default function Roster() {
|
||||
setSearch('')
|
||||
setFilterStatus('')
|
||||
setFilterContractStatus('')
|
||||
setFilterDepartment('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || filterStatus || filterContractStatus
|
||||
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
|
||||
|
||||
const { data: departmentList } = useQuery<string[]>({
|
||||
queryKey: ['roster-departments'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster/departments') as any
|
||||
return res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const filtered = employees?.filter((e: any) =>
|
||||
!search || e.name.includes(search) || e.department.includes(search)
|
||||
@@ -231,7 +243,7 @@ export default function Roster() {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-start gap-2 xl:justify-end">
|
||||
<Input
|
||||
placeholder="搜索姓名或部门"
|
||||
placeholder="搜索姓名、部门或身份证后4位"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="!w-full sm:!w-64 shrink-0"
|
||||
@@ -259,6 +271,14 @@ export default function Roster() {
|
||||
<option value="unsigned_over_30">未签合同(超30天)</option>
|
||||
<option value="unsigned_over_year">未签合同(已满一年)</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterDepartment}
|
||||
onChange={(e) => { setFilterDepartment(e.target.value); setPage(1) }}
|
||||
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>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700">清除筛选</button>
|
||||
)}
|
||||
@@ -268,6 +288,28 @@ export default function Roster() {
|
||||
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
|
||||
<Upload className="mr-1.5 h-4 w-4" />批量导入
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (debouncedSearch) params.set('search', debouncedSearch)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterDepartment) params.set('department', filterDepartment)
|
||||
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/export/roster?${params}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
if (!res.ok) throw new Error('导出失败')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `花名册-${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('导出失败') }
|
||||
}} className="h-9 shrink-0">
|
||||
<Download className="mr-1.5 h-4 w-4" />导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -308,6 +350,7 @@ export default function Roster() {
|
||||
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
|
||||
</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="hidden px-4 py-3 text-left">入职日期</th>
|
||||
@@ -335,6 +378,7 @@ export default function Roster() {
|
||||
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{e.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs font-mono">{e.idCardMasked || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{e.department}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
|
||||
Reference in New Issue
Block a user