1024 lines
52 KiB
TypeScript
1024 lines
52 KiB
TypeScript
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>
|
||
)
|
||
}
|