Files
TurboHR/frontend/src/pages/Roster.tsx
T

1024 lines
52 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, useMemo, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
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 Pagination from '../components/ui/Pagination'
import { fmt, terminateReasonMap } from './roster/shared'
import PageGuide from '../components/ui/PageGuide'
import EmployeeProfile from './roster/EmployeeProfile'
import { InlineAlert } from '../components/ui/InlineAlert'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
import { ImportSettings } from './Settings'
import QueryError from '../components/ui/QueryError'
const ROSTER_COLUMNS = [
{ key: 'department', label: '部门' },
{ key: 'status', label: '状态' },
{ key: 'hireDate', label: '入职日期' },
{ key: 'position', label: '职务' },
{ key: 'phone', label: '手机号' },
{ key: 'gender', label: '性别' },
{ key: 'contractType', label: '合同类型' },
{ key: 'contractStatus', label: '合同状态' },
{ key: 'contractExpiry', label: '合同到期' },
{ key: 'socialStatus', label: '社保状态' },
{ key: 'records', label: '记录' },
] as const
const DEFAULT_VISIBLE = ['department', 'status', 'hireDate', 'position', 'phone', 'gender', 'contractType', 'contractStatus', 'contractExpiry', 'socialStatus']
function useRosterColumns() {
const [visible, setVisible] = useState<string[]>(() => {
try {
const saved = localStorage.getItem('roster-columns')
return saved ? JSON.parse(saved) : DEFAULT_VISIBLE
} catch { return DEFAULT_VISIBLE }
})
const toggle = (key: string) => {
setVisible(prev => {
const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
localStorage.setItem('roster-columns', JSON.stringify(next))
return next
})
}
const isVisible = (key: string) => visible.includes(key)
return { isVisible, toggle, visible }
}
export default function Roster() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
const [showAddModal, setShowAddModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [showResignModal, setShowResignModal] = useState(false)
const [resignEmployee, setResignEmployee] = useState<any>(null)
const [showRehireModal, setShowRehireModal] = useState(false)
const [rehireEmployee, setRehireEmployee] = useState<any>(null)
const [showSalaryModal, setShowSalaryModal] = useState(false)
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
const [showDeptModal, setShowDeptModal] = useState(false)
const [deptEmployee, setDeptEmployee] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
const [batchRenewYears, setBatchRenewYears] = useState(3)
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, isError, error, refetch } = useQuery<any>({
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
queryFn: async () => {
const params: any = { page, pageSize }
if (debouncedSearch) params.search = debouncedSearch
if (filterStatus && filterStatus !== 'PROBATION') params.status = filterStatus
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
if (filterContractStatus) params.contractStatus = filterContractStatus
if (filterDepartment) params.department = filterDepartment
const res = await rosterApi.list({ search: debouncedSearch, page, pageSize, status: filterStatus === 'PROBATION' ? 'ACTIVE' : filterStatus || undefined, contractStatus: filterContractStatus || undefined, department: filterDepartment || undefined } as any) as any
return res
},
})
const employees = (rosterData?.data || []).filter((e: any) => {
if (filterStatus === 'PROBATION') return e.probationInfo?.isProbation
return true
})
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
// 从 URL query 参数自动定位员工(从待办跳转时)
const employeeParam = searchParams.get('employee')
useEffect(() => {
if (!employeeParam) return
setSearch(employeeParam)
// 只有当 debouncedSearch 与参数一致时,搜索结果才真正匹配
if (debouncedSearch === employeeParam && !isLoading && employees.length > 0 && !selectedId) {
setSelectedId(employees[0].id)
// 清除 URL 参数,避免后续搜索时重复触发
setSearchParams({}, { replace: true })
}
}, [employeeParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
const addMutation = useMutation({
mutationFn: (data: any) => employeeApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowAddModal(false)
},
})
const resignMutation = useMutation({
mutationFn: (data: any) => terminationApi.createDraft({
employeeId: data.employeeId,
type: 'RESIGNATION',
reason: 'RESIGNATION',
terminationDate: data.terminationDate,
resignationReason: data.resignationReason,
remark: data.remark,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程', {
action: { label: '前往处理', onClick: () => navigate('/termination') },
})
setShowResignModal(false)
setResignEmployee(null)
},
})
const revokeMutation = useMutation({
mutationFn: (recordId: string) => terminationApi.revoke(recordId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
},
})
const rehireMutation = useMutation({
mutationFn: (data: any) => employeeApi.rehire(rehireEmployee?.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowRehireModal(false)
setRehireEmployee(null)
},
})
const salaryChangeMutation = useMutation({
mutationFn: (data: any) => rosterApi.salaryChange(salaryEmployee?.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowSalaryModal(false)
setSalaryEmployee(null)
},
})
const deptChangeMutation = useMutation({
mutationFn: (data: any) => rosterApi.departmentChange(deptEmployee?.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowDeptModal(false)
setDeptEmployee(null)
},
})
const batchRenewMutation = useMutation({
mutationFn: (data: { contractIds: string[]; years: number }) => employeeApi.batchRenew(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowBatchRenewModal(false)
setSelectedIds(new Set())
setPreviewData(null)
},
})
const previewRenewMutation = useMutation({
mutationFn: (contractIds: string[]) => employeeApi.previewRenew(contractIds),
onSuccess: (data: any) => {
setPreviewData(data.data)
},
})
const previewTerminateMutation = useMutation({
mutationFn: (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) =>
terminationApi.batchPreview(items),
onSuccess: (data: any) => {
setTerminatePreviewData(data.data)
},
})
const batchTerminateMutation = useMutation({
mutationFn: async (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) => {
const results = await Promise.all(
items.map(item => terminationApi.createDraft({
employeeId: item.employeeId,
type: 'TERMINATION',
reason: item.reason,
terminationDate: item.terminationDate,
}).catch((err: any) => ({ error: err, employeeId: item.employeeId })))
)
return results
},
onSuccess: (results: any) => {
const success = results.filter((r: any) => !r?.error).length
const failed = results.filter((r: any) => r?.error).length
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
if (success > 0) toast.success(`已创建 ${success} 个解聘草稿,请前往「解聘补偿」页面完成流程`)
if (failed > 0) toast.error(`${failed} 个员工创建草稿失败`)
setShowBatchTerminateModal(false)
setTerminatePreviewData(null)
setSelectedIds(new Set())
},
})
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === employees.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(employees.map((e: any) => e.id)))
}
}
const clearFilters = () => {
setSearch('')
setFilterStatus('')
setFilterContractStatus('')
setFilterDepartment('')
setPage(1)
}
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
/** 全局合同风险统计(来自后端,与风险中心同口径) */
const globalRiskStats = rosterData?.globalRiskStats
const riskStats = useMemo(() => {
if (globalRiskStats) return globalRiskStats
// fallback: 当前页统计
const expiring = employees.filter((e: any) => e.contractStatus === 'expiring').length
const expired = employees.filter((e: any) => e.contractStatus === 'expired').length
const unsigned = employees.filter((e: any) => ['unsigned', 'unsigned_over_30', 'unsigned_over_year'].includes(e.contractStatus)).length
const probation = employees.filter((e: any) => e.probationInfo?.isProbation && e.probationInfo?.isExpiring).length
return { expiring, expired, unsigned, probation }
}, [globalRiskStats, employees])
/** 快捷筛选标签 */
const quickFilters = [
{ key: 'expiring', label: '即将到期', count: riskStats.expiring, color: 'amber', filter: { status: '', contractStatus: 'expiring', department: '' } },
{ key: 'expired', label: '已到期', count: riskStats.expired, color: 'rose', filter: { status: '', contractStatus: 'expired', department: '' } },
{ key: 'unsigned', label: '未签合同', count: riskStats.unsigned, color: 'rose', filter: { status: '', contractStatus: 'unsigned', department: '' } },
{ key: 'probation', label: '试用期即将到期', count: riskStats.probation, color: 'amber', filter: { status: 'PROBATION', contractStatus: '', department: '' } },
].filter(f => f.count > 0)
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
return await rosterApi.departments()
},
})
const { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
const [showColSettings, setShowColSettings] = useState(false)
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search) || (e.idCardMasked && e.idCardMasked.includes(search))
) || []
// 通讯录视图数据
if (selectedId) {
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
}
return (
<div className="space-y-5">
<PageGuide>
</PageGuide>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<div className="flex items-center gap-2">
<Users 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>
<div className="flex flex-wrap items-center justify-start gap-2 xl:justify-end">
<Input
placeholder="搜索姓名、部门或身份证后4位"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="!w-full sm:!w-64 shrink-0"
/>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(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>
<option value="ACTIVE"></option>
<option value="PROBATION"></option>
<option value="PRE_HIRE"></option>
<option value="RESIGNED"></option>
</select>
<select
value={filterContractStatus}
onChange={(e) => { setFilterContractStatus(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>
<option value="active"></option>
<option value="expiring"></option>
<option value="expired"></option>
<option value="unsigned"></option>
<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>
)}
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
<Plus className="mr-1.5 h-4 w-4" />
</Button>
<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 className="relative shrink-0">
<Button variant="secondary" onClick={() => setShowColSettings(v => !v)} className="h-9">
<Settings2 className="mr-1.5 h-4 w-4" />
</Button>
{showColSettings && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowColSettings(false)} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-lg border border-gray-200 bg-white shadow-lg py-2">
<div className="px-3 pb-1 text-xs font-medium text-gray-400"></div>
{ROSTER_COLUMNS.map(col => (
<label key={col.key} className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={colVisible(col.key)}
onChange={() => colToggle(col.key)}
className="rounded border-gray-300"
/>
<span className="text-gray-700">{col.label}</span>
</label>
))}
</div>
</>
)}
</div>
</div>
</div>
{selectedIds.size > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3">
<span className="text-sm font-medium text-gray-700"> {selectedIds.size} </span>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
<Check className="mr-1 h-4 w-4" />
</Button>
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
<UserX className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
)}
{/* 合同风险提示 */}
{!isLoading && (riskStats.expired > 0 || riskStats.unsigned > 0) && (
<InlineAlert
type="warning"
title="合同风险提醒"
closable
>
{riskStats.expired > 0 && <span> <strong className="text-danger">{riskStats.expired}</strong> </span>}
{riskStats.unsigned > 0 && <span><strong className="text-danger">{riskStats.unsigned}</strong> </span>}
<span className="text-gray-500"></span>
</InlineAlert>
)}
{/* 快捷筛选标签 */}
{!isLoading && quickFilters.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-400"></span>
{quickFilters.map((f) => (
<button
key={f.key}
onClick={() => {
setFilterStatus(f.filter.status)
setFilterContractStatus(f.filter.contractStatus)
setFilterDepartment(f.filter.department)
setPage(1)
}}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-all hover:shadow-sm ${
f.color === 'rose'
? 'border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100'
: 'border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100'
}`}
>
{f.label}{f.count}
</button>
))}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-gray-200 bg-white py-16 text-center text-sm text-gray-400">...</div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : filtered.length === 0 ? (
<Card><div className="py-12 text-center text-sm text-gray-400"></div></Card>
) : (
<Card className="overflow-hidden p-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] 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 w-8">
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
</th>
<th className="px-4 py-3 text-left"></th>
{colVisible('department') && <th className="px-4 py-3 text-left"></th>}
{colVisible('status') && <th className="px-4 py-3 text-left"></th>}
{colVisible('hireDate') && <th className="px-4 py-3 text-left"></th>}
<th className="hidden px-4 py-3 text-left"></th>
{colVisible('position') && <th className="px-4 py-3 text-left"></th>}
{colVisible('phone') && <th className="px-4 py-3 text-left"></th>}
{colVisible('gender') && <th className="px-4 py-3 text-center"></th>}
{colVisible('contractType') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractExpiry') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('records') && <th className="px-4 py-3 text-center"></th>}
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{employees.map((e: any) => (
<tr
key={e.id}
className="border-b border-gray-100 last:border-0 cursor-pointer transition-colors hover:bg-primary/[0.03]"
onClick={() => setSelectedId(e.id)}
>
<td className="px-4 py-3" onClick={(ev) => ev.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
</td>
<td className="px-4 py-3 font-medium">
<div>{e.name}</div>
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
ev.stopPropagation()
if (e.idCardNumber) {
navigator.clipboard.writeText(e.idCardNumber).then(() => toast.success('已复制身份证号')).catch(() => toast.error('复制失败'))
}
}}>{e.idCardMasked || '—'}</div>
</td>
{colVisible('department') && <td className="px-4 py-3 text-gray-500">{e.department}</td>}
{colVisible('status') && <td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
: 'bg-gray-100 text-gray-500'
}`}>
{e.status === 'ACTIVE' ? '在职' : e.status === 'PRE_HIRE' ? '预入职' : '离职'}
</span>
{e.probationInfo?.isProbation && (
<span className={`ml-1 px-2 py-0.5 rounded text-xs ${
e.probationInfo.isExpiring
? 'bg-orange-50 text-orange-700 border border-orange-200'
: 'bg-amber-50 text-amber-700 border border-amber-200'
}`}>
{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `${e.probationInfo.daysToConfirm}`}
</span>
)}
</td>}
{colVisible('hireDate') && <td className="px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
<td className="hidden px-4 py-3 text-gray-500">
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
{e.latestTerminationDate.toString().slice(0, 10)}
{e.status === 'ACTIVE' && ' (预计)'}
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
{colVisible('position') && <td className="px-4 py-3 text-gray-500">{e.position || '—'}</td>}
{colVisible('phone') && <td className="px-4 py-3 text-gray-500">{e.phone || '—'}</td>}
{colVisible('gender') && <td className="px-4 py-3 text-center text-gray-500">{e.gender || '—'}</td>}
{colVisible('contractType') && <td className="px-4 py-3">
{(() => {
const typeConfig: Record<string, { label: string; style: string }> = {
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
UNFIXED: { label: '劳动合同-无固定期', style: 'bg-purple-50 text-purple-700 border border-purple-200' },
LABOR: { label: '劳务协议', style: 'bg-amber-50 text-amber-700 border border-amber-200' },
INTERNSHIP: { label: '实习协议', style: 'bg-teal-50 text-teal-700 border border-teal-200' },
DISPATCH: { label: '劳务派遣', style: 'bg-cyan-50 text-cyan-700 border border-cyan-200' },
OUTSOURCING: { label: '业务外包', style: 'bg-slate-50 text-slate-700 border border-slate-200' },
PARTTIME: { label: '兼职协议', style: 'bg-indigo-50 text-indigo-700 border border-indigo-200' },
UNSIGNED: { label: '未签合同', style: 'bg-gray-100 text-gray-500 border border-gray-200' },
}
const ct = e.latestContract?.contractType
const cfg = typeConfig[ct] || typeConfig.UNSIGNED
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
})()}
</td>}
{colVisible('contractStatus') && <td className="px-4 py-3">
{(() => {
const tagStyles: Record<string, string> = {
expired: 'bg-red-50 text-danger',
unsigned_over_year: 'bg-red-50 text-danger',
unsigned_over_30: 'bg-red-50 text-danger',
unsigned: 'bg-yellow-50 text-yellow-700',
expiring: 'bg-yellow-50 text-yellow-700',
active: 'bg-green-50 text-safe',
unfixed: 'bg-green-50 text-safe',
}
const statusTextMap: Record<string, string> = {
expired: '已过期',
unsigned_over_year: '未签署(超1年)',
unsigned_over_30: '未签署(超30天)',
unsigned: '未签署',
expiring: '即将到期',
active: '正常',
unfixed: '正常',
}
const style = tagStyles[e.contractStatus] || 'bg-gray-100 text-gray-500'
const text = statusTextMap[e.contractStatus] || '无合同'
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
})()}
</td>}
{colVisible('contractExpiry') && <td className="px-4 py-3 text-gray-500">
{(() => {
const endDate = e.latestContract?.endDate
if (!endDate) {
// 无固定期限合同显示"无固定期限",否则显示"—"
if (e.latestContract?.contractType === 'UNFIXED') return <span className="text-xs text-gray-400"></span>
return <span className="text-gray-300"></span>
}
const end = new Date(endDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
end.setHours(0, 0, 0, 0)
const diffDays = Math.ceil((end.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const dateStr = end.toISOString().slice(0, 10)
if (diffDays < 0) return <span className="text-danger text-xs">{dateStr} ()</span>
if (diffDays <= 30) return <span className="text-danger font-medium text-xs">{dateStr} ({diffDays})</span>
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{dateStr} ({diffDays})</span>
return <span className="text-xs">{dateStr}</span>
})()}
</td>}
{colVisible('socialStatus') && <td className="px-4 py-3">
{(() => {
const status = e.socialInsuranceStatus
if (!status) return <span className="text-gray-300 text-xs"></span>
const cfg: Record<string, { label: string; style: string }> = {
ACTIVE: { label: '在保', style: 'bg-green-50 text-safe' },
SUSPENDED: { label: '停保', style: 'bg-amber-50 text-amber-600' },
UNINSURED: { label: '未参保', style: 'bg-red-50 text-danger' },
PENDING: { label: '待办理', style: 'bg-blue-50 text-blue-600' },
}
const c = cfg[status] || { label: status, style: 'bg-gray-100 text-gray-500' }
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
})()}
</td>}
{colVisible('records') && <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1 flex-wrap">
{(() => {
const items = [
{ label: '违纪', count: e.counts?.disciplinaryRecords || 0, danger: true },
{ label: '考勤', count: e.counts?.attendanceRecords || 0 },
{ label: '培训', count: e.counts?.trainingRecords || 0 },
{ label: '绩效', count: e.counts?.performanceRecords || 0 },
{ label: '工资条', count: e.counts?.payslips || 0 },
]
return items.map((it, i) => (
<span
key={i}
className={`text-xs px-1.5 py-0.5 rounded ${it.count > 0 ? (it.danger ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-600') : 'text-gray-300'}`}
>
{it.label}{it.count}
</span>
))
})()}
</div>
</td>}
<td className="px-4 py-3 text-center">
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
<div className="flex items-center justify-center gap-2">
<button
type="button"
title="调薪"
aria-label={`${e.name}调薪`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
setSalaryEmployee(e)
setShowSalaryModal(true)
}}
>
<DollarSign className="h-4 w-4" />
</button>
<button
type="button"
title="发薪"
aria-label={`${e.name}发薪`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
navigate('/money')
}}
>
<Wallet className="h-4 w-4" />
</button>
<button
type="button"
title="调部门"
aria-label={`${e.name}调整部门`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
setDeptEmployee(e)
setShowDeptModal(true)
}}
>
<Building2 className="h-4 w-4" />
</button>
<button
type="button"
title="离职"
aria-label={`${e.name}办理离职`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-danger/10 hover:text-danger"
onClick={(ev) => {
ev.stopPropagation()
setResignEmployee(e)
setShowResignModal(true)
}}
>
<UserX className="h-4 w-4" />
</button>
</div>
)}
{e.hasTermination && e.status === 'ACTIVE' && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' && (
<div className="flex items-center justify-center gap-2">
<span className={`text-xs ${e.latestTerminationType === 'RESIGNATION' ? 'text-blue-600' : 'text-amber-600'}`}>
{e.latestTerminationType === 'RESIGNATION' ? '待离职' : '待解聘'}
</span>
<button
type="button"
title="撤回"
aria-label={`撤回${e.name}${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录`}
className="rounded-md p-1.5 text-gray-400 transition hover:bg-danger/10 hover:text-danger"
onClick={async (ev) => {
ev.stopPropagation()
if (e.latestTerminationId && await confirm({
title: '撤回确认',
message: `确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`,
})) {
revokeMutation.mutate(e.latestTerminationId)
}
}}
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
)}
{e.status === 'RESIGNED' && (
<button
type="button"
title="重新入职"
aria-label={`${e.name}办理重新入职`}
className="rounded-md p-1.5 text-primary transition hover:bg-primary/10 hover:text-primary/80"
onClick={(ev) => {
ev.stopPropagation()
setRehireEmployee(e)
setShowRehireModal(true)
}}
>
<UserPlus className="h-4 w-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="border-t border-gray-100 px-5 py-3">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={() => setPage(1)}
/>
</div>
</Card>
)}
{showAddModal && (
<AddEmployeeModal
onClose={() => setShowAddModal(false)}
onSubmit={(data) => addMutation.mutate(data)}
loading={addMutation.isPending}
error={addMutation.error as any}
/>
)}
{showImportModal && (
<Modal open={showImportModal} onClose={() => setShowImportModal(false)} title="批量导入" size="lg">
<ImportSettings />
</Modal>
)}
{showResignModal && resignEmployee && (
<ResignModal
employee={resignEmployee}
onClose={() => { setShowResignModal(false); setResignEmployee(null) }}
onSubmit={(data) => resignMutation.mutate(data)}
loading={resignMutation.isPending}
error={resignMutation.error as any}
/>
)}
{showRehireModal && rehireEmployee && (
<RehireModal
employee={rehireEmployee}
onClose={() => { setShowRehireModal(false); setRehireEmployee(null) }}
onSubmit={(data) => rehireMutation.mutate(data)}
loading={rehireMutation.isPending}
error={rehireMutation.error as any}
/>
)}
{showSalaryModal && salaryEmployee && (
<SalaryChangeModal
employee={salaryEmployee}
onClose={() => { setShowSalaryModal(false); setSalaryEmployee(null) }}
onSubmit={(data) => salaryChangeMutation.mutate(data)}
loading={salaryChangeMutation.isPending}
error={salaryChangeMutation.error as any}
/>
)}
{showDeptModal && deptEmployee && (
<DeptChangeModal
employee={deptEmployee}
onClose={() => { setShowDeptModal(false); setDeptEmployee(null) }}
onSubmit={(data) => deptChangeMutation.mutate(data)}
loading={deptChangeMutation.isPending}
error={deptChangeMutation.error as any}
/>
)}
{showBatchRenewModal && (
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
<div className="space-y-3">
{!previewData ? (
<>
<p className="text-xs text-gray-500"> {selectedIds.size} </p>
<div>
<Label></Label>
<Select value={String(batchRenewYears)} onChange={(e) => setBatchRenewYears(Number(e.target.value))}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="5">5</option>
</Select>
</div>
{previewRenewMutation.error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{(previewRenewMutation.error as any)?.response?.data?.error?.message || '预检失败'}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowBatchRenewModal(false); setPreviewData(null); }}></Button>
<Button
onClick={() => {
const contractIds = employees
.filter((e: any) => selectedIds.has(e.id) && e.latestContract?.id)
.map((e: any) => e.latestContract.id)
if (contractIds.length === 0) {
toast.error('所选员工没有可续签的合同')
return
}
previewRenewMutation.mutate(contractIds)
}}
disabled={previewRenewMutation.isPending}
>
{previewRenewMutation.isPending ? '预检中...' : '合规预检'}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-xs font-medium"></p>
<span className={`text-xs px-2 py-0.5 rounded ${previewData.warnings > 0 ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'}`}>
{previewData.warnings > 0 ? `${previewData.warnings} 项风险提示` : '全部通过'}
</span>
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{previewData.results.map((r: any) => (
<div key={r.contractId} className={`p-2 rounded text-xs ${r.warning ? 'bg-amber-50 border border-amber-200' : 'bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="font-medium">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
</div>
<div className="text-gray-500 mt-1">
{r.renewalCount} · {r.yearsSinceHire}
</div>
{r.warning && (
<div className="mt-1 text-amber-600">{r.warning}</div>
)}
<div className="mt-1 text-gray-500">{r.suggestion}</div>
</div>
))}
</div>
{previewData.warnings > 0 && (
<div className="px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs">
</div>
)}
{batchRenewMutation.error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{(batchRenewMutation.error as any)?.response?.data?.error?.message || '续签失败'}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewData(null)}></Button>
<Button
onClick={() => {
const contractIds = employees
.filter((e: any) => selectedIds.has(e.id) && e.latestContract?.id)
.map((e: any) => e.latestContract.id)
batchRenewMutation.mutate({ contractIds, years: batchRenewYears })
}}
disabled={batchRenewMutation.isPending}
>
{batchRenewMutation.isPending ? '续签中...' : `确认续签 ${selectedIds.size}`}
</Button>
</div>
</>
)}
</div>
</Modal>
)}
{showBatchTerminateModal && (
<Modal open={showBatchTerminateModal} onClose={() => { setShowBatchTerminateModal(false); setTerminatePreviewData(null); }} title="批量解聘" size="lg">
<div className="space-y-3">
{!terminatePreviewData ? (
<>
<p className="text-xs text-gray-500"> {selectedIds.size} </p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={batchTerminateDate} onChange={(e) => setBatchTerminateDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Select value={batchTerminateReason} onChange={(e) => setBatchTerminateReason(e.target.value)}>
<option value="NEGOTIATED"></option>
<option value="LAYOFF"></option>
<option value="NONFAULT"></option>
<option value="FAULT"></option>
<option value="EXPIRED"></option>
</Select>
</div>
</div>
{previewTerminateMutation.error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{(previewTerminateMutation.error as any)?.response?.data?.error?.message || '预检失败'}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowBatchTerminateModal(false); setTerminatePreviewData(null); }}></Button>
<Button
onClick={() => {
const items = Array.from(selectedIds).map(id => ({
employeeId: id,
reason: batchTerminateReason,
terminationDate: batchTerminateDate,
}))
previewTerminateMutation.mutate(items)
}}
disabled={previewTerminateMutation.isPending}
>
{previewTerminateMutation.isPending ? '预检中...' : '合规预检'}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-xs font-medium"></p>
<span className={`text-xs px-2 py-0.5 rounded ${terminatePreviewData.warnings > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
{terminatePreviewData.warnings > 0 ? `${terminatePreviewData.warnings} 项风险提示` : '全部通过'}
</span>
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{terminatePreviewData.results.map((r: any) => (
<div key={r.employeeId} className={`p-2 rounded text-xs ${r.warnings.length > 0 ? 'bg-red-50 border border-red-200' : 'bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="font-medium">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
</div>
<div className="text-gray-500 mt-1">
{terminateReasonMap[r.reason] || r.reason} · {r.terminationDate}
</div>
{r.warnings.length > 0 && r.warnings.map((w: string, i: number) => (
<div key={i} className="mt-1 text-red-600">{w}</div>
))}
</div>
))}
</div>
{terminatePreviewData.warnings > 0 && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
</div>
)}
{batchTerminateMutation.error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{(batchTerminateMutation.error as any)?.response?.data?.error?.message || '解聘失败'}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setTerminatePreviewData(null)}></Button>
<Button
variant="danger"
onClick={() => {
const items = Array.from(selectedIds).map(id => ({
employeeId: id,
reason: batchTerminateReason,
terminationDate: batchTerminateDate,
}))
batchTerminateMutation.mutate(items)
}}
disabled={batchTerminateMutation.isPending}
>
{batchTerminateMutation.isPending ? '解聘中...' : `确认解聘 ${selectedIds.size}`}
</Button>
</div>
</>
)}
</div>
</Modal>
)}
</div>
)
}