Files
TurboHR/frontend/src/pages/Roster.tsx
T
selfrelease 8fcc7ae143 feat: 转正弹窗显示原薪资 + 薪资变化时联动签署
- ConfirmModal 显示原薪资(¥xxx),转正薪资默认填入原薪资
- 转正薪资与原薪资不同时,提示"将发起薪酬调整确认书签署"
- confirmMutation onSuccess:薪资变化时弹出签署方式选择弹窗
  - scene=POLICY,文件标题为"转正薪酬调整确认书"
  - 备注记录薪资变化:¥原薪资 → ¥新薪资

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:51:41 +08:00

1532 lines
75 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 { toastError } from '../lib/errorToast'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Download, Phone, MapPin, Search, Settings2, CheckCircle, FileText } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi, workProcessApi } from '../lib/api-services'
import api from '../lib/api'
import { copyToClipboard } from '../lib/clipboard'
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, ConfirmModal } from './roster/modals'
import SignMethodChoice from '../components/ui/SignMethodChoice'
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: 'socialInsBase', label: '社保基数' },
{ key: 'socialInsAmount', 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 [showConfirmModal, setShowConfirmModal] = useState(false)
const [confirmEmployee, setConfirmEmployee] = useState<any>(null)
// 开具证明弹窗
const [showCertModal, setShowCertModal] = useState(false)
const [certEmployee, setCertEmployee] = useState<any>(null)
const [certPurpose, setCertPurpose] = useState('')
const [certType, setCertType] = useState('INCOME_CERT')
// 续签合同弹窗
const [showRenewModal, setShowRenewModal] = useState(false)
const [renewEmployee, setRenewEmployee] = useState<any>(null)
const [renewNewStartDate, setRenewNewStartDate] = useState('')
const [renewNewEndDate, setRenewNewEndDate] = useState('')
const [renewNewSalary, setRenewNewSalary] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
// 签署方式选择弹窗
const [signChoice, setSignChoice] = useState<{
open: boolean
employeeId: string
employeeName: string
scene: 'CONTRACT' | 'RESIGNATION' | 'POLICY' | 'PAYSLIP' | 'ONBOARDING'
documentTitle: string
contractId?: string
remark?: string
actionName: string
} | null>(null)
const [batchRenewYears, setBatchRenewYears] = useState(3)
const [previewData, setPreviewData] = useState<any>(null)
const [filterStatus, setFilterStatus] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [filterPosition, setFilterPosition] = 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 [showBatchConfirmModal, setShowBatchConfirmModal] = useState(false)
const [batchConfirmDate, setBatchConfirmDate] = useState(new Date().toISOString().slice(0, 10))
const [batchConfirmSalary, setBatchConfirmSalary] = useState('')
// 批量开具证明弹窗
const [showBatchCertModal, setShowBatchCertModal] = useState(false)
const [batchCertType, setBatchCertType] = useState('INCOME_CERT')
const { data: rosterData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment, filterPosition],
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
if (filterPosition) params.position = filterPosition
const res = await rosterApi.list({ search: debouncedSearch, page, pageSize, status: filterStatus === 'PROBATION' ? 'ACTIVE' : filterStatus || undefined, contractStatus: filterContractStatus || undefined, department: filterDepartment || undefined, position: filterPosition || 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')
const employeeIdParam = searchParams.get('employeeId')
useEffect(() => {
// 优先按 employeeId 直接定位
if (employeeIdParam && !selectedId) {
setSelectedId(employeeIdParam)
setSearchParams({}, { replace: true })
return
}
if (!employeeParam) return
setSearch(employeeParam)
// 只有当 debouncedSearch 与参数一致时,搜索结果才真正匹配
if (debouncedSearch === employeeParam && !isLoading && employees.length > 0 && !selectedId) {
setSelectedId(employees[0].id)
// 清除 URL 参数,避免后续搜索时重复触发
setSearchParams({}, { replace: true })
}
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
const addMutation = useMutation({
mutationFn: async (data: any) => {
const res = await employeeApi.create(data)
return res
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
toast.success('员工已添加,请前往员工档案签订合同')
},
onError: (err: any) => toastError(err, '创建失败'),
})
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'] })
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('已创建离职草稿,离职协议电子签署已自动发起', {
action: { label: '查看签署', onClick: () => navigate('/esign') },
})
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)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: rehireEmployee?.id || '',
employeeName: rehireEmployee?.name || '',
scene: 'CONTRACT',
documentTitle: `${rehireEmployee?.name || ''}的劳动合同`,
remark: '重新入职时发起',
actionName: '重新入职',
})
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)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: salaryEmployee?.id || '',
employeeName: salaryEmployee?.name || '',
scene: 'POLICY',
documentTitle: `${salaryEmployee?.name || ''}的薪酬调整确认书`,
remark: '薪酬变更时发起',
actionName: '薪酬变更',
})
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)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: deptEmployee?.id || '',
employeeName: deptEmployee?.name || '',
scene: 'POLICY',
documentTitle: `${deptEmployee?.name || ''}的调岗确认书`,
remark: '部门/岗位调动时发起',
actionName: '调岗调动',
})
setDeptEmployee(null)
},
})
// 转正 mutation
const confirmMutation = useMutation({
mutationFn: (data: { employeeId: string; confirmDate: string; regularSalary?: number }) =>
workProcessApi.create({ type: 'CONFIRM', title: '转正', employeeId: data.employeeId, formData: { employeeId: data.employeeId, confirmDate: data.confirmDate, regularSalary: data.regularSalary }, status: 'DRAFT' }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowConfirmModal(false)
const originalSalary = confirmEmployee?.monthlySalary ? Number(confirmEmployee.monthlySalary) : null
const newSalary = variables.regularSalary
// 薪资有变化时弹出签署方式选择
if (newSalary && newSalary !== originalSalary) {
setSignChoice({
open: true,
employeeId: variables.employeeId,
employeeName: confirmEmployee?.name || '',
scene: 'POLICY',
documentTitle: `${confirmEmployee?.name || ''}的转正薪酬调整确认书`,
remark: `转正薪资调整:¥${originalSalary || 0} → ¥${newSalary}`,
actionName: '转正调薪',
})
} else {
toast.success('转正已提交')
}
setConfirmEmployee(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '转正失败'),
})
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 certMutation = useMutation({
mutationFn: async (data: { employeeId: string; formData: Record<string, unknown> }) => {
const certTitleMap: Record<string, string> = {
INCOME_CERT: '开具收入证明',
EMPLOYMENT_CERT: '开具在职证明',
LEAVING_CERT: '开具离职证明',
}
const created: any = await workProcessApi.create({ type: certType, title: certTitleMap[certType] || '开具证明', employeeId: data.employeeId, formData: data.formData, status: 'DRAFT' })
if (created?.id) {
await workProcessApi.submit(created.id)
}
return created
},
onSuccess: () => {
toast.success('证明已开具,可在「证据管理」中查看和下载')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCertModal(false)
setCertEmployee(null)
setCertPurpose('')
},
onError: (err: any) => toastError(err, '开具证明失败'),
})
/** 续签合同:直接创建并提交工单 */
const renewMutation = useMutation({
mutationFn: async (data: { employeeId: string; formData: Record<string, unknown> }) => {
const created: any = await workProcessApi.create({ type: 'RENEW', title: '合同续签', employeeId: data.employeeId, formData: data.formData, status: 'DRAFT' })
if (created?.id) {
await workProcessApi.submit(created.id)
}
return created
},
onSuccess: () => {
toast.success('合同续签已完成')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setShowRenewModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: renewEmployee?.id || '',
employeeName: renewEmployee?.name || '',
scene: 'CONTRACT',
documentTitle: `${renewEmployee?.name || ''}的续签劳动合同`,
remark: '合同续签时发起',
actionName: '合同续签',
})
setRenewEmployee(null)
setRenewNewStartDate('')
setRenewNewEndDate('')
setRenewNewSalary('')
},
onError: (err: any) => toastError(err, '续签合同失败'),
})
/** 批量转正:为选中的试用期员工创建转正草稿 */
const handleBatchConfirm = async () => {
const probationEmployees = employees.filter((e: any) => selectedIds.has(e.id) && e.probationInfo?.isProbation)
if (probationEmployees.length === 0) {
toast.error('选中的员工中没有试用期员工')
return
}
const confirmDate = batchConfirmDate
let success = 0
let failed = 0
await Promise.all(
probationEmployees.map(async (emp: any) => {
try {
await workProcessApi.create({
type: 'CONFIRM',
title: '批量转正',
employeeId: emp.id,
formData: { employeeId: emp.id, confirmDate, salary: batchConfirmSalary || undefined },
status: 'DRAFT',
})
success++
} catch {
failed++
}
})
)
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
if (success > 0) toast.success(`已为 ${success} 名员工创建转正草稿`)
if (failed > 0) toast.error(`${failed} 名员工转正失败`)
setSelectedIds(new Set())
setShowBatchConfirmModal(false)
}
/** 批量开具证明:直接为选中员工创建并提交证明 */
const handleBatchCert = async () => {
if (selectedIds.size === 0) {
toast.error('请至少选择一名员工')
return
}
const certTitleMap: Record<string, string> = {
INCOME_CERT: '开具收入证明',
EMPLOYMENT_CERT: '开具在职证明',
LEAVING_CERT: '开具离职证明',
}
let success = 0
let failed = 0
for (const id of selectedIds) {
const emp = employees.find((e: any) => e.id === id)
if (!emp) continue
try {
const created: any = await workProcessApi.create({
type: batchCertType,
title: certTitleMap[batchCertType] || '开具证明',
employeeId: id,
formData: {
employeeId: id,
employeeName: emp.name,
idCardNumber: emp.idCardMasked || '',
position: emp.position || '',
monthlyIncome: emp.monthlySalary ? `¥${emp.monthlySalary}` : '',
purpose: '批量开具',
},
status: 'DRAFT',
})
if (created?.id) {
await workProcessApi.submit(created.id)
}
success++
} catch {
failed++
}
}
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
if (success > 0) toast.success(`已为 ${success} 名员工开具证明`)
if (failed > 0) toast.error(`${failed} 名员工开具证明失败`)
setSelectedIds(new Set())
setShowBatchCertModal(false)
}
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('')
setFilterPosition('')
setPage(1)
}
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment || filterPosition
/** 全局合同风险统计(来自后端,与风险中心同口径) */
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 { data: positionList } = useQuery<string[]>({
queryKey: ['roster-positions'],
queryFn: async () => {
const res = await api.get('/roster/positions')
return res.data as string[]
},
})
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>
<select
value={filterPosition}
onChange={(e) => { setFilterPosition(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>
{positionList?.map((p: string) => <option key={p} value={p}>{p}</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 (filterPosition) params.set('position', filterPosition)
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="secondary" onClick={() => setShowBatchConfirmModal(true)} className="shrink-0">
<CheckCircle className="mr-1 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => setShowBatchCertModal(true)} className="shrink-0">
<FileText 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('socialInsBase') && <th className="px-4 py-3 text-right"></th>}
{colVisible('socialInsAmount') && <th className="px-4 py-3 text-right"></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-50 last:border-0 cursor-pointer transition-colors hover:bg-primary/[0.03] group"
onClick={() => setSelectedId(e.id)}
>
<td className="px-4 py-2.5" onClick={(ev) => ev.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
</td>
<td className="px-4 py-2.5">
<div className="flex flex-col gap-0.5">
<span className="font-medium text-gray-900 text-sm leading-tight">{e.name}</span>
<span className="text-gray-400 text-[11px] font-mono cursor-pointer hover:text-primary transition-colors leading-tight" title="点击复制完整证件号码" onClick={(ev) => {
ev.stopPropagation()
if (e.idCardNumber) {
copyToClipboard(e.idCardNumber, '已复制证件号码')
}
}}>{e.idCardMasked || '—'}</span>
</div>
</td>
{colVisible('department') && <td className="px-4 py-2.5 text-gray-500">{e.department}</td>}
{colVisible('status') && <td className="px-4 py-2.5">
<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-2.5 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
<td className="hidden px-4 py-2.5 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-2.5 text-gray-500">{e.position || '—'}</td>}
{colVisible('phone') && <td className="px-4 py-2.5 text-gray-500">{e.phone || '—'}</td>}
{colVisible('gender') && <td className="px-4 py-2.5 text-center text-gray-500">{e.gender || '—'}</td>}
{colVisible('contractType') && <td className="px-4 py-2.5">
{(() => {
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-2.5">
{(() => {
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-2.5 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-2.5">
{(() => {
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('socialInsBase') && <td className="px-4 py-2.5 text-right text-xs">
{e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300"></span>}
</td>}
{colVisible('socialInsAmount') && <td className="px-4 py-2.5 text-right">
{(() => {
if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300 text-xs"></span>
const socialEmp = e.socialInsCalc?.socialEmp || 0
const socialOrg = e.socialInsCalc?.socialOrg || 0
const housingEmp = e.housingFundCalc?.housingEmp || 0
const housingOrg = e.housingFundCalc?.housingOrg || 0
const totalEmp = socialEmp + housingEmp
const totalOrg = socialOrg + housingOrg
return (
<div className="flex flex-col gap-0.5 items-end">
<span className="text-xs text-gray-700 leading-tight"> {totalEmp.toFixed(2)}</span>
<span className="text-[11px] text-gray-400 leading-tight"> {totalOrg.toFixed(2)}</span>
</div>
)
})()}
</td>}
{colVisible('records') && <td className="px-4 py-2.5 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-3 py-2 text-center">
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
<div className="flex flex-col items-center gap-1.5">
<div className="flex items-center justify-center gap-0.5">
<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>
{/* 试用期员工显示转正按钮 */}
{e.probationInfo?.isProbation && (
<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()
setConfirmEmployee(e)
setShowConfirmModal(true)
}}
>
<CheckCircle 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>
</div>
<div className="flex items-center justify-center gap-0.5 border-t border-gray-100 pt-1.5">
<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()
setCertEmployee(e)
setCertPurpose('')
setCertType('INCOME_CERT')
setShowCertModal(true)
}}
>
<FileText className="h-4 w-4" />
</button>
{(e.contractStatus === 'expiring' || e.contractStatus === 'expired') && (
<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()
// 自动推导新合同开始日期:原合同结束日 + 1天
const oldEndDate = e.latestContract?.endDate
let newStart = new Date().toISOString().slice(0, 10)
if (oldEndDate) {
const d = new Date(oldEndDate)
d.setDate(d.getDate() + 1)
newStart = d.toISOString().slice(0, 10)
}
setRenewEmployee(e)
setRenewNewStartDate(newStart)
setRenewNewEndDate('')
setRenewNewSalary(e.monthlySalary ? String(e.monthlySalary) : '')
setShowRenewModal(true)
}}
>
<RotateCcw 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>
</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={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}
/>
)}
{showConfirmModal && confirmEmployee && (
<ConfirmModal
employee={confirmEmployee}
onClose={() => { setShowConfirmModal(false); setConfirmEmployee(null) }}
onSubmit={(data) => confirmMutation.mutate(data)}
loading={confirmMutation.isPending}
error={confirmMutation.error as any}
/>
)}
{/* 开具证明弹窗 */}
{showCertModal && certEmployee && (
<Modal open={showCertModal} onClose={() => { setShowCertModal(false); setCertEmployee(null) }} title={`${certEmployee.name} 开具证明`}>
<div className="space-y-3">
<div>
<Label></Label>
<Select value={certType} onChange={(e) => setCertType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="EMPLOYMENT_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div className="text-xs text-gray-500">
</div>
<div>
<Label></Label>
<Input value={certPurpose} onChange={(e) => setCertPurpose(e.target.value)} placeholder="如:办理签证、租房、贷款等" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowCertModal(false); setCertEmployee(null) }}></Button>
<Button
disabled={certMutation.isPending}
onClick={() => {
certMutation.mutate({
employeeId: certEmployee.id,
formData: {
employeeId: certEmployee.id,
employeeName: certEmployee.name,
idCardNumber: certEmployee.idCardMasked || '',
position: certEmployee.position || '',
monthlyIncome: certEmployee.monthlySalary ? `¥${certEmployee.monthlySalary}` : '',
purpose: certPurpose || '通用',
},
})
}}
></Button>
</div>
</div>
</Modal>
)}
{/* 续签合同弹窗 */}
{showRenewModal && renewEmployee && (
<Modal open={showRenewModal} onClose={() => { setShowRenewModal(false); setRenewEmployee(null) }} title={`${renewEmployee.name} 续签合同`}>
<div className="space-y-3">
<div className="text-xs text-gray-500">
{renewEmployee.latestContract?.endDate ? new Date(renewEmployee.latestContract.endDate).toISOString().slice(0, 10) : '无'}
</div>
<div>
<Label> *</Label>
<Input type="date" value={renewNewStartDate} onChange={(e) => setRenewNewStartDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="date" value={renewNewEndDate} onChange={(e) => setRenewNewEndDate(e.target.value)} placeholder="留空表示无固定期限" />
</div>
<div>
<Label></Label>
<Input type="number" value={renewNewSalary} onChange={(e) => setRenewNewSalary(e.target.value)} placeholder="续签后月薪" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowRenewModal(false); setRenewEmployee(null) }}></Button>
<Button
disabled={renewMutation.isPending}
onClick={() => {
if (!renewNewStartDate) {
toast.error('请填写新合同开始日期')
return
}
if (renewNewEndDate && renewNewEndDate < renewNewStartDate) {
toast.error('结束日期不能早于开始日期')
return
}
renewMutation.mutate({
employeeId: renewEmployee.id,
formData: {
employeeId: renewEmployee.id,
oldContractId: renewEmployee.latestContract?.id || '',
newStartDate: renewNewStartDate,
newEndDate: renewNewEndDate || undefined,
newSalary: renewNewSalary || undefined,
},
})
}}
></Button>
</div>
</div>
</Modal>
)}
{/* 批量转正弹窗 */}
{showBatchConfirmModal && (
<Modal open={showBatchConfirmModal} onClose={() => setShowBatchConfirmModal(false)} title="批量转正">
<div className="space-y-3">
<div className="text-xs text-gray-500">
{selectedIds.size} 稿
</div>
<div>
<Label></Label>
<Input type="date" value={batchConfirmDate} onChange={(e) => setBatchConfirmDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="number" value={batchConfirmSalary} onChange={(e) => setBatchConfirmSalary(e.target.value)} placeholder="转正后月薪" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowBatchConfirmModal(false)}></Button>
<Button onClick={handleBatchConfirm}></Button>
</div>
</div>
</Modal>
)}
{/* 批量开具证明弹窗 */}
{showBatchCertModal && (
<Modal open={showBatchCertModal} onClose={() => setShowBatchCertModal(false)} title="批量开具证明">
<div className="space-y-3">
<div className="text-xs text-gray-500">
{selectedIds.size}
</div>
<div>
<Label></Label>
<Select value={batchCertType} onChange={(e) => setBatchCertType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="EMPLOYMENT_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowBatchCertModal(false)}></Button>
<Button onClick={handleBatchCert}></Button>
</div>
</div>
</Modal>
)}
{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>
)}
{/* 签署方式选择弹窗 */}
{signChoice?.open && (
<SignMethodChoice
open={signChoice.open}
onClose={() => setSignChoice(null)}
employeeId={signChoice.employeeId}
employeeName={signChoice.employeeName}
scene={signChoice.scene}
documentTitle={signChoice.documentTitle}
contractId={signChoice.contractId}
remark={signChoice.remark}
actionName={signChoice.actionName}
/>
)}
</div>
)
}