Files
TurboHR/frontend/src/pages/Termination.tsx
T
selfrelease 9946197d20 Sprint 3: 员工目录优化 + Profile Shell + 离职工作流重构 + 合同类型扩充 + 月度社保重构 + 商险管理Tab
S3-1: Roster.tsx 添加 InlineAlert 合同风险提示 + 快捷筛选标签
S3-2: 新建 EmployeeProfileShell.tsx 统一员工详情布局,重构 EmployeeProfile.tsx
S3-3: Termination.tsx 集成 Stepper 步骤条 + InlineAlert 风险提示
S3-4: schema.prisma ContractType 枚举扩充 DISPATCH/OUTSOURCING/PARTTIME + 后端接口 + 前端选择器
S3-5: SocialInsurance.tsx 月度办理 Tab 使用 InlineAlert 替换原始提示
S3-6: SocialInsurance.tsx 新增商险管理 Tab(方案CRUD + 参保人员列表)
2026-07-31 18:07:49 +08:00

1862 lines
88 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
import { Stepper } from '../components/ui/Stepper'
import { InlineAlert } from '../components/ui/InlineAlert'
import jsPDF from 'jspdf'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const REASONS = [
{ value: 'NEGOTIATED', label: '协商解除(双方同意分开了)', legalBasis: '《劳动合同法》第36条' },
{ value: 'FAULT', label: '员工犯错被辞退(严重违纪/失职等)', legalBasis: '《劳动合同法》第39条' },
{ value: 'NONFAULT', label: '员工没犯错但干不了(生病/不胜任等)', legalBasis: '《劳动合同法》第40条' },
{ value: 'LAYOFF', label: '公司裁员(经营困难/技术调整等)', legalBasis: '《劳动合同法》第41条' },
{ value: 'EXPIRED', label: '合同到期不续签', legalBasis: '《劳动合同法》第44条、第46条' },
{ value: 'ILLEGAL', label: '违法解除(赔偿金×2', legalBasis: '《劳动合同法》第87条' },
{ value: 'RESIGNATION', label: '员工主动离职', legalBasis: '《劳动合同法》第37条' },
]
const STEPS = ['选择员工', '解聘方式', '合规检查', '费用结算', '工作交接', '确认提交']
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
}
const DEFAULT_HANDOVER_ITEMS = [
{ key: 'work_handover', label: '工作交接完成', done: false, remark: '' },
{ key: 'equipment_return', label: '办公设备归还', done: false, remark: '' },
{ key: 'access_revoke', label: '系统权限收回', done: false, remark: '' },
{ key: 'docs_signed', label: '离职文件签署', done: false, remark: '' },
{ key: 'finance_settled', label: '财务结算完成', done: false, remark: '' },
{ key: 'contract_return', label: '劳动合同收回', done: false, remark: '' },
]
interface RosterEmployee {
id: string
name: string
department: string
status: string
hasTermination?: boolean
latestTerminationStatus?: string
hireDate: string
monthlySalary: number
latestContract: any
counts: any
}
interface EmployeeProfile {
id: string
name: string
department: string
status: string
hireDate: string
monthlySalary: number
isPregnant: boolean
isInMedicalPeriod: boolean
isWorkInjured: boolean
contracts: any[]
disciplinaryRecords: any[]
attendanceRecords: any[]
performanceRecords: any[]
trainingRecords: any[]
}
export default function Termination() {
const queryClient = useQueryClient()
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
const [draftId, setDraftId] = useState<string | null>(null)
const [step, setStep] = useState(0)
const [reason, setReason] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [terminationDate, setTerminationDate] = useState('')
const [socialInsEndMonth, setSocialInsEndMonth] = useState('')
const [housingFundEndMonth, setHousingFundEndMonth] = useState('')
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
const [socialAvgWage, setSocialAvgWage] = useState(0)
const [compBreakdown, setCompBreakdown] = useState<any>(null)
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
const [editingCompField, setEditingCompField] = useState<string | null>(null)
const [editCompValue, setEditCompValue] = useState<number>(0)
const [editCompReason, setEditCompReason] = useState('')
const [approvalComment, setApprovalComment] = useState('')
const [savedItems, setSavedItems] = useState<Array<{
id: string
employeeId: string
name: string
department: string
reason: string
reasonLabel: string
terminationDate: string
severancePay: number
noticePay: number
doublePay: number
grandTotal: number
years: number
remainingMonths: number
compMonths: number
version: number
isSimulated: boolean
createdAt: string
}>>([])
// 是否展开对比
const [showCompare, setShowCompare] = useState(false)
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [draftPage, setDraftPage] = useState(1)
const [draftPageSize, setDraftPageSize] = useState(20)
const { data: employees } = useQuery<RosterEmployee[]>({
queryKey: ['roster-for-termination'],
queryFn: async () => {
const res = await api.get('/roster') as any
return res.data
},
})
const selectedEmployee = employees?.find((e) => e.id === employeeId)
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
const { data: profile } = useQuery<EmployeeProfile>({
queryKey: ['employee-profile', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/profile`) as any
return res.data
},
enabled: !!employeeId,
})
// 根据员工数据生成解聘建议
const suggestions = useMemo(() => {
if (!profile) return []
const list: { reason: string; label: string; why: string }[] = []
// 有违纪记录 → 建议过错解除
if (profile.disciplinaryRecords?.length > 0) {
const severe = profile.disciplinaryRecords.filter((d) => d.action === 'TERMINATION' || d.type === 'INSUBORDINATION' || d.type === 'MISCONDUCT')
if (severe.length > 0) {
list.push({ reason: 'FAULT', label: '过错解除', why: `${severe.length}条严重违纪记录,可依据规章制度解除` })
} else {
list.push({ reason: 'FAULT', label: '过错解除', why: `${profile.disciplinaryRecords.length}条违纪记录,可考虑过错解除` })
}
}
// 绩效不佳 → 建议非过错解除
const badPerf = profile.performanceRecords?.filter((p) => p.result === 'NEED_IMPROVE' || p.result === 'UNQUALIFIED')
if (badPerf?.length > 0) {
const hasTraining = profile.trainingRecords?.length > 0
list.push({
reason: 'NONFAULT',
label: '非过错解除',
why: hasTraining
? `${badPerf.length}次绩效不佳且已培训/调岗,可按不胜任解除`
: `${badPerf.length}次绩效不佳,需先培训或调岗后才能按不胜任解除`,
})
}
// 合同到期 → 建议不续签
const latestContract = profile.contracts?.[0]
if (latestContract?.endDate) {
const daysToExpire = Math.floor((new Date(latestContract.endDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
if (daysToExpire <= 30 && daysToExpire >= -90) {
list.push({ reason: 'EXPIRED', label: '合同到期不续签', why: `合同将于${latestContract.endDate.slice(0, 10)}到期,可选择不续签` })
}
}
// 未签合同 → 提示双倍工资风险
if (!latestContract?.signDate || latestContract?.contractType === 'UNSIGNED') {
const days = Math.floor((new Date().getTime() - new Date(profile.hireDate).getTime()) / (1000 * 60 * 60 * 24))
if (days > 30) {
list.push({ reason: 'NEGOTIATED', label: '协商解除', why: `未签合同已${days}天,协商解除可同时解决双倍工资问题` })
}
}
// 孕期/哺乳期/工伤 → 风险提示
if (profile.isPregnant) list.push({ reason: '', label: '⚠️ 孕期禁止解除', why: '该员工在孕期/哺乳期,法律禁止以非过错理由解除' })
if (profile.isWorkInjured) list.push({ reason: '', label: '⚠️ 工伤期间禁止解除', why: '工伤期间不得解除劳动合同' })
if (profile.isInMedicalPeriod) list.push({ reason: '', label: '⚠️ 医疗期保护', why: '医疗期内不得以非过错理由解除' })
// 默认推荐协商解除
if (list.length === 0 || !list.some((s) => s.reason !== '')) {
list.push({ reason: 'NEGOTIATED', label: '协商解除', why: '无特殊风险因素,推荐协商解除,成本最低、风险最小' })
}
return list
}, [profile])
const { data: checklistItems } = useQuery<{
key: string; label: string; autoChecked?: boolean | null; autoSource?: string; suggestion?: string; suggestionType?: string
}[]>({
queryKey: ['checklist', reason, employeeId],
queryFn: async () => {
const res = await api.get(`/termination/checklist/${reason}`, { params: { employeeId } }) as any
return res.data
},
enabled: !!reason && !!employeeId && step >= 2,
})
// checklist 加载后自动预填系统判断结果
useEffect(() => {
if (checklistItems) {
const prefilled: Record<string, boolean> = {}
checklistItems.forEach((item) => {
if (item.autoChecked === true) prefilled[item.key] = true
else if (item.autoChecked === false) prefilled[item.key] = false
})
setChecklist(prefilled)
}
}, [checklistItems])
const { data: riskAssessment } = useQuery<{ level: string; warnings: string[] }>({
queryKey: ['assess', employeeId, reason],
queryFn: async () => {
const res = await api.get(`/termination/assess/${employeeId}`, { params: { reason } }) as any
return res.data
},
enabled: !!employeeId && !!reason && step >= 1,
})
const saveMutation = useMutation({
mutationFn: (data: any) => api.post('/termination', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['employees'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['evidence-chain'] })
setStep(5)
},
})
// 草稿列表
const { data: draftsData, refetch: refetchDrafts } = useQuery({
queryKey: ['termination-drafts', filterStatus, filterDepartment, searchTerm, draftPage, draftPageSize],
queryFn: async () => {
const params: any = { page: draftPage, pageSize: draftPageSize }
if (filterStatus) params.status = filterStatus
if (filterDepartment) params.department = filterDepartment
if (searchTerm) params.search = searchTerm
const res = await api.get('/termination/drafts', { params }) as any
return res.data
},
enabled: view === 'list',
})
const drafts = draftsData?.items || []
const draftsTotal = draftsData?.total || 0
// 草稿详情
const { data: draftDetail } = useQuery({
queryKey: ['termination-detail', draftId],
queryFn: async () => {
const res = await api.get(`/termination/detail/${draftId}`) as any
return res.data
},
enabled: !!draftId && view === 'detail',
})
// 保存草稿
const saveDraftMutation = useMutation({
mutationFn: (data: any) => draftId
? api.put(`/termination/draft/${draftId}`, data)
: api.post('/termination/draft', data),
onSuccess: (res: any) => {
const newId = draftId || res?.data?.id
setDraftId(newId)
toast.success('草稿已保存')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
},
onError: () => toast.error('保存失败'),
})
// 提交审批
const submitMutation = useMutation({
mutationFn: () => api.post(`/termination/draft/${draftId}/submit`),
onSuccess: () => {
toast.success('已提交审批')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
setView('list')
resetWizard()
},
onError: () => toast.error('提交失败'),
})
// 审批通过
const approveMutation = useMutation({
mutationFn: (comment: string) => api.post(`/termination/draft/${draftId}/approve`, { comment }),
onSuccess: () => {
toast.success('审批通过')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
setView('list')
},
onError: () => toast.error('操作失败'),
})
// 审批驳回
const rejectMutation = useMutation({
mutationFn: (comment: string) => api.post(`/termination/draft/${draftId}/reject`, { comment }),
onSuccess: () => {
toast.success('已驳回')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
setView('list')
},
onError: () => toast.error('操作失败'),
})
// 执行解聘
const executeMutation = useMutation({
mutationFn: () => api.post(`/termination/draft/${draftId}/execute`),
onSuccess: () => {
toast.success('解聘已执行')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['evidence-chain'] })
setView('list')
resetWizard()
},
onError: () => toast.error('执行失败'),
})
// 撤销
const cancelMutation = useMutation({
mutationFn: () => api.post(`/termination/draft/${draftId}/cancel`),
onSuccess: () => {
toast.success('已撤销')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setView('list')
},
onError: () => toast.error('撤销失败'),
})
const { data: evidenceChain } = useQuery({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
return res.data
},
enabled: !!employeeId && step === 5 && saveMutation.isSuccess,
})
const reasonLabel = REASONS.find((r) => r.value === reason)?.label || ''
const reasonLegalBasis = REASONS.find((r) => r.value === reason)?.legalBasis || ''
const costResult = useMemo(() => {
if (!selectedEmployee || !terminationDate) return null
const hire = new Date(selectedEmployee.hireDate)
const leave = new Date(terminationDate)
const totalMonths = (leave.getFullYear() - hire.getFullYear()) * 12 + (leave.getMonth() - hire.getMonth())
const years = Math.floor(totalMonths / 12)
const remainingMonths = totalMonths % 12
let compMonths: number
if (remainingMonths >= 6) compMonths = years + 1
else if (remainingMonths > 0) compMonths = years + 0.5
else compMonths = years
if (compMonths <= 0) compMonths = 0.5
const wage = selectedEmployee.monthlySalary || 0
let capped = false
let cappedWage = wage
let cappedMonths = compMonths
if (socialAvgWage > 0 && wage > socialAvgWage * 3) {
cappedWage = socialAvgWage * 3
cappedMonths = Math.min(compMonths, 12)
capped = true
}
const reasonMap: Record<string, { multiplier: number; notice: boolean }> = {
NEGOTIATED: { multiplier: 1, notice: false },
FAULT: { multiplier: 0, notice: false },
NONFAULT: { multiplier: 1, notice: true },
LAYOFF: { multiplier: 1, notice: false },
EXPIRED: { multiplier: 1, notice: false },
ILLEGAL: { multiplier: 2, notice: false },
}
const r = reasonMap[reason] || { multiplier: 1, notice: false }
const basePay = cappedWage * cappedMonths
const severancePay = basePay * r.multiplier
const noticePay = r.notice ? cappedWage : 0
const totalSeverance = severancePay + noticePay
// 双倍工资计算(未签合同)
const contract = selectedEmployee.latestContract
const hasContract = contract && contract.signDate && contract.contractType !== 'UNSIGNED'
let doublePay = 0
let doubleMonths = 0
let doubleStartDate = ''
let doubleEndDate = ''
if (!hasContract) {
const startDate = new Date(hire)
startDate.setMonth(startDate.getMonth() + 1)
startDate.setDate(startDate.getDate() + 1)
let endDate = new Date(hire)
endDate.setFullYear(endDate.getFullYear() + 1)
if (leave < endDate) endDate = leave
doubleMonths = Math.min(
Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)),
11,
)
doubleMonths = Math.max(doubleMonths, 0)
doublePay = wage * doubleMonths
doubleStartDate = startDate.toISOString().slice(0, 10)
doubleEndDate = endDate.toISOString().slice(0, 10)
}
return {
years, remainingMonths, compMonths, wage, cappedWage, cappedMonths, capped,
basePay, severancePay, noticePay, totalSeverance,
doublePay, doubleMonths, doubleStartDate, doubleEndDate, hasContract,
noComp: r.multiplier === 0,
isIllegal: r.multiplier === 2,
grandTotal: totalSeverance + doublePay,
}
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
const canProceed = () => {
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED')
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
if (step === 2) return true
if (step === 3) return true
if (step === 4) return true
return false
}
const handleSave = () => {
saveMutation.mutate({
employeeId,
reason,
terminationDate: new Date(terminationDate).toISOString(),
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
compensation: costResult?.totalSeverance || 0,
checklist,
remark: '',
})
}
/** 保存草稿(任意步骤可调用) */
const handleSaveDraft = () => {
const breakdown = costResult ? {
severance: costResult.severancePay,
noticePay: costResult.noticePay,
doublePay: costResult.doublePay,
other: 0,
total: costResult.grandTotal,
adjustments: compAdjustments,
} : null
saveDraftMutation.mutate({
employeeId,
reason,
type: 'TERMINATION',
terminationDate: terminationDate ? new Date(terminationDate).toISOString() : new Date().toISOString(),
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
compensation: costResult?.grandTotal || 0,
checklist,
currentStep: step,
compensationBreakdown: breakdown,
handoverItems,
checklistOverrides,
remark: '',
})
}
/** 编辑已有草稿 */
const handleEditDraft = (item: any) => {
setDraftId(item.id)
setEmployeeId(item.employeeId)
setReason(item.reason)
setTerminationDate(item.terminationDate)
setStep(item.currentStep || 0)
setView('wizard')
}
/** 查看详情 */
const handleViewDetail = (id: string) => {
setDraftId(id)
setView('detail')
}
/** 新建解聘 */
const handleNewTermination = () => {
resetWizard()
setView('wizard')
}
/** 补偿金分项调整 */
const handleCompAdjust = (field: string, fromVal: number, toVal: number, reason: string) => {
if (!reason.trim()) {
toast.error('请填写调整原因')
return
}
setCompAdjustments(prev => [...prev, { field, from: fromVal, to: toVal, reason }])
setCompBreakdown((prev: any) => ({ ...prev, [field]: toVal, total: (prev?.total || 0) - fromVal + toVal }))
setEditingCompField(null)
setEditCompValue(0)
setEditCompReason('')
toast.success('已调整')
}
// 模拟计算:追加新版本,支持参数对比
const handleSimulate = () => {
if (!costResult || !selectedEmployee) return
setSavedItems((prev) => {
// 该员工的最新版本号
const sameEmployee = prev.filter(item => item.employeeId === employeeId)
const maxVersion = sameEmployee.reduce((max, item) => Math.max(max, item.version), 0)
const newVersion = maxVersion + 1
// 追加新版本(不覆盖旧版本)
return [
...prev,
{
id: `sim-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
employeeId,
name: selectedEmployee.name,
department: selectedEmployee.department,
reason,
reasonLabel,
terminationDate,
severancePay: costResult.severancePay,
noticePay: costResult.noticePay,
doublePay: costResult.doublePay,
grandTotal: costResult.grandTotal,
years: costResult.years,
remainingMonths: costResult.remainingMonths,
compMonths: costResult.compMonths,
version: newVersion,
isSimulated: true,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
},
]
})
handleReset()
}
// 保存成功后追加正式版本(isSimulated=false
useEffect(() => {
if (saveMutation.isSuccess && costResult && selectedEmployee) {
setSavedItems((prev) => {
// 该员工的最新正式版本
const sameEmp = prev.filter(item => item.employeeId === employeeId && !item.isSimulated)
const maxVer = sameEmp.reduce((max, item) => Math.max(max, item.version), 0)
const newVer = maxVer + 1
// 替换该员工的旧正式版本(如有),追加新版本
const filtered = prev.filter(item => !(item.employeeId === employeeId && !item.isSimulated))
return [
...filtered,
{
id: `saved-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
employeeId,
name: selectedEmployee.name,
department: selectedEmployee.department,
reason,
reasonLabel,
terminationDate,
severancePay: costResult.severancePay,
noticePay: costResult.noticePay,
doublePay: costResult.doublePay,
grandTotal: costResult.grandTotal,
years: costResult.years,
remainingMonths: costResult.remainingMonths,
compMonths: costResult.compMonths,
version: newVer,
isSimulated: false,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
},
]
})
}
}, [saveMutation.isSuccess])
const resetWizard = () => {
setStep(0)
setReason('')
setEmployeeId('')
setTerminationDate('')
setSocialInsEndMonth('')
setHousingFundEndMonth('')
setChecklist({})
setAcknowledgeRisk(false)
setDraftId(null)
setCompBreakdown(null)
setCompAdjustments([])
setHandoverItems(DEFAULT_HANDOVER_ITEMS)
setChecklistOverrides({})
setEditingCompField(null)
setApprovalComment('')
}
const handleReset = resetWizard
const totalSeverance = savedItems.reduce((sum, item) => sum + item.severancePay, 0)
const totalNotice = savedItems.reduce((sum, item) => sum + item.noticePay, 0)
const totalDouble = savedItems.reduce((sum, item) => sum + item.doublePay, 0)
const totalGrand = savedItems.reduce((sum, item) => sum + item.grandTotal, 0)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
{view === 'list' && (
<Button size="sm" onClick={handleNewTermination}>
<Plus className="w-4 h-4 mr-1" />
</Button>
)}
{view !== 'list' && (
<Button variant="secondary" size="sm" onClick={() => { setView('list'); resetWizard() }}>
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
)}
</div>
{/* 草稿列表视图 */}
{view === 'list' && (
<>
<div className="flex gap-2 flex-wrap items-center">
<Input
placeholder="搜索员工姓名或部门"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="!w-48"
/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
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="DRAFT">稿</option>
<option value="PENDING_APPROVAL"></option>
<option value="APPROVED"></option>
<option value="EXECUTING"></option>
<option value="COMPLETED"></option>
<option value="CANCELLED"></option>
</select>
<select
value={filterDepartment}
onChange={(e) => setFilterDepartment(e.target.value)}
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>
{(searchTerm || filterStatus || filterDepartment) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment('') }} className="text-xs text-gray-500 hover:text-primary"></button>
)}
<Button variant="secondary" size="sm" onClick={async () => {
try {
const params = new URLSearchParams()
if (searchTerm) params.set('search', searchTerm)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/terminations?${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('导出失败') }
}}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
<Card>
{(!drafts || drafts.length === 0) ? (
<EmptyState
icon={<List className="w-8 h-8 text-gray-400" />}
title="暂无解聘记录"
description="点击「新建解聘」开始创建"
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{drafts.map((item: any) => (
<tr key={item.id} className="border-b hover:bg-gray-50">
<td className="py-2 px-3 font-medium">{item.employeeName}</td>
<td className="py-2 px-3 text-gray-600">{item.department}</td>
<td className="py-2 px-3 text-xs">{REASONS.find(r => r.value === item.reason)?.label || item.reason}</td>
<td className="py-2 px-3 text-xs">{item.terminationDate}</td>
<td className="py-2 px-3 text-xs">¥{fmt(item.compensation)}</td>
<td className="py-2 px-3">
<span className={`text-xs px-1.5 py-0.5 rounded ${
item.riskLevel === 'HIGH' ? 'bg-red-50 text-danger' :
item.riskLevel === 'MEDIUM' ? 'bg-amber-50 text-warning' :
'bg-green-50 text-safe'
}`}>
{item.riskLevel === 'HIGH' ? '高' : item.riskLevel === 'MEDIUM' ? '中' : '低'}
</span>
</td>
<td className="py-2 px-3">
<span className={`text-xs px-2 py-0.5 rounded ${STATUS_LABELS[item.status]?.color || 'bg-gray-100 text-gray-600'}`}>
{STATUS_LABELS[item.status]?.label || item.status}
</span>
</td>
<td className="py-2 px-3 text-xs text-gray-500">{item.updatedAt}</td>
<td className="py-2 px-3">
<div className="flex items-center justify-end gap-1">
{(item.status === 'DRAFT' || item.status === 'REJECTED') && (
<button
onClick={() => handleEditDraft(item)}
className="p-1 text-gray-500 hover:text-primary"
aria-label="编辑"
title="编辑"
>
<Edit className="w-3.5 h-3.5" />
</button>
)}
{item.status === 'DRAFT' && (
<button
onClick={() => {
if (confirm(`确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。`)) {
setDraftId(item.id)
executeMutation.mutate()
}
}}
className="p-1 text-safe hover:opacity-70"
aria-label="确定"
title="确定执行"
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
{item.status === 'PENDING_APPROVAL' && (
<>
<button
onClick={() => { setDraftId(item.id); setView('detail') }}
className="p-1 text-safe hover:opacity-70"
aria-label="审批通过"
title="审批通过"
>
<CheckCircle className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { setDraftId(item.id); setView('detail') }}
className="p-1 text-danger hover:opacity-70"
aria-label="驳回"
title="驳回"
>
<XCircle className="w-3.5 h-3.5" />
</button>
</>
)}
{item.status === 'APPROVED' && (
<button
onClick={() => { setDraftId(item.id); executeMutation.mutate() }}
className="p-1 text-primary hover:opacity-70"
aria-label="执行"
title="执行解聘"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
{item.status !== 'COMPLETED' && item.status !== 'CANCELLED' && (
<button
onClick={() => { setDraftId(item.id); cancelMutation.mutate() }}
className="p-1 text-gray-400 hover:text-danger"
aria-label="撤销"
title="撤销"
>
<Ban className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => handleViewDetail(item.id)}
className="p-1 text-gray-500 hover:text-primary"
aria-label="详情"
title="详情"
>
<FileText className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination
page={draftPage}
pageSize={draftPageSize}
total={draftsTotal}
onPageChange={setDraftPage}
onPageSizeChange={(s) => { setDraftPageSize(s); setDraftPage(1) }}
/>
</Card>
</>
)}
{/* 详情视图 */}
{view === 'detail' && draftDetail && (
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-sm font-medium">{draftDetail.employeeName}</h2>
<span className={`text-xs px-2 py-0.5 rounded ${STATUS_LABELS[draftDetail.status]?.color || ''}`}>
{STATUS_LABELS[draftDetail.status]?.label || draftDetail.status}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div><span className="text-gray-500"></span>{draftDetail.department}</div>
<div><span className="text-gray-500"></span>{REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason}</div>
<div><span className="text-gray-500"></span>{draftDetail.terminationDate}</div>
<div><span className="text-gray-500"></span>¥{fmt(draftDetail.compensation)}</div>
<div><span className="text-gray-500"></span>{draftDetail.socialInsEndMonth || '-'}</div>
<div><span className="text-gray-500"></span>{draftDetail.housingFundEndMonth || '-'}</div>
<div><span className="text-gray-500"></span>{draftDetail.riskLevel}</div>
<div><span className="text-gray-500"></span>{draftDetail.createdAt}</div>
</div>
{/* 补偿金分项 */}
{draftDetail.compensationBreakdown && (
<div className="border rounded-md p-3 space-y-2">
<div className="text-xs font-medium"></div>
<div className="text-xs space-y-1">
<div className="flex justify-between"><span className="text-gray-500"></span><span>¥{fmt(draftDetail.compensationBreakdown.severance)}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span>¥{fmt(draftDetail.compensationBreakdown.noticePay)}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span>¥{fmt(draftDetail.compensationBreakdown.doublePay)}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span>¥{fmt(draftDetail.compensationBreakdown.other)}</span></div>
<div className="flex justify-between font-bold border-t pt-1"><span></span><span className="text-danger">¥{fmt(draftDetail.compensationBreakdown.total)}</span></div>
</div>
{draftDetail.compensationBreakdown.adjustments?.length > 0 && (
<div className="mt-2 space-y-1">
<div className="text-xs text-gray-500"></div>
{draftDetail.compensationBreakdown.adjustments.map((adj: any, i: number) => (
<div key={i} className="text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded">
{adj.field}: ¥{fmt(adj.from)} ¥{fmt(adj.to)}{adj.reason}
</div>
))}
</div>
)}
</div>
)}
{/* 工作交接清单 */}
{draftDetail.handoverItems && (
<div className="border rounded-md p-3 space-y-2">
<div className="text-xs font-medium"></div>
{draftDetail.handoverItems.map((item: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={item.done ? 'text-safe' : 'text-gray-400'}>{item.done ? '✓' : '○'}</span>
<span>{item.label}</span>
{item.remark && <span className="text-gray-500">{item.remark}</span>}
</div>
))}
</div>
)}
{/* 审批信息 */}
{draftDetail.approvalComment && (
<div className="border rounded-md p-3">
<div className="text-xs font-medium mb-1"></div>
<div className="text-xs text-gray-600">{draftDetail.approvalComment}</div>
{draftDetail.approvedAt && <div className="text-xs text-gray-400 mt-1">{draftDetail.approvedAt}</div>}
</div>
)}
{/* 审批操作 */}
{draftDetail.status === 'PENDING_APPROVAL' && (
<div className="space-y-3 border-t pt-3">
<div>
<Label></Label>
<Input
type="text"
value={approvalComment}
onChange={(e) => setApprovalComment(e.target.value)}
placeholder="填写审批意见..."
/>
</div>
<div className="flex gap-2">
<Button
onClick={() => approveMutation.mutate(approvalComment)}
disabled={approveMutation.isPending}
>
<CheckCircle className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
onClick={() => rejectMutation.mutate(approvalComment)}
disabled={rejectMutation.isPending}
>
<XCircle className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
)}
{/* 执行操作 */}
{draftDetail.status === 'APPROVED' && (
<div className="border-t pt-3">
<Button
onClick={() => executeMutation.mutate()}
disabled={executeMutation.isPending}
>
<Play className="w-4 h-4 mr-1" />
</Button>
</div>
)}
{/* 撤销操作 */}
{draftDetail.status !== 'COMPLETED' && draftDetail.status !== 'CANCELLED' && (
<div className="border-t pt-3">
<Button
variant="secondary"
onClick={() => cancelMutation.mutate()}
disabled={cancelMutation.isPending}
>
<Ban className="w-4 h-4 mr-1" />
</Button>
</div>
)}
</div>
</Card>
)}
{/* 向导视图 */}
{view === 'wizard' && (
<>
<div className="flex gap-4">
{/* 左侧:向导 */}
<div className="flex-1 min-w-0">
{/* 步骤条 */}
<Stepper
steps={STEPS.map((title: string, i: number) => ({
key: String(i),
title,
status: i < step ? 'complete' : i === step ? 'current' : 'pending',
}))}
onStepClick={(key) => { const i = parseInt(key); if (i <= step) setStep(i) }}
className="mb-4"
/>
<Card>
<div className="mb-2 text-xs text-gray-500">Step {step + 1}/6{STEPS[step]}</div>
{/* Step 1: 选择员工 */}
{step === 0 && (
<div className="space-y-3">
<div>
<Label></Label>
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
<option value=""></option>
{employees?.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
{selectedEmployee && (
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="font-medium">{selectedEmployee.name}{selectedEmployee.department}</div>
<div>{selectedEmployee.hireDate?.toString().slice(0, 10)}</div>
<div>¥{fmt(selectedEmployee.monthlySalary)}</div>
{selectedEmployee.hasTermination && selectedEmployee.latestTerminationStatus !== 'CANCELLED' && selectedEmployee.latestTerminationStatus !== 'COMPLETED' && (
<div className="text-danger font-medium mt-1"> /</div>
)}
{selectedEmployee.latestContract ? (
<div>{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}</div>
) : (
<div className="text-warning"> </div>
)}
{selectedEmployee.counts && (
<div className="flex gap-3 flex-wrap mt-2">
{selectedEmployee.counts.disciplinaryRecords > 0 && (
<span className="text-danger">{selectedEmployee.counts.disciplinaryRecords}</span>
)}
{selectedEmployee.counts.performanceRecords > 0 && (
<span>{selectedEmployee.counts.performanceRecords}</span>
)}
{selectedEmployee.counts.attendanceRecords > 0 && (
<span>{selectedEmployee.counts.attendanceRecords}</span>
)}
</div>
)}
</div>
)}
{profile && suggestions.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-medium">📋 </div>
{suggestions.map((s, i) => (
<div
key={i}
className={`px-3 py-2 rounded-md text-xs ${s.reason === '' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}
>
<div className="font-medium">{s.label}</div>
<div className="text-xs mt-0.5">{s.why}</div>
</div>
))}
</div>
)}
{employeeId && !profile && (
<div className="text-xs text-gray-400">...</div>
)}
</div>
)}
{/* Step 2: 解聘方式 */}
{step === 1 && (
<div className="space-y-3">
{suggestions.length > 0 && (
<div className="bg-blue-50 rounded-md p-3 space-y-1">
<div className="text-xs font-medium text-blue-700">💡 </div>
{suggestions.filter((s) => s.reason).map((s, i) => (
<div key={i} className="text-xs text-blue-600">
{s.label}{s.why}
</div>
))}
</div>
)}
<div className="space-y-2">
{REASONS.map((r) => {
const suggested = suggestions.find((s) => s.reason === r.value)
return (
<label
key={r.value}
className={`flex items-start gap-3 p-3 rounded-md border cursor-pointer hover:bg-gray-50 ${suggested ? 'border-primary bg-primary/5' : ''}`}
>
<input type="radio" name="reason" value={r.value} checked={reason === r.value} onChange={(e) => setReason(e.target.value)} className="mt-0.5" />
<div className="flex-1">
<div className="text-xs flex items-center gap-2">
{r.label}
{suggested && <span className="text-xs text-primary font-medium"></span>}
</div>
{suggested && (
<div className="text-xs text-gray-500 mt-0.5">{suggested.why}</div>
)}
</div>
</label>
)
})}
</div>
<div>
<Label></Label>
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
</div>
{/* 实时费用预览 - 当有足够数据时在解聘方式下方显示 */}
{step === 1 && costResult && (
<div className="border border-primary/30 bg-primary/5 rounded-md p-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-primary">
<Calculator className="w-4 h-4" />
</div>
<div className="text-xs text-gray-500">{costResult.years}{costResult.remainingMonths} · ¥{fmt(costResult.wage)}</div>
{costResult.capped && (
<div className="text-xs text-warning"> 312</div>
)}
{costResult.noComp ? (
<div className="text-xs text-gray-600"></div>
) : (
<div className="flex items-center justify-between">
<span className="text-xs">
{costResult.isIllegal ? '违法解除赔偿金(×2' : '经济补偿金'}
<span className="text-gray-400 ml-1">{costResult.cappedMonths} × ¥{fmt(costResult.cappedWage)}</span>
</span>
<span className={`text-sm font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
¥{fmt(costResult.severancePay)}
</span>
</div>
)}
{costResult.noticePay > 0 && (
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span>¥{fmt(costResult.noticePay)}</span>
</div>
)}
{costResult.isIllegal && (
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
= × 287
</div>
)}
{!costResult.hasContract && costResult.doubleMonths > 0 && (
<div className="flex items-center justify-between text-xs border-t pt-2">
<span className="text-warning">{costResult.doubleMonths}</span>
<span className="text-warning font-medium">¥{fmt(costResult.doublePay)}</span>
</div>
)}
<div className="flex items-center justify-between border-t pt-2">
<span className="text-xs font-medium"></span>
<span className="text-base font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
</div>
</div>
)}
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="month" value={socialInsEndMonth || terminationDate.slice(0, 7)} onChange={(e) => setSocialInsEndMonth(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="month" value={housingFundEndMonth || terminationDate.slice(0, 7)} onChange={(e) => setHousingFundEndMonth(e.target.value)} />
</div>
</div>
{terminationDate && ((socialInsEndMonth && socialInsEndMonth !== terminationDate.slice(0, 7)) || (housingFundEndMonth && housingFundEndMonth !== terminationDate.slice(0, 7))) && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs mt-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
/
</div>
)}
</div>
{/* 禁止解聘检查 */}
{riskAssessment && riskAssessment.warnings.length > 0 && (
<div className="space-y-2">
{riskAssessment.warnings.map((w, i) => (
<InlineAlert key={i} type="error">
{w}
</InlineAlert>
))}
<label className="flex items-center gap-2 text-xs px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
<input type="checkbox" checked={acknowledgeRisk} onChange={(e) => setAcknowledgeRisk(e.target.checked)} />
</label>
</div>
)}
</div>
)}
{/* Step 3: 合规检查 */}
{step === 2 && (
<div className="space-y-3">
<InlineAlert type="info">
</InlineAlert>
{checklistItems?.map((item) => {
const checked = checklist[item.key] || false
const isAutoChecked = item.autoChecked !== null && item.autoChecked !== undefined
const suggestionColor =
item.suggestionType === 'warning' ? 'bg-amber-50 text-amber-700' :
item.suggestionType === 'required' ? 'bg-red-50 text-red-700' :
'bg-gray-50 text-gray-600'
return (
<div key={item.key} className="border rounded-md p-3 space-y-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecklist({ ...checklist, [item.key]: e.target.checked })}
/>
<span className="text-xs font-medium">{item.label}</span>
{isAutoChecked && (
<span className={`px-1.5 py-0.5 rounded text-xs ${item.autoChecked ? 'bg-green-50 text-safe' : 'bg-red-50 text-danger'}`}>
{item.autoChecked ? '是' : '否'}
</span>
)}
</label>
{item.autoSource && (
<div className="text-xs text-gray-500 pl-7 flex items-start gap-1">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>{item.autoSource}</span>
</div>
)}
{item.suggestion && (
<div className={`text-xs px-2 py-1.5 rounded-md ml-7 flex items-start gap-1 ${suggestionColor}`}>
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
<span>{item.suggestion}</span>
</div>
)}
</div>
)
})}
</div>
)}
{/* Step 4: 费用结算 */}
{step === 3 && (
<div className="space-y-3">
<div>
<Label></Label>
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
</div>
{costResult && (
<div className="space-y-3">
{/* 员工概况 */}
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="font-medium">{selectedEmployee?.name}{selectedEmployee?.department}</div>
<div>{costResult.years}{costResult.remainingMonths}</div>
<div>¥{fmt(costResult.wage)}/</div>
{costResult.capped && (
<div className="text-warning"> 312</div>
)}
</div>
{/* 经济补偿金 / 赔偿金 */}
{costResult.noComp ? (
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
</div>
) : (
<div className="border rounded-md p-4 space-y-2">
<div className="font-medium flex items-center gap-2">
<Calculator className="w-4 h-4" />
{costResult.isIllegal ? '违法解除赔偿金' : '经济补偿金'}
</div>
<div className="text-xs text-gray-500">{costResult.cappedMonths}</div>
<div className="text-xs text-gray-500">¥{fmt(costResult.cappedWage)}/</div>
{costResult.isIllegal && (
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span>¥{fmt(costResult.basePay)}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="font-medium">{costResult.isIllegal ? '赔偿金(×2' : '补偿金'}</span>
<span className={`text-base font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
¥{fmt(costResult.severancePay)}
</span>
</div>
{costResult.noticePay > 0 && (
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span>¥{fmt(costResult.noticePay)}</span>
</div>
)}
{costResult.noticePay > 0 && (
<div className="text-xs text-gray-400"> ¥{fmt(costResult.noticePay)}</div>
)}
{costResult.isIllegal && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>287</span>
</div>
)}
</div>
)}
{/* 双倍工资(未签合同自动触发) */}
{!costResult.hasContract && costResult.doubleMonths > 0 && (
<div className="border border-warning rounded-md p-4 space-y-2">
<div className="font-medium flex items-center gap-2 text-warning">
<AlertTriangle className="w-4 h-4" />
</div>
<div className="text-xs text-gray-500">{costResult.doubleStartDate}</div>
<div className="text-xs text-gray-500">{costResult.doubleEndDate}</div>
<div className="text-xs text-gray-500">{costResult.doubleMonths}</div>
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-base font-bold text-warning">¥{fmt(costResult.doublePay)}</span>
</div>
<div className="text-xs text-gray-400">{costResult.doubleMonths} × ¥{fmt(costResult.wage)}</div>
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-800 text-xs">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>1211</span>
</div>
</div>
)}
{/* 合计 */}
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
</div>
</div>
{/* 补偿金分项手动调整 */}
<div className="border rounded-md p-3 space-y-2">
<div className="text-xs font-medium flex items-center gap-1.5">
<Edit className="w-3.5 h-3.5" />
</div>
<div className="text-xs text-gray-500"></div>
{[
{ field: 'severance', label: '经济补偿金/赔偿金', value: costResult.severancePay },
{ field: 'noticePay', label: '代通知金', value: costResult.noticePay },
{ field: 'doublePay', label: '未签合同双倍工资', value: costResult.doublePay },
{ field: 'other', label: '其他费用', value: 0 },
].map((item) => (
<div key={item.field} className="flex items-center justify-between text-xs">
<span className="text-gray-600">{item.label}</span>
{editingCompField === item.field ? (
<div className="flex items-center gap-2">
<Input
type="number"
value={editCompValue}
onChange={(e) => setEditCompValue(Number(e.target.value) || 0)}
className="w-24 text-xs"
placeholder="新金额"
/>
<Input
type="text"
value={editCompReason}
onChange={(e) => setEditCompReason(e.target.value)}
className="w-32 text-xs"
placeholder="调整原因"
/>
<Button
size="sm"
onClick={() => handleCompAdjust(item.field, item.value, editCompValue, editCompReason)}
>
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingCompField(null)}>
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-medium">¥{fmt(compAdjustments.find(a => a.field === item.field)?.to ?? item.value)}</span>
<button
onClick={() => { setEditingCompField(item.field); setEditCompValue(item.value); setEditCompReason('') }}
className="text-gray-400 hover:text-primary"
aria-label="调整"
>
<Edit className="w-3 h-3" />
</button>
</div>
)}
</div>
))}
{compAdjustments.length > 0 && (
<div className="mt-2 space-y-1">
<div className="text-xs text-gray-500"></div>
{compAdjustments.map((adj, i) => (
<div key={i} className="text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded">
{adj.field}: ¥{fmt(adj.from)} ¥{fmt(adj.to)}{adj.reason}
</div>
))}
</div>
)}
</div>
{!costResult.noComp && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>116116</span>
</div>
)}
</div>
)}
</div>
)}
{/* Step 5: 工作交接 */}
{step === 4 && (
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div></div>
</div>
{handoverItems.map((item, i) => (
<div key={i} className="border rounded-md p-3 space-y-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={item.done}
onChange={(e) => setHandoverItems(prev => prev.map((p, idx) => idx === i ? { ...p, done: e.target.checked } : p))}
/>
<span className={`text-xs font-medium ${item.done ? 'text-safe' : ''}`}>{item.label}</span>
{item.done && <Check className="w-3.5 h-3.5 text-safe" />}
</label>
<Input
type="text"
value={item.remark}
onChange={(e) => setHandoverItems(prev => prev.map((p, idx) => idx === i ? { ...p, remark: e.target.value } : p))}
placeholder="备注说明(选填)"
className="text-xs"
/>
</div>
))}
<div className="flex items-center gap-2 text-xs text-gray-500">
<span> {handoverItems.filter(i => i.done).length}/{handoverItems.length} </span>
{handoverItems.every(i => i.done) && (
<span className="text-safe flex items-center gap-1">
<Check className="w-3 h-3" />
</span>
)}
</div>
</div>
)}
{/* Step 6: 确认提交 */}
{step === 5 && (
<div className="space-y-3">
{/* 汇总信息 */}
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="font-medium">{selectedEmployee?.name}{selectedEmployee?.department}</div>
<div>{reasonLabel}</div>
<div>{terminationDate}</div>
<div>{socialInsEndMonth || terminationDate.slice(0, 7)}</div>
<div>{housingFundEndMonth || terminationDate.slice(0, 7)}</div>
{costResult && (
<div>¥{fmt(costResult.grandTotal)}</div>
)}
<div>{handoverItems.filter(i => i.done).length}/{handoverItems.length} </div>
{compAdjustments.length > 0 && (
<div className="text-amber-700"> {compAdjustments.length} </div>
)}
</div>
{/* 风险提示 */}
{riskAssessment && riskAssessment.warnings.length > 0 && (
<div className="space-y-1">
{riskAssessment.warnings.map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
{w}
</div>
))}
</div>
)}
{/* 操作按钮 */}
<div className="border-t pt-3 space-y-2">
<div className="text-xs text-gray-500"></div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
onClick={handleSaveDraft}
disabled={saveDraftMutation.isPending}
>
<List className="w-4 h-4 mr-1" />稿
</Button>
<Button
onClick={() => { handleSaveDraft(); submitMutation.mutate() }}
disabled={submitMutation.isPending || saveDraftMutation.isPending}
>
<Send className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
onClick={() => { handleSaveDraft(); executeMutation.mutate() }}
disabled={executeMutation.isPending || saveDraftMutation.isPending}
>
<Play className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="text-xs text-gray-400 mt-2">
稿 · ·
</div>
</div>
</div>
)}
{/* Step 6: 解聘材料(执行完成后展示) */}
{step === 5 && saveMutation.isSuccess && (
<div className="space-y-3">
{saveMutation.isError ? (
<div className="text-center py-8">
<AlertTriangle className="w-12 h-12 text-danger mx-auto" />
<div className="text-danger font-medium mt-2"></div>
<div className="text-xs text-gray-500">{(saveMutation as any).error?.response?.data?.error?.message || '请稍后重试'}</div>
<Button onClick={() => setStep(3)} className="mt-4"></Button>
</div>
) : saveMutation.isPending ? (
<div className="text-center py-8 text-gray-400">...</div>
) : (
<div className="space-y-3">
{/* 成功提示 */}
<div className="flex items-center gap-2 text-safe">
<Check className="w-5 h-5" />
<span className="font-medium"></span>
</div>
{/* 打印按钮 */}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => window.print()}>
<Printer className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={handleReset}>
</Button>
</div>
{/* 1. 解聘通知书 */}
<div className="border rounded-lg p-6 space-y-3 print:shadow-none">
<div className="text-center">
<h2 className="text-base font-bold"></h2>
</div>
<div className="text-xs text-gray-700 space-y-3">
<p><strong>{selectedEmployee?.name}</strong> /</p>
<p>
<strong>{selectedEmployee?.hireDate?.toString().slice(0, 10)}</strong> {selectedEmployee?.department}
<strong>{reasonLabel}</strong> <strong>{terminationDate}</strong>
</p>
<p>
{reasonLegalBasis}
</p>
{costResult && !costResult.noComp && (
<p>
<strong>{costResult.cappedMonths}</strong> <strong>¥{fmt(costResult.cappedWage)}/</strong>
<strong>¥{fmt(costResult.severancePay)}</strong>
{costResult.noticePay > 0 && `(含代通知金 ¥${fmt(costResult.noticePay)}`}
</p>
)}
{costResult && costResult.noComp && (
<p></p>
)}
{costResult && !costResult.hasContract && costResult.doubleMonths > 0 && (
<p>
{costResult.doubleMonths} <strong>¥{fmt(costResult.doublePay)}</strong>
</p>
)}
{costResult && (
<p><strong>¥{fmt(costResult.grandTotal)}</strong></p>
)}
<p></p>
<div className="text-right mt-6 space-y-1">
<p></p>
<p className="text-gray-400">{new Date().toISOString().slice(0, 10)}</p>
</div>
</div>
</div>
{/* 2. 费用结算明细 */}
{costResult && (
<div className="border rounded-lg p-4 space-y-2">
<h3 className="font-medium flex items-center gap-2"><Calculator className="w-4 h-4" /></h3>
<div className="text-xs space-y-1">
<div className="flex justify-between"><span></span><span>{costResult.years}{costResult.remainingMonths}</span></div>
<div className="flex justify-between"><span></span><span>¥{fmt(costResult.wage)}/</span></div>
{costResult.capped && <div className="text-warning"> 312</div>}
{!costResult.noComp && (
<>
<div className="flex justify-between"><span></span><span>{costResult.cappedMonths}</span></div>
<div className="flex justify-between"><span></span><span>¥{fmt(costResult.cappedWage)}/</span></div>
<div className="flex justify-between font-medium"><span>{costResult.isIllegal ? '违法解除赔偿金(×2' : '经济补偿金'}</span><span>¥{fmt(costResult.severancePay)}</span></div>
{costResult.noticePay > 0 && <div className="flex justify-between"><span></span><span>¥{fmt(costResult.noticePay)}</span></div>}
</>
)}
{!costResult.hasContract && costResult.doubleMonths > 0 && (
<div className="flex justify-between text-warning"><span>{costResult.doubleMonths}</span><span>¥{fmt(costResult.doublePay)}</span></div>
)}
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span></span><span>¥{fmt(costResult.grandTotal)}</span></div>
</div>
</div>
)}
{/* 3. 合规检查清单 */}
<div className="border rounded-lg p-4 space-y-2">
<h3 className="font-medium flex items-center gap-2"><Shield className="w-4 h-4" /></h3>
<div className="text-xs space-y-1">
{checklistItems?.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<span className={checklist[item.key] ? 'text-safe' : 'text-danger'}>
{checklist[item.key] ? '✓' : '✗'}
</span>
<span className={checklist[item.key] ? '' : 'text-gray-500'}>{item.label}</span>
</div>
))}
{riskAssessment && riskAssessment.warnings.length > 0 && (
<div className="mt-2 space-y-1">
{riskAssessment.warnings.map((w, i) => (
<div key={i} className="flex items-center gap-2 text-danger">
<AlertTriangle className="w-3 h-3" />{w}
</div>
))}
</div>
)}
</div>
</div>
{/* 4. 仲裁证据链 */}
<div className="border rounded-lg p-4 space-y-3">
<h3 className="font-medium flex items-center gap-2"><FileText className="w-4 h-4" /></h3>
{evidenceChain ? (
<>
<div className="text-xs text-gray-500">
{evidenceChain.summary?.total || 0}
{evidenceChain.summary?.signed || 0}
{evidenceChain.summary?.unsigned || 0}
</div>
{(() => {
const grouped = (evidenceChain.evidence || []).reduce((acc: Record<string, any[]>, e: any) => {
(acc[e.category] = acc[e.category] || []).push(e)
return acc
}, {})
return Object.entries(grouped).map(([category, items]) => (
<div key={category} className="space-y-1">
<div className="text-xs font-medium text-gray-700">{category as string}</div>
{(items as any[]).map((e: any, i: number) => (
<div key={i} className="text-xs text-gray-600 pl-4 border-l-2 border-gray-200 ml-1">
<div className="flex items-center gap-2">
<span>{e.title}</span>
{e.acknowledged === true && <span className="text-safe"></span>}
{e.acknowledged === false && <span className="text-danger"></span>}
</div>
<div className="text-gray-400">{e.description}</div>
</div>
))}
</div>
))
})()}
</>
) : (
<div className="text-xs text-gray-400">...</div>
)}
</div>
</div>
)}
</div>
)}
{/* 导航按钮 */}
{step < 5 && (
<div className="flex justify-between mt-6">
<Button
variant="secondary"
onClick={() => setStep(Math.max(0, step - 1))}
disabled={step === 0}
>
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
{step < 4 ? (
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
) : step === 4 ? (
<div className="flex gap-2">
<Button variant="secondary" onClick={handleSaveDraft} disabled={saveDraftMutation.isPending}>
<List className="w-4 h-4 mr-1" />稿
</Button>
<Button onClick={() => setStep(5)} disabled={!canProceed()}>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
) : null}
</div>
)}
</Card>
</div>
{/* 右侧:暂存列表 */}
<div className="w-72 shrink-0 hidden md:block">
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5">
<List className="w-4 h-4" />
</h2>
<div className="flex gap-1">
{savedItems.length > 0 && (
<Button
variant={showCompare ? 'primary' : 'secondary'}
size="sm"
onClick={() => {
setShowCompare(!showCompare)
}}
>
<List className="w-3.5 h-3.5 mr-1" />
{showCompare ? '收起对比' : '版本对比'}
</Button>
)}
{savedItems.length > 0 && (
<>
<Button variant="secondary" size="sm" onClick={() => {
const doc = new jsPDF()
doc.setFontSize(16)
doc.text('解聘补偿记录', 14, 20)
doc.setFontSize(10)
doc.text(`生成日期: ${new Date().toLocaleDateString('zh-CN')}`, 14, 28)
let y = 40
savedItems.forEach((item, i) => {
if (y > 270) { doc.addPage(); y = 20 }
doc.setFontSize(11)
doc.text(`${i + 1}. ${item.name} (${item.department})`, 14, y)
y += 6
doc.setFontSize(9)
doc.text(`解聘原因: ${item.reason || '-'}`, 20, y); y += 5
doc.text(`解聘日期: ${item.terminationDate || '-'}`, 20, y); y += 5
doc.text(`工作年限: ${item.years?.toFixed(1) || 0}`, 20, y); y += 5
if (item.severancePay > 0) { doc.text(`补偿金: ¥${fmt(item.severancePay)}`, 20, y); y += 5 }
if (item.noticePay > 0) { doc.text(`代通知金: ¥${fmt(item.noticePay)}`, 20, y); y += 5 }
if (item.doublePay > 0) { doc.text(`双倍工资: ¥${fmt(item.doublePay)}`, 20, y); y += 5 }
doc.text(`合计: ¥${fmt(item.grandTotal)}`, 20, y); y += 8
})
if (y > 260) { doc.addPage(); y = 20 }
doc.setFontSize(11)
doc.text(`总计: ¥${fmt(totalGrand)}`, 14, y)
doc.save(`解聘补偿记录-${new Date().toISOString().slice(0, 10)}.pdf`)
}}>
<Download className="w-3.5 h-3.5" />
</Button>
<Button variant="secondary" size="sm" onClick={() => setSavedItems([])}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</div>
{savedItems.length === 0 && !showCompare ? (
<div className="text-center py-6 text-gray-400 text-xs">
<br />
</div>
) : showCompare ? (
<div className="space-y-2">
{savedItems.map((item, i) => (
<div key={i} className="border rounded-md p-2 space-y-1">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium">{item.name}</div>
<div className="text-xs text-gray-400">{item.department}</div>
</div>
<button
onClick={() => setSavedItems(savedItems.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-xs text-gray-500 flex justify-between">
<span>{item.reasonLabel}</span>
<span>{item.years}{item.remainingMonths}</span>
</div>
<div className="text-xs space-y-0.5">
{item.severancePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.severancePay)}</span></div>
)}
{item.noticePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.noticePay)}</span></div>
)}
{item.doublePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.doublePay)}</span></div>
)}
<div className="flex justify-between font-bold border-t pt-0.5"><span></span><span className="text-danger">¥{fmt(item.grandTotal)}</span></div>
</div>
</div>
))}
{/* 合计 */}
<div className="border-t-2 pt-2 space-y-0.5">
<div className="text-xs font-medium text-gray-600">{savedItems.length}</div>
{totalSeverance > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalSeverance)}</span></div>
)}
{totalNotice > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalNotice)}</span></div>
)}
{totalDouble > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalDouble)}</span></div>
)}
<div className="flex justify-between text-sm font-bold">
<span></span>
<span className="text-danger">¥{fmt(totalGrand)}</span>
</div>
</div>
</div>
) : (
<div className="space-y-2">
{savedItems.map((item, i) => (
<div key={i} className="border rounded-md p-2 space-y-1">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium">{item.name}</div>
<div className="text-xs text-gray-400">{item.department}</div>
</div>
<button
onClick={() => setSavedItems(savedItems.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-xs text-gray-500 flex justify-between">
<span>{item.reasonLabel}</span>
<span>{item.years}{item.remainingMonths}</span>
</div>
<div className="text-xs space-y-0.5">
{item.severancePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.severancePay)}</span></div>
)}
{item.noticePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.noticePay)}</span></div>
)}
{item.doublePay > 0 && (
<div className="flex justify-between"><span className="text-gray-400"></span><span>¥{fmt(item.doublePay)}</span></div>
)}
<div className="flex justify-between font-bold border-t pt-0.5"><span></span><span className="text-danger">¥{fmt(item.grandTotal)}</span></div>
</div>
</div>
))}
{/* 合计 */}
<div className="border-t-2 pt-2 space-y-0.5">
<div className="text-xs font-medium text-gray-600">{savedItems.length}</div>
{totalSeverance > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalSeverance)}</span></div>
)}
{totalNotice > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalNotice)}</span></div>
)}
{totalDouble > 0 && (
<div className="flex justify-between text-xs"><span className="text-gray-400"></span><span>¥{fmt(totalDouble)}</span></div>
)}
<div className="flex justify-between text-sm font-bold">
<span></span>
<span className="text-danger">¥{fmt(totalGrand)}</span>
</div>
</div>
</div>
)}
</Card>
</div>
</div>
</>
)}
</div>
)
}