7e720d9bfc
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>
2441 lines
124 KiB
TypeScript
2441 lines
124 KiB
TypeScript
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">⚠️ 工资超社平3倍,按三倍封顶且最多12个月</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">
|
||
违法解除赔偿金 = 经济补偿金 × 2(《劳动合同法》第87条)
|
||
</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">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</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>违法解除劳动合同,按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)</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>入职1个月未签合同,从第2个月起需付双倍工资,最多11个月</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">
|
||
违法解除劳动合同应按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)。当前实际补偿金 ¥{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>满1年补1个月,满6个月不满1年按1年算,不满6个月补半个月</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">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</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>
|
||
)
|
||
}
|