Files
TurboHR/frontend/src/pages/Termination.tsx
T
selfrelease 7e720d9bfc feat: 北京解聘合规增强 — 政策法规库+地区过滤+工会回执证据链
1. 政策法规库(RAG知识库):
   - 新增6条北京地区单方解除劳动合同工作指引种子数据
   - 涵盖通知工会程序、函件内容要求、回执要求、监督提示函、仲裁审查等
   - 企业用户通过AI问答可检索到北京地区工会通知规定

2. 地区差异化合规检查:
   - getChecklistForReason 增加 orgCity 参数
   - 工会通知检查项仅北京地区显示(FAULT/NONFAULT/LAYOFF)
   - 前端解聘方式说明中北京工会提示仅北京地区动态显示
   - 非北京地区不显示工会通知检查项,避免误导

3. 工会回执上传+证据链留存:
   - 后端新增3个接口:上传回执文件、保存回执信息、获取回执信息
   - 回执信息保存到草稿 checklistOverrides
   - 自动追加到证据链(appendEvidence),作为劳动仲裁举证材料
   - 前端合规检查步骤增加工会回执上传区域
   - 确认提交步骤展示回执文件链接
   - 新增 uploads 静态文件服务

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

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

2441 lines
124 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 { useSearchParams } from 'react-router-dom'
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, PenTool } 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, esignApi, settingsApi } 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 [searchParams] = useSearchParams()
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 { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: () => settingsApi.org(),
staleTime: 300000,
})
const isBeijing = !orgData?.city || orgData?.city === '北京' || orgData?.city === '北京市' || (orgData?.city as string)?.includes('北京')
// 从 URL 参数预填员工和解聘类型(从花名册操作栏跳转)
useEffect(() => {
const urlEmployeeId = searchParams.get('employeeId')
const urlReason = searchParams.get('reason')
if (urlEmployeeId) {
setEmployeeId(urlEmployeeId)
setView('wizard')
}
if (urlReason) {
setReason(urlReason)
}
}, [searchParams])
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 [unionReceipt, setUnionReceipt] = useState<{
receiptNo: string
unionName: string
receiptDate: string
fileUrl: string
fileName: string
unionOpinion: string
} | null>(null)
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [editingCompField, setEditingCompField] = useState<string | null>(null)
const [editCompValue, setEditCompValue] = useState<number>(0)
const [editCompReason, setEditCompReason] = useState('')
const [approvalComment, setApprovalComment] = useState('')
/** 违法解除且实际补偿金低于法定2N时需确认风险 */
const [illegalRiskAcknowledged, setIllegalRiskAcknowledged] = useState(false)
/** 补偿月数模式:N/N+1/2N/自定义 */
const [compMonthMode, setCompMonthMode] = useState<'N' | 'N+1' | '2N' | 'CUSTOM'>('N')
const [customCompMonths, setCustomCompMonths] = useState<number>(0)
const [customCompReason, setCustomCompReason] = useState('')
/** 计算基数来源:近12月平均/合同工资/自定义 */
const [wageBaseType, setWageBaseType] = useState<'AVG_12' | 'CONTRACT' | 'CUSTOM'>('AVG_12')
const [customWageBase, setCustomWageBase] = useState<number>(0)
const [customWageReason, setCustomWageReason] = useState('')
/** 剩余年假天数及折算金额 */
const [annualLeaveDays, setAnnualLeaveDays] = useState<number>(0)
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 [filterDateFrom, setFilterDateFrom] = useState('')
const [filterDateTo, setFilterDateTo] = 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 deleteDraftMutation = useMutation({
mutationFn: (id: string) => terminationApi.deleteDraft(id),
onSuccess: () => {
toast.success('草稿已删除')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
},
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
}
// 支持自定义计算基数(近12月平均/合同工资/自定义)
let effectiveWageBase = cappedWage
if (wageBaseType === 'CONTRACT') {
effectiveWageBase = selectedEmployee.latestContract?.monthlySalary || wage
} else if (wageBaseType === 'CUSTOM' && customWageBase > 0) {
effectiveWageBase = customWageBase
// 自定义基数时提示三倍社平封顶但不强制
if (socialAvgWage > 0 && customWageBase > socialAvgWage * 3) {
capped = true // 仅标记提示
}
}
// 支持自定义补偿月数(N/N+1/2N/其他)
let effectiveMonths = cappedMonths
if (compMonthMode === 'N+1') {
effectiveMonths = compMonths + 1
} else if (compMonthMode === '2N') {
effectiveMonths = compMonths * 2
} else if (compMonthMode === 'CUSTOM' && customCompMonths > 0) {
effectiveMonths = customCompMonths
}
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 = effectiveWageBase * effectiveMonths
const severancePay = basePay * r.multiplier
const noticePay = r.notice ? effectiveWageBase : 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, compMonthMode, customCompMonths, wageBaseType, customWageBase])
// 年假折算金额:日工资 × 剩余年假天数 × 3(未休年假按300%支付)
const annualLeavePay = useMemo(() => {
if (!costResult || annualLeaveDays <= 0) return 0
const dailyWage = (costResult.cappedWage || 0) / 21.75
return Math.round(dailyWage * annualLeaveDays * 3 * 100) / 100
}, [costResult, annualLeaveDays])
// 计算调整后的实际补偿金(系统预估 + 手动调整差额 + 年假折算)
const adjustedTotal = useMemo(() => {
if (!costResult) return 0
let total = costResult.grandTotal
compAdjustments.forEach(adj => {
total += (adj.to - adj.from)
})
total += annualLeavePay
return total
}, [costResult, compAdjustments, annualLeavePay])
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 (riskAssessment?.warnings.length && !acknowledgeRisk) return false
return true
}
// step 3: 费用结算 — 违法解除且实际补偿金低于法定2N时需确认风险
if (step === 3) {
if (reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal) {
return illegalRiskAcknowledged
}
return true
}
// step 4: 工作交接 — 改为软阻断,未完成可下一步但会生成待办
if (step === 4) {
return true
}
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,
riskAcknowledged: reason === 'ILLEGAL' && adjustedTotal < costResult.grandTotal ? {
acknowledged: illegalRiskAcknowledged,
legalAmount: costResult.grandTotal,
actualAmount: adjustedTotal,
difference: costResult.grandTotal - adjustedTotal,
timestamp: new Date().toISOString(),
} : null,
} : 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,
compMonthMode,
customCompMonths: compMonthMode === 'CUSTOM' ? customCompMonths : undefined,
customCompReason: compMonthMode === 'CUSTOM' ? customCompReason : undefined,
wageBaseType,
customWageBase: wageBaseType === 'CUSTOM' ? customWageBase : undefined,
customWageReason: wageBaseType === 'CUSTOM' ? customWageReason : undefined,
annualLeaveDays: annualLeaveDays > 0 ? annualLeaveDays : undefined,
annualLeavePay: annualLeavePay > 0 ? annualLeavePay : undefined,
socialAvgWage: socialAvgWage > 0 ? socialAvgWage : undefined,
remark: '',
})
}
/** 编辑已有草稿(拉取完整详情后恢复所有字段) */
const handleEditDraft = async (item: any) => {
setDraftId(item.id)
setEmployeeId(item.employeeId)
setReason(item.reason)
setTerminationDate(item.terminationDate)
setStep(item.currentStep || 0)
setView('wizard')
// 恢复所有已保存的字段
if (item.socialInsEndMonth) setSocialInsEndMonth(item.socialInsEndMonth)
if (item.housingFundEndMonth) setHousingFundEndMonth(item.housingFundEndMonth)
if (item.checklist) setChecklist(item.checklist)
if (item.compensationBreakdown) {
setCompBreakdown(item.compensationBreakdown)
if (item.compensationBreakdown.adjustments) {
setCompAdjustments(item.compensationBreakdown.adjustments)
}
// 恢复违法解除风险确认状态
if (item.compensationBreakdown.riskAcknowledged?.acknowledged) {
setIllegalRiskAcknowledged(true)
}
}
if (item.handoverItems) setHandoverItems(item.handoverItems)
if (item.checklistOverrides) setChecklistOverrides(item.checklistOverrides)
// 恢复补偿月数和计算基数设置
if (item.compMonthMode) setCompMonthMode(item.compMonthMode)
if (item.customCompMonths) setCustomCompMonths(item.customCompMonths)
if (item.customCompReason) setCustomCompReason(item.customCompReason)
if (item.wageBaseType) setWageBaseType(item.wageBaseType)
if (item.customWageBase) setCustomWageBase(item.customWageBase)
if (item.customWageReason) setCustomWageReason(item.customWageReason)
if (item.annualLeaveDays) setAnnualLeaveDays(item.annualLeaveDays)
// 恢复社平工资
if (item.socialAvgWage) setSocialAvgWage(item.socialAvgWage)
}
/** 查看详情 */
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('')
setIllegalRiskAcknowledged(false)
setCompMonthMode('N')
setCustomCompMonths(0)
setCustomCompReason('')
setWageBaseType('AVG_12')
setCustomWageBase(0)
setCustomWageReason('')
setAnnualLeaveDays(0)
}
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">
<PageGuide>
稿
</PageGuide>
<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 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>
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="开始日期"
/>
<span className="text-xs text-gray-400"></span>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="结束日期"
/>
{(searchTerm || filterStatus || filterDepartment || filterDateFrom || filterDateTo) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment(''); setFilterDateFrom(''); setFilterDateTo('') }} 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)
if (filterDateFrom) params.set('dateFrom', filterDateFrom)
if (filterDateTo) params.set('dateTo', filterDateTo)
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>
)}
{(item.status === 'DRAFT' || item.status === 'CANCELLED') && (
<button
onClick={async () => {
if (await confirm({ title: '删除草稿', message: '确定删除此草稿记录?删除后不可恢复。' })) {
deleteDraftMutation.mutate(item.id)
}
}}
className="p-1 text-gray-400 hover:text-danger"
aria-label="删除"
title="删除"
>
<Trash2 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 reasonLabel = REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>离职证明</title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">解除/终止劳动合同证明书</div>
<div class="body">兹证明 ${draftDetail.employeeName}(证件号码:${draftDetail.idCardNumber || '—'}),原系我公司 ${draftDetail.department || '—'} 部门员工,于 ${draftDetail.terminationDate}${reasonLabel} 原因,正式解除/终止劳动合同。</div>
<div class="body">经济补偿金已结清:¥${fmt(draftDetail.compensation)}。社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}。</div>
<div class="body">特此证明。</div>
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
{draftDetail.handoverItems && draftDetail.handoverItems.length > 0 && (
<Button
variant="secondary"
onClick={() => {
const items = draftDetail.handoverItems.map((item: any) => `<div class="body">${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}</div>`).join('')
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>工作交接清单</title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 18pt; font-weight: bold; margin-bottom: 20pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">工作交接清单</div>
<div class="body">员工姓名:${draftDetail.employeeName}  部门:${draftDetail.department || '—'}  离职日期:${draftDetail.terminationDate}</div>
${items}
<div class="sign">交接人签字:____________<br/>接收人签字:____________<br/>日期:____________</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<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>
))}
{/* 北京地区单方解除须通知工会 */}
{isBeijing && ['FAULT', 'NONFAULT', 'LAYOFF'].includes(r.value) && (
<>
<li className="text-xs leading-relaxed text-amber-600 font-medium">
5///使
</li>
<li className="text-xs leading-relaxed text-amber-600 font-medium">
</li>
</>
)}
</ol>
</div>
</div>
)
})()}
<div>
<Label></Label>
<Input type="date" value={terminationDate} onChange={(e) => {
const date = e.target.value
setTerminationDate(date)
// 自动推导社保/公积金截止月:15号前(含)截止上月,15号后截止当月
if (date) {
const d = new Date(date)
const day = d.getDate()
const monthStr = date.slice(0, 7)
if (day <= 15) {
// 上月
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
const prevMonth = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
setSocialInsEndMonth(prevMonth)
setHousingFundEndMonth(prevMonth)
} else {
setSocialInsEndMonth(monthStr)
setHousingFundEndMonth(monthStr)
}
}
}} />
</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>
)
})}
{/* 工会回执上传区域(北京地区单方解除,checklist 包含 union_receipt 项时显示) */}
{checklistItems?.some(item => item.key === 'union_receipt') && checklist['notify_union'] && (
<div className="border rounded-md p-3 space-y-3 bg-blue-50/30">
<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" />
</div>
<div className="text-xs text-gray-500">
稿
</div>
{unionReceipt && unionReceipt.fileUrl ? (
/* 已上传回执展示 */
<div className="space-y-2 bg-white rounded-md p-2.5 border">
<div className="flex items-center gap-2 text-xs">
<CheckCircle className="w-4 h-4 text-safe" />
<span className="font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div>{unionReceipt.receiptNo || '—'}</div>
<div>{unionReceipt.unionName || '—'}</div>
<div>{unionReceipt.receiptDate || '—'}</div>
<div><a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{unionReceipt.fileName}</a></div>
</div>
{unionReceipt.unionOpinion && (
<div className="text-xs text-gray-600">{unionReceipt.unionOpinion}</div>
)}
<Button size="sm" variant="secondary" onClick={() => setUnionReceipt(null)}></Button>
</div>
) : (
/* 上传表单 */
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label></Label>
<Input value={unionReceipt?.receiptNo || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptNo: e.target.value }))} placeholder="如:2026第001号" />
</div>
<div>
<Label></Label>
<Input value={unionReceipt?.unionName || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionName: e.target.value }))} placeholder="如:XX街道总工会" />
</div>
<div>
<Label></Label>
<Input type="date" value={unionReceipt?.receiptDate || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptDate: e.target.value }))} />
</div>
<div>
<Label></Label>
<Input value={unionReceipt?.unionOpinion || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionOpinion: e.target.value }))} placeholder="如有意见建议请填写" />
</div>
</div>
<div>
<Label></Label>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf,.bmp"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file || !draftId) return
setUploadingReceipt(true)
try {
const res = await terminationApi.uploadUnionReceipt(draftId, file)
setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), fileUrl: res.fileUrl, fileName: res.fileName }))
toast.success('文件上传成功')
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '上传失败')
} finally {
setUploadingReceipt(false)
}
}}
className="block w-full text-xs text-gray-500 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:bg-primary file:text-white hover:file:bg-primary/90"
/>
{uploadingReceipt && <div className="text-xs text-gray-400 mt-1">...</div>}
</div>
{draftId && unionReceipt?.fileUrl && (
<Button
size="sm"
onClick={async () => {
try {
await terminationApi.saveUnionReceipt(draftId, unionReceipt)
toast.success('工会回执已保存到证据链')
setChecklist({ ...checklist, union_receipt: true })
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '保存失败')
}
}}
></Button>
)}
{!draftId && (
<div className="text-xs text-amber-600">稿</div>
)}
</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="border rounded-md p-3 space-y-3 bg-blue-50/30">
<div className="text-xs font-medium text-gray-700"></div>
{/* 补偿月数模式 */}
<div>
<Label></Label>
<div className="flex gap-2 flex-wrap">
{(['N', 'N+1', '2N', 'CUSTOM'] as const).map(mode => (
<button
key={mode}
type="button"
onClick={() => setCompMonthMode(mode)}
className={`px-3 py-1 rounded text-xs border ${compMonthMode === mode ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-300 hover:border-primary'}`}
>
{mode === 'N' ? 'N(法定)' : mode === 'N+1' ? 'N+1(含代通知)' : mode === '2N' ? '2N(违法解除)' : '自定义'}
</button>
))}
</div>
{compMonthMode === 'CUSTOM' && (
<div className="mt-2 space-y-1">
<Input type="number" value={customCompMonths || ''} onChange={(e) => setCustomCompMonths(Number(e.target.value) || 0)} placeholder="自定义补偿月数" />
<Input value={customCompReason} onChange={(e) => setCustomCompReason(e.target.value)} placeholder="自定义原因(必填)" />
</div>
)}
</div>
{/* 计算基数来源 */}
<div>
<Label></Label>
<div className="flex gap-2 flex-wrap">
{(['AVG_12', 'CONTRACT', 'CUSTOM'] as const).map(bt => (
<button
key={bt}
type="button"
onClick={() => setWageBaseType(bt)}
className={`px-3 py-1 rounded text-xs border ${wageBaseType === bt ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-300 hover:border-primary'}`}
>
{bt === 'AVG_12' ? '近12月平均工资' : bt === 'CONTRACT' ? '合同约定工资' : '自定义基数'}
</button>
))}
</div>
{wageBaseType === 'CUSTOM' && (
<div className="mt-2 space-y-1">
<Input type="number" value={customWageBase || ''} onChange={(e) => setCustomWageBase(Number(e.target.value) || 0)} placeholder="自定义月工资基数" />
<Input value={customWageReason} onChange={(e) => setCustomWageReason(e.target.value)} placeholder="自定义原因(必填)" />
</div>
)}
</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-gray-700">¥{fmt(costResult.grandTotal)}</span>
</div>
{compAdjustments.length > 0 && (
<div className="flex items-center justify-between mt-1">
<span className="font-medium text-primary"></span>
<span className="text-lg font-bold text-danger">¥{fmt(adjustedTotal)}</span>
</div>
)}
</div>
{/* 违法解除风险提醒:实际补偿金低于法定2N时需确认 */}
{reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal && (
<div className="border border-danger rounded-md p-3 space-y-2 bg-red-50">
<div className="text-xs font-medium text-danger flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4" />
</div>
<p className="text-xs text-danger">
287 ¥{fmt(adjustedTotal)} ¥{fmt(costResult.grandTotal)} ¥{fmt(costResult.grandTotal - adjustedTotal)}
</p>
<p className="text-xs text-danger"></p>
<label className="flex items-center gap-2 text-xs text-danger cursor-pointer">
<input
type="checkbox"
checked={illegalRiskAcknowledged}
onChange={(e) => setIllegalRiskAcknowledged(e.target.checked)}
/>
<span></span>
</label>
</div>
)}
{/* 剩余年假折算 */}
<div className="border rounded-md p-3 space-y-2 bg-green-50/30">
<div className="text-xs font-medium flex items-center gap-1.5">
<Calculator className="w-3.5 h-3.5" />
</div>
<div className="text-xs text-gray-500">300%5</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<Label></Label>
<Input type="number" min="0" step="0.5" value={annualLeaveDays || ''} onChange={(e) => setAnnualLeaveDays(Number(e.target.value) || 0)} placeholder="0" />
</div>
{annualLeaveDays > 0 && costResult && (
<div className="text-xs text-gray-600">
<div>¥{fmt((costResult.cappedWage || 0) / 21.75)}/</div>
<div className="font-medium text-danger">¥{fmt(annualLeavePay)}</div>
</div>
)}
</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"
/>
{item.key === 'docs_signed' && employeeId && (
<Button
size="sm"
variant="secondary"
onClick={async () => {
try {
await esignApi.create({
employeeId,
documentTitle: '离职协议',
remark: '离职流程中发起',
scene: 'RESIGNATION',
})
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('离职协议电子签署已发起')
} catch {
toast.error('发起签署失败')
}
}}
>
<PenTool className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</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>
{checklistItems?.some(item => item.key === 'notify_union') && (
<div className={checklist['notify_union'] ? 'text-safe' : 'text-danger'}>
{checklist['notify_union'] ? '已通知' : '⚠ 未通知工会(北京地区单方解除必经程序)'}
{checklist['notify_union'] && checklist['union_receipt'] ? ' · 已收到回执' : checklist['notify_union'] ? ' · 未收到回执' : ''}
{unionReceipt?.fileUrl && <a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="ml-1 text-primary hover:underline"></a>}
</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">
{step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
<span className="text-xs text-amber-600"></span>
)}
{step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
<span className="text-xs text-amber-600"></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>
)
}