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(() => { 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(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(null) const [showRehireModal, setShowRehireModal] = useState(false) const [rehireEmployee, setRehireEmployee] = useState(null) const [showSalaryModal, setShowSalaryModal] = useState(false) const [salaryEmployee, setSalaryEmployee] = useState(null) const [showDeptModal, setShowDeptModal] = useState(false) const [deptEmployee, setDeptEmployee] = useState(null) const pageSize = usePageSize() const [page, setPage] = useState(1) const [selectedIds, setSelectedIds] = useState>(new Set()) const [showBatchRenewModal, setShowBatchRenewModal] = useState(false) const [batchRenewYears, setBatchRenewYears] = useState(3) const [previewData, setPreviewData] = useState(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(null) const { data: rosterData, isLoading, isError, error, refetch } = useQuery({ 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({ 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 setSelectedId(null)} /> } return (
员工花名册统一管理所有员工档案信息,包括基本信息、合同、薪酬、社保等。支持搜索、筛选、导出。点击员工可查看详细档案。支持入职登记、离职办理、合同签订等操作。关联:入职后请前往「用工办理」签订合同;合同到期请前往「风险中心」处理;离职请前往「离职管理」计算补偿。

员工花名册

统一查看员工状态、合同风险及人事档案

{ setSearch(e.target.value); setPage(1) }} className="!w-full sm:!w-64 shrink-0" /> {hasActiveFilters && ( )}
{showColSettings && ( <>
setShowColSettings(false)} />
显示列
{ROSTER_COLUMNS.map(col => ( ))}
)}
{selectedIds.size > 0 && (
已选择 {selectedIds.size} 名员工
)} {/* 合同风险提示 */} {!isLoading && (riskStats.expired > 0 || riskStats.unsigned > 0) && ( {riskStats.expired > 0 && 当前页有 {riskStats.expired} 人合同已到期;} {riskStats.unsigned > 0 && {riskStats.unsigned} 人未签合同;} 请及时处理以避免法律风险。 )} {/* 快捷筛选标签 */} {!isLoading && quickFilters.length > 0 && (
快捷筛选: {quickFilters.map((f) => ( ))}
)} {isLoading ? (
加载中...
) : isError ? ( ) : filtered.length === 0 ? (
暂无符合条件的员工
) : (
{colVisible('department') && } {colVisible('status') && } {colVisible('hireDate') && } {colVisible('position') && } {colVisible('phone') && } {colVisible('gender') && } {colVisible('contractType') && } {colVisible('contractStatus') && } {colVisible('contractExpiry') && } {colVisible('socialStatus') && } {colVisible('records') && } {employees.map((e: any) => ( setSelectedId(e.id)} > {colVisible('department') && } {colVisible('status') && } {colVisible('hireDate') && } {colVisible('position') && } {colVisible('phone') && } {colVisible('gender') && } {colVisible('contractType') && } {colVisible('contractStatus') && } {colVisible('contractExpiry') && } {colVisible('socialStatus') && } {colVisible('records') && } ))}
0 && selectedIds.size === employees.length} onChange={toggleSelectAll} /> 姓名部门状态入职日期离职日期职务手机号性别合同类型合同状态合同到期社保状态记录操作
ev.stopPropagation()}> toggleSelect(e.id)} />
{e.name}
{ ev.stopPropagation() if (e.idCardNumber) { navigator.clipboard.writeText(e.idCardNumber).then(() => toast.success('已复制身份证号')).catch(() => toast.error('复制失败')) } }}>{e.idCardMasked || '—'}
{e.department} {e.status === 'ACTIVE' ? '在职' : e.status === 'PRE_HIRE' ? '预入职' : '离职'} {e.probationInfo?.isProbation && ( 试用期{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `剩${e.probationInfo.daysToConfirm}天`} )} {e.hireDate?.toString().slice(0, 10)} {e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? ( {e.latestTerminationDate.toString().slice(0, 10)} {e.status === 'ACTIVE' && ' (预计)'} ) : ( )} {e.position || '—'}{e.phone || '—'}{e.gender || '—'} {(() => { const typeConfig: Record = { 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 {cfg.label} })()} {(() => { const tagStyles: Record = { 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 = { 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 {text} })()} {(() => { const endDate = e.latestContract?.endDate if (!endDate) { // 无固定期限合同显示"无固定期限",否则显示"—" if (e.latestContract?.contractType === 'UNFIXED') return 无固定期限 return } 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 {dateStr} (已过期) if (diffDays <= 30) return {dateStr} ({diffDays}天) if (diffDays <= 90) return {dateStr} ({diffDays}天) return {dateStr} })()} {(() => { const status = e.socialInsuranceStatus if (!status) return const cfg: Record = { 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 {c.label} })()}
{(() => { 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) => ( 0 ? (it.danger ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-600') : 'text-gray-300'}`} > {it.label}{it.count} )) })()}
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
)} {e.hasTermination && e.status === 'ACTIVE' && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' && (
{e.latestTerminationType === 'RESIGNATION' ? '待离职' : '待解聘'}
)} {e.status === 'RESIGNED' && ( )}
setPage(p)} onPageSizeChange={() => setPage(1)} />
)} {showAddModal && ( setShowAddModal(false)} onSubmit={(data) => addMutation.mutate(data)} loading={addMutation.isPending} error={addMutation.error as any} /> )} {showImportModal && ( setShowImportModal(false)} title="批量导入" size="lg"> )} {showResignModal && resignEmployee && ( { setShowResignModal(false); setResignEmployee(null) }} onSubmit={(data) => resignMutation.mutate(data)} loading={resignMutation.isPending} error={resignMutation.error as any} /> )} {showRehireModal && rehireEmployee && ( { setShowRehireModal(false); setRehireEmployee(null) }} onSubmit={(data) => rehireMutation.mutate(data)} loading={rehireMutation.isPending} error={rehireMutation.error as any} /> )} {showSalaryModal && salaryEmployee && ( { setShowSalaryModal(false); setSalaryEmployee(null) }} onSubmit={(data) => salaryChangeMutation.mutate(data)} loading={salaryChangeMutation.isPending} error={salaryChangeMutation.error as any} /> )} {showDeptModal && deptEmployee && ( { setShowDeptModal(false); setDeptEmployee(null) }} onSubmit={(data) => deptChangeMutation.mutate(data)} loading={deptChangeMutation.isPending} error={deptChangeMutation.error as any} /> )} {showBatchRenewModal && ( { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
{!previewData ? ( <>

已选择 {selectedIds.size} 名员工,将为其续签劳动合同。

{previewRenewMutation.error && (
{(previewRenewMutation.error as any)?.response?.data?.error?.message || '预检失败'}
)}
) : ( <>

合规预检结果

0 ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'}`}> {previewData.warnings > 0 ? `${previewData.warnings} 项风险提示` : '全部通过'}
{previewData.results.map((r: any) => (
{r.employeeName} {r.department}
第 {r.renewalCount} 次续签 · 在职 {r.yearsSinceHire} 年
{r.warning && (
{r.warning}
)}
{r.suggestion}
))}
{previewData.warnings > 0 && (
存在法律风险,建议处理后再继续操作
)} {batchRenewMutation.error && (
{(batchRenewMutation.error as any)?.response?.data?.error?.message || '续签失败'}
)}
)}
)} {showBatchTerminateModal && ( { setShowBatchTerminateModal(false); setTerminatePreviewData(null); }} title="批量解聘" size="lg">
{!terminatePreviewData ? ( <>

已选择 {selectedIds.size} 名员工进行批量解聘。

setBatchTerminateDate(e.target.value)} />
{previewTerminateMutation.error && (
{(previewTerminateMutation.error as any)?.response?.data?.error?.message || '预检失败'}
)}
) : ( <>

合规预检结果

0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}> {terminatePreviewData.warnings > 0 ? `${terminatePreviewData.warnings} 项风险提示` : '全部通过'}
{terminatePreviewData.results.map((r: any) => (
0 ? 'bg-red-50 border border-red-200' : 'bg-gray-50'}`}>
{r.employeeName} {r.department}
{terminateReasonMap[r.reason] || r.reason} · {r.terminationDate}
{r.warnings.length > 0 && r.warnings.map((w: string, i: number) => (
{w}
))}
))}
{terminatePreviewData.warnings > 0 && (
存在法律风险,建议处理后再继续操作
)} {batchTerminateMutation.error && (
{(batchTerminateMutation.error as any)?.response?.data?.error?.message || '解聘失败'}
)}
)}
)}
) }