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 } from 'lucide-react' import api from '../lib/api' 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, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared' import EmployeeProfile from './roster/EmployeeProfile' import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals' export default function Roster() { const queryClient = useQueryClient() const confirm = useConfirm() const [selectedId, setSelectedId] = useState(null) const [search, setSearch] = useState('') const debouncedSearch = useDebouncedValue(search, 300) const [showAddModal, setShowAddModal] = 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 [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) 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 [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 } = useQuery({ queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus], queryFn: async () => { const params: any = { page, pageSize } if (debouncedSearch) params.search = debouncedSearch if (filterStatus) params.status = filterStatus if (filterContractStatus) params.contractStatus = filterContractStatus const res = await api.get('/roster', { params }) as any return res }, }) const employees = rosterData?.data || [] const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 } const addMutation = useMutation({ mutationFn: (data: any) => api.post('/employees', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowAddModal(false) }, }) const resignMutation = useMutation({ mutationFn: (data: any) => api.post('/termination/draft', { 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('已创建离职草稿,请前往「解聘补偿」页面完成流程') setShowResignModal(false) setResignEmployee(null) }, }) const revokeMutation = useMutation({ mutationFn: (recordId: string) => api.delete(`/termination/${recordId}/revoke`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) }, }) const rehireMutation = useMutation({ mutationFn: (data: any) => api.post(`/employees/${rehireEmployee?.id}/rehire`, 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) => api.post(`/roster/${salaryEmployee?.id}/salary-change`, 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) => api.post(`/roster/${deptEmployee?.id}/department-change`, 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 }) => api.post('/employees/contracts/batch-renew', 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[]) => api.post('/employees/contracts/preview-renew', { contractIds }), onSuccess: (data: any) => { setPreviewData(data.data) }, }) const previewTerminateMutation = useMutation({ mutationFn: (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) => api.post('/termination/batch/preview', { 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 => api.post('/termination/draft', { employeeId: item.employeeId, type: 'TERMINATION', reason: item.reason, terminationDate: item.terminationDate, }).catch(err => ({ 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('') setPage(1) } const hasActiveFilters = search || filterStatus || filterContractStatus const filtered = employees?.filter((e: any) => !search || e.name.includes(search) || e.department.includes(search) ) || [] if (selectedId) { return setSelectedId(null)} /> } return (

员工花名册

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

{ setSearch(e.target.value); setPage(1) }} className="!w-full sm:!w-64 shrink-0" /> {hasActiveFilters && ( )}
{selectedIds.size > 0 && (
已选择 {selectedIds.size} 名员工
)} {isLoading ? (
加载中...
) : filtered.length === 0 ? (
暂无符合条件的员工
) : (
setPage(p)} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
{employees.map((e: any) => ( setSelectedId(e.id)} > ))}
0 && selectedIds.size === employees.length} onChange={toggleSelectAll} /> 姓名 部门 状态 入职日期 离职日期 月薪 合同类型 合同状态 合同到期 违纪 考勤 培训 绩效 工资条 操作
ev.stopPropagation()}> toggleSelect(e.id)} /> {e.name} {e.department} {e.status === 'ACTIVE' ? '在职' : e.status === 'PRE_HIRE' ? '预入职' : '离职'} {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' && ' (预计)'} ) : ( )} ¥{fmt(e.monthlySalary)} {(() => { 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' }, 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} })()} {e.counts?.disciplinaryRecords ? ( {e.counts.disciplinaryRecords} ) : 0} {e.counts?.attendanceRecords || 0} {e.counts?.trainingRecords || 0} {e.counts?.performanceRecords || 0} {e.counts?.payslips || 0} {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' && ( )}
)} {showAddModal && ( setShowAddModal(false)} onSubmit={(data) => addMutation.mutate(data)} loading={addMutation.isPending} error={addMutation.error as any} /> )} {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 || '解聘失败'}
)}
)}
)}
) }