Files
TurboHR/frontend/src/pages/Termination.tsx
T

2013 lines
99 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 { usePageSize } from '../hooks/usePageSize'
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 PageGuide from '../components/ui/PageGuide'
import jsPDF from 'jspdf'
import { rosterApi, terminationApi } from '../lib/api-services'
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'
import { useConfirm } from '../hooks/useConfirm'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const REASONS = [
{
value: 'NEGOTIATED',
label: '协商解除(双方同意分开了)',
legalBasis: '《劳动合同法》第36条',
legalDetail: '用人单位与劳动者协商一致,可以解除劳动合同。需支付经济补偿(N),工作满1年支付1个月工资。',
steps: [
'第一步:与员工进行协商沟通,达成解除意向',
'第二步:签订《协商解除劳动合同协议书》,明确解除日期、补偿金额、支付方式',
'第三步:按协议约定支付经济补偿金',
'第四步:出具《解除劳动合同证明书》',
'第五步:办理工作交接、社保减员、档案转移',
],
},
{
value: 'FAULT',
label: '员工犯错被辞退(严重违纪/失职等)',
legalBasis: '《劳动合同法》第39条',
legalDetail: '劳动者有下列情形之一的,用人单位可以解除劳动合同:(1)在试用期间被证明不符合录用条件的;(2)严重违反用人单位的规章制度的;(3)严重失职,营私舞弊,给用人单位造成重大损害的;(4)同时与其他用人单位建立劳动关系,对完成本单位的工作任务造成严重影响,或经提出拒不改正的;(5)以欺诈、胁迫等手段致使劳动合同无效的;(6)被依法追究刑事责任的。无需支付经济补偿。',
steps: [
'第一步:收集并固化证据(违纪事实、制度依据、证人证言等)',
'第二步:确认规章制度经过民主程序制定并已向员工公示(签收记录)',
'第三步:将解除理由通知工会,工会提出意见后书面回复工会',
'第四步:向员工送达《解除劳动合同通知书》,注明解除依据和事实',
'第五步:办理工作交接、社保减员、档案转移',
'⚠ 注意:证据不足或制度未公示可能导致违法解除,建议咨询律师',
],
},
{
value: 'NONFAULT',
label: '员工没犯错但干不了(生病/不胜任等)',
legalBasis: '《劳动合同法》第40条',
legalDetail: '有下列情形之一的,用人单位提前三十日以书面形式通知劳动者本人或者额外支付一个月工资后,可以解除劳动合同:(1)劳动者患病或非因工负伤,在规定的医疗期满后不能从事原工作,也不能从事由用人单位另行安排的工作的;(2)劳动者不能胜任工作,经过培训或者调整工作岗位,仍不能胜任工作的。需支付经济补偿(N)+代通知金(1个月)。',
steps: [
'第一步(医疗期满):确认医疗期已满,安排劳动能力鉴定',
'第一步(不胜任):进行绩效考核,确认不胜任事实',
'第二步(医疗期满):另行安排合适岗位,员工仍不能胜任',
'第二步(不胜任):进行培训或调岗,再次考核仍不胜任',
'第三步:提前30天书面通知员工,或额外支付1个月代通知金',
'第四步:支付经济补偿金(N)',
'第五步:出具解除证明,办理交接、社保减员',
],
},
{
value: 'LAYOFF',
label: '公司裁员(经营困难/技术调整等)',
legalBasis: '《劳动合同法》第41条',
legalDetail: '有下列情形之一,需要裁减人员二十人以上或不足二十人但占企业职工总数百分之十以上的,用人单位提前三十日向工会或全体职工说明情况,听取意见后,向劳动行政部门报告,可以裁减人员:(1)依照企业破产法规定进行重整的;(2)生产经营发生严重困难的;(3)企业转产、重大技术革新或经营方式调整,经变更劳动合同仍需裁减人员的。需支付经济补偿(N)。',
steps: [
'第一步:确认符合法定裁员情形,准备相关证明材料',
'第二步:提前30天向工会或全体职工说明情况(书面会议记录)',
'第三步:听取工会或职工意见,形成意见处理方案',
'第四步:向当地劳动行政部门报告裁员方案',
'第五步:确定裁员名单(优先留用法定保护人员)',
'第六步:向员工送达解除通知,支付经济补偿金(N)',
'第七步:办理交接、社保减员、档案转移',
],
},
{
value: 'EXPIRED',
label: '合同到期不续签',
legalBasis: '《劳动合同法》第44条、第46条',
legalDetail: '劳动合同期满,劳动合同终止。除用人单位维持或提高劳动合同约定条件续订劳动合同,劳动者不同意续订的情形外,用人单位应当向劳动者支付经济补偿(N)。连续订立两次固定期限劳动合同后续订的,应当订立无固定期限劳动合同。',
steps: [
'第一步:确认合同到期日期,提前评估是否续签',
'第二步:如不续签,提前通知员工(建议提前30天)',
'第三步:确认是否属于法定应续签无固定期限合同的情形',
'第四步:支付经济补偿金(N),工作满1年支付1个月工资',
'第五步:出具《终止劳动合同证明书》',
'第六步:办理交接、社保减员、档案转移',
],
},
{
value: 'ILLEGAL',
label: '违法解除(赔偿金×2',
legalBasis: '《劳动合同法》第87条',
legalDetail: '用人单位违反本法规定解除或终止劳动合同的,应当依照本法第47条规定的经济补偿标准的二倍向劳动者支付赔偿金(2N)。',
steps: [
'第一步:确认违法解除事实(未走法定程序、证据不足等)',
'第二步:与员工协商赔偿金额,尽量达成一致',
'第三步:如员工要求继续履行合同,应恢复劳动关系',
'第四步:如无法恢复,支付2倍经济补偿作为赔偿金',
'第五步:办理交接、社保减员、档案转移',
'⚠ 建议:违法解除风险极高,操作前务必咨询专业律师',
],
},
{
value: 'RESIGNATION',
label: '员工主动离职',
legalBasis: '《劳动合同法》第37条',
legalDetail: '劳动者提前三十日以书面形式通知用人单位,可以解除劳动合同。劳动者在试用期内提前三日通知用人单位,可以解除劳动合同。无需支付经济补偿。',
steps: [
'第一步:接收员工书面辞职信(确认日期和签字)',
'第二步:确认提前30天通知(试用期3天)',
'第三步:安排工作交接计划',
'第四步:结算未发放工资、未休年假补偿等',
'第五步:出具《解除劳动合同证明书》',
'第六步:办理社保减员、档案转移',
],
},
]
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 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 confirm = useConfirm()
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 [, 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 draftPageSize = usePageSize()
const [draftPage, setDraftPage] = useState(1)
const { data: employees } = useQuery<any[]>({
queryKey: ['roster-for-termination'],
queryFn: async () => {
const res = await rosterApi.list({ pageSize: 999 })
return res.data
},
})
const selectedEmployee = employees?.find((e) => e.id === employeeId)
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: () => rosterApi.departments(),
})
const { data: profile } = useQuery<EmployeeProfile>({
queryKey: ['employee-profile', employeeId],
queryFn: async () => {
return await rosterApi.profile(employeeId)
},
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 () => {
return await terminationApi.checklist(reason, employeeId)
},
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 () => {
return await terminationApi.assess(employeeId, reason)
},
enabled: !!employeeId && !!reason && step >= 1,
})
const saveMutation = useMutation({
mutationFn: (data: any) => terminationApi.create(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 } = 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
return await terminationApi.drafts(params)
},
enabled: view === 'list',
})
const drafts = draftsData?.items || []
const draftsTotal = draftsData?.total || 0
// 草稿详情
const { data: draftDetail } = useQuery({
queryKey: ['termination-detail', draftId],
queryFn: async () => {
return await terminationApi.detail(draftId!)
},
enabled: !!draftId && view === 'detail',
})
// 保存草稿
const saveDraftMutation = useMutation({
mutationFn: (data: any) => draftId
? terminationApi.updateDraft(draftId!, data)
: terminationApi.createDraft(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: () => terminationApi.submit(draftId!),
onSuccess: () => {
toast.success('已提交审批')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
setView('list')
resetWizard()
},
onError: () => toast.error('提交失败'),
})
// 审批通过
const approveMutation = useMutation({
mutationFn: (comment: string) => terminationApi.approve(draftId!, comment),
onSuccess: () => {
toast.success('审批通过')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
setView('list')
},
onError: () => toast.error('操作失败'),
})
// 审批驳回
const rejectMutation = useMutation({
mutationFn: (comment: string) => terminationApi.reject(draftId!, comment),
onSuccess: () => {
toast.success('已驳回')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
setView('list')
},
onError: () => toast.error('操作失败'),
})
// 执行解聘
const executeMutation = useMutation({
mutationFn: () => terminationApi.execute(draftId!),
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: () => terminationApi.cancel(draftId!),
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 () => {
return await rosterApi.evidenceChain(employeeId)
},
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 adjustedTotal = useMemo(() => {
if (!costResult) return 0
let total = costResult.grandTotal
compAdjustments.forEach(adj => {
total += (adj.to - adj.from)
})
return total
}, [costResult, compAdjustments])
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)
// step 2: 合规检查 — required 项必须勾选
if (step === 2) {
if (!checklistItems) return true
const requiredItems = checklistItems.filter(item => item.suggestionType === 'required')
if (requiredItems.length === 0) return true
return requiredItems.every(item => checklist[item.key] === true)
}
// step 3: 费用结算 — 有补偿金时需确认
if (step === 3) return true
// step 4: 工作交接 — 关键项需完成
if (step === 4) {
const keyItems = handoverItems.filter(item => item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke')
if (keyItems.length === 0) return true
return keyItems.every(item => item.done)
}
return false
}
/** 保存草稿(任意步骤可调用) */
const handleSaveDraft = () => {
const breakdown = costResult ? {
severance: costResult.severancePay,
noticePay: costResult.noticePay,
doublePay: costResult.doublePay,
other: 0,
total: adjustedTotal,
systemTotal: 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: adjustedTotal || 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('已调整')
}
// 保存成功后追加正式版本(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' && (
<>
<PageGuide>
稿
</PageGuide>
<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={async () => {
if (await confirm({ title: '执行解聘', message: `确认执行「${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={() => 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>
)}
{/* 下载离职证明 & 交接清单 */}
{draftDetail.status === 'COMPLETED' && (
<div className="border-t pt-3 flex gap-2 flex-wrap">
<Button
variant="secondary"
onClick={() => {
const doc = new jsPDF()
doc.setFontSize(18)
doc.text('解除/终止劳动合同证明书', 105, 25, { align: 'center' })
doc.setFontSize(11)
let y = 45
doc.text(`兹证明 ${draftDetail.employeeName}(身份证号:${draftDetail.idCardNumber || '—'}),`, 14, y); y += 8
doc.text(`原系我公司 ${draftDetail.department} 部门员工,`, 14, y); y += 8
doc.text(`${draftDetail.terminationDate}${REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason} 原因,`, 14, y); y += 8
doc.text(`正式解除/终止劳动合同。`, 14, y); y += 8
doc.text(`经济补偿金已结清:¥${fmt(draftDetail.compensation)}`, 14, y); y += 8
doc.text(`社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}`, 14, y); y += 16
doc.text('特此证明。', 14, y); y += 24
doc.text('公司(盖章)', 140, y)
doc.text(new Date().toISOString().slice(0, 10), 140, y + 8)
doc.save(`离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.pdf`)
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
{draftDetail.handoverItems && draftDetail.handoverItems.length > 0 && (
<Button
variant="secondary"
onClick={() => {
const doc = new jsPDF()
doc.setFontSize(16)
doc.text('工作交接清单', 105, 25, { align: 'center' })
doc.setFontSize(11)
let y = 40
doc.text(`员工姓名:${draftDetail.employeeName}`, 14, y); y += 8
doc.text(`部门:${draftDetail.department || '—'}`, 14, y); y += 8
doc.text(`离职日期:${draftDetail.terminationDate}`, 14, y); y += 12
doc.setFontSize(10)
draftDetail.handoverItems.forEach((item: any, i: number) => {
if (y > 270) { doc.addPage(); y = 20 }
doc.text(`${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}`, 14, y); y += 7
})
y += 16
doc.text('交接人签字:____________', 14, y)
doc.text('接收人签字:____________', 100, y)
y += 12
doc.text('日期:____________', 14, y)
doc.save(`交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.pdf`)
}}
>
<Download 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>
{/* 法律条款详情 + 操作步骤 */}
{reason && (() => {
const r = REASONS.find((x) => x.value === reason)
if (!r) return null
return (
<div className="border border-gray-200 rounded-md p-4 space-y-3 bg-gray-50/50">
<div>
<div className="text-xs font-medium text-gray-700 flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-primary" />
{r.legalBasis}
</div>
<p className="text-xs text-gray-600 mt-1 leading-relaxed">{r.legalDetail}</p>
</div>
<div className="border-t border-gray-200 pt-2">
<div className="text-xs font-medium text-gray-700 mb-1.5">📋 </div>
<ol className="space-y-1">
{r.steps.map((s, i) => (
<li key={i} className={`text-xs leading-relaxed ${s.includes('⚠') ? 'text-amber-600 font-medium' : 'text-gray-600'}`}>{s}</li>
))}
</ol>
</div>
</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 && (
<>
{compAdjustments.length > 0 ? (
<>
<div>¥{fmt(costResult.grandTotal)}</div>
{compAdjustments.map((adj, i) => (
<div key={i} className="text-amber-700">
- {adj.field}¥{fmt(adj.from)} ¥{fmt(adj.to)}{adj.reason}
</div>
))}
<div className="font-medium text-primary">¥{fmt(adjustedTotal)}</div>
</>
) : (
<div>¥{fmt(costResult.grandTotal)}</div>
)}
</>
)}
<div>{handoverItems.filter(i => i.done).length}/{handoverItems.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 ? (
<div className="flex items-center gap-2">
{!canProceed() && step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
<span className="text-xs text-danger"></span>
)}
{!canProceed() && step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
<span className="text-xs text-danger"></span>
)}
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
) : 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>
)
}