Files
TurboHR/frontend/src/pages/Roster.tsx
T
selfrelease d79e3baa34 feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全
- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
2026-07-26 20:32:38 +08:00

761 lines
38 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 } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History } from 'lucide-react'
import api from '../lib/api'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import Pagination from '../components/ui/Pagination'
import { fmt, terminateReasonMap, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared'
import EmployeeProfile from './roster/EmployeeProfile'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
export default function Roster() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
const [showAddModal, setShowAddModal] = 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 [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
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 [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 } = useQuery<any>({
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus],
queryFn: async () => {
const params: any = { page, pageSize }
if (debouncedSearch) params.search = debouncedSearch
if (filterStatus) params.status = filterStatus
if (filterContractStatus) params.contractStatus = filterContractStatus
const res = await api.get('/roster', { params }) as any
return res
},
})
const employees = rosterData?.data || []
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
const addMutation = useMutation({
mutationFn: (data: any) => api.post('/employees', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowAddModal(false)
},
})
const resignMutation = useMutation({
mutationFn: (data: any) => api.post('/termination/draft', {
employeeId: data.employeeId,
type: 'RESIGNATION',
reason: 'RESIGNATION',
terminationDate: data.terminationDate,
resignationReason: data.resignationReason,
remark: data.remark,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程')
setShowResignModal(false)
setResignEmployee(null)
},
})
const revokeMutation = useMutation({
mutationFn: (recordId: string) => api.delete(`/termination/${recordId}/revoke`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
},
})
const rehireMutation = useMutation({
mutationFn: (data: any) => api.post(`/employees/${rehireEmployee?.id}/rehire`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowRehireModal(false)
setRehireEmployee(null)
},
})
const salaryChangeMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${salaryEmployee?.id}/salary-change`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowSalaryModal(false)
setSalaryEmployee(null)
},
})
const deptChangeMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${deptEmployee?.id}/department-change`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowDeptModal(false)
setDeptEmployee(null)
},
})
const batchRenewMutation = useMutation({
mutationFn: (data: { contractIds: string[]; years: number }) => api.post('/employees/contracts/batch-renew', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowBatchRenewModal(false)
setSelectedIds(new Set())
setPreviewData(null)
},
})
const previewRenewMutation = useMutation({
mutationFn: (contractIds: string[]) => api.post('/employees/contracts/preview-renew', { contractIds }),
onSuccess: (data: any) => {
setPreviewData(data.data)
},
})
const previewTerminateMutation = useMutation({
mutationFn: (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) =>
api.post('/termination/batch/preview', { items }),
onSuccess: (data: any) => {
setTerminatePreviewData(data.data)
},
})
const batchTerminateMutation = useMutation({
mutationFn: async (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) => {
const results = await Promise.all(
items.map(item => api.post('/termination/draft', {
employeeId: item.employeeId,
type: 'TERMINATION',
reason: item.reason,
terminationDate: item.terminationDate,
}).catch(err => ({ error: err, employeeId: item.employeeId })))
)
return results
},
onSuccess: (results: any) => {
const success = results.filter((r: any) => !r?.error).length
const failed = results.filter((r: any) => r?.error).length
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
if (success > 0) toast.success(`已创建 ${success} 个解聘草稿,请前往「解聘补偿」页面完成流程`)
if (failed > 0) toast.error(`${failed} 个员工创建草稿失败`)
setShowBatchTerminateModal(false)
setTerminatePreviewData(null)
setSelectedIds(new Set())
},
})
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === employees.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(employees.map((e: any) => e.id)))
}
}
const clearFilters = () => {
setSearch('')
setFilterStatus('')
setFilterContractStatus('')
setPage(1)
}
const hasActiveFilters = search || filterStatus || filterContractStatus
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search)
) || []
if (selectedId) {
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
}
return (
<div className="space-y-5">
<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="搜索姓名或部门"
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="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>
{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>
</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 ? (
<div className="rounded-lg border border-gray-200 bg-white py-16 text-center text-sm text-gray-400">...</div>
) : 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="border-b border-gray-100 px-5">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] 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>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<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">{e.name}</td>
<td className="px-4 py-3 text-gray-500">{e.department}</td>
<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>
</td>
<td className="hidden 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>
<td className="px-4 py-3 text-right">¥{fmt(e.monthlySalary)}</td>
<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' },
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>
<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>
<td className="hidden 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>
<td className="px-4 py-3 text-center">
{e.counts?.disciplinaryRecords ? (
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
) : <span className="text-gray-300">0</span>}
</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.payslips || 0}</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()
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>
</Card>
)}
{showAddModal && (
<AddEmployeeModal
onClose={() => setShowAddModal(false)}
onSubmit={(data) => addMutation.mutate(data)}
loading={addMutation.isPending}
error={addMutation.error as any}
/>
)}
{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>
)
}