4f125d309b
- Schema: 拆分社保/公积金配置,新增EmployeeSocialInsRecord/EmployeeHousingFundRecord/DepartmentRecord模型,扩展SalaryChangeRecord,增加SEVERANCE批次类型 - 后端: createEmployee/rehireEmployee接收社保公积金字段并创建缴费记录版本;createTermination/createResignation接收截止年月并关闭缴费记录;调薪/调部门API+版本记录;月度增减员API;公积金独立CRUD/计算/调基;SEVERANCE批次calcBatchEntry - 前端: AddEmployeeModal/RehireModal增加社保公积金输入;ResignModal/Termination增加截止年月+日期不一致提醒;花名册增加调薪/调部门弹窗;SocialInsurance.tsx Tab拆分(社保/公积金/月度增减员)+CSV导出;Money.tsx增加补偿金批次类型 - 修复: seed.ts移除housingOrg/housingEmp;risk.service.ts从HousingFundConfig获取公积金费率
989 lines
46 KiB
TypeScript
989 lines
46 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List } from 'lucide-react'
|
||
import api from '../lib/api'
|
||
import Card from '../components/ui/Card'
|
||
import Button from '../components/ui/Button'
|
||
import { Input, Label, Select } from '../components/ui/Input'
|
||
import Signal from '../components/ui/Signal'
|
||
|
||
// 金额格式化:保留两位小数 + 千分位
|
||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
|
||
const REASONS = [
|
||
{ value: 'NEGOTIATED', label: '协商解除(双方同意分开了)', legalBasis: '《劳动合同法》第36条' },
|
||
{ value: 'FAULT', label: '员工犯错被辞退(严重违纪/失职等)', legalBasis: '《劳动合同法》第39条' },
|
||
{ value: 'NONFAULT', label: '员工没犯错但干不了(生病/不胜任等)', legalBasis: '《劳动合同法》第40条' },
|
||
{ value: 'LAYOFF', label: '公司裁员(经营困难/技术调整等)', legalBasis: '《劳动合同法》第41条' },
|
||
{ value: 'EXPIRED', label: '合同到期不续签', legalBasis: '《劳动合同法》第44条、第46条' },
|
||
{ value: 'ILLEGAL', label: '违法解除(赔偿金×2)', legalBasis: '《劳动合同法》第87条' },
|
||
]
|
||
|
||
const STEPS = ['选择员工', '解聘方式', '合规检查', '费用结算', '确认完成']
|
||
|
||
interface RosterEmployee {
|
||
id: string
|
||
name: string
|
||
department: string
|
||
status: string
|
||
hasTermination?: boolean
|
||
hireDate: string
|
||
monthlySalary: number
|
||
latestContract: any
|
||
counts: any
|
||
}
|
||
|
||
interface EmployeeProfile {
|
||
id: string
|
||
name: string
|
||
department: string
|
||
status: string
|
||
hireDate: string
|
||
monthlySalary: number
|
||
isPregnant: boolean
|
||
isInMedicalPeriod: boolean
|
||
isWorkInjured: boolean
|
||
contracts: any[]
|
||
disciplinaryRecords: any[]
|
||
attendanceRecords: any[]
|
||
performanceRecords: any[]
|
||
trainingRecords: any[]
|
||
}
|
||
|
||
export default function Termination() {
|
||
const queryClient = useQueryClient()
|
||
const [step, setStep] = useState(0)
|
||
const [reason, setReason] = useState('')
|
||
const [employeeId, setEmployeeId] = useState('')
|
||
const [terminationDate, setTerminationDate] = useState('')
|
||
const [socialInsEndMonth, setSocialInsEndMonth] = useState('')
|
||
const [housingFundEndMonth, setHousingFundEndMonth] = useState('')
|
||
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
|
||
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
|
||
const [socialAvgWage, setSocialAvgWage] = useState(0)
|
||
const [savedItems, setSavedItems] = useState<Array<{
|
||
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
|
||
}>>([])
|
||
|
||
const { data: employees } = useQuery<RosterEmployee[]>({
|
||
queryKey: ['roster-for-termination'],
|
||
queryFn: async () => {
|
||
const res = await api.get('/roster') as any
|
||
return res.data
|
||
},
|
||
})
|
||
|
||
const selectedEmployee = employees?.find((e) => e.id === employeeId)
|
||
|
||
const { data: profile } = useQuery<EmployeeProfile>({
|
||
queryKey: ['employee-profile', employeeId],
|
||
queryFn: async () => {
|
||
const res = await api.get(`/roster/${employeeId}/profile`) as any
|
||
return res.data
|
||
},
|
||
enabled: !!employeeId,
|
||
})
|
||
|
||
// 根据员工数据生成解聘建议
|
||
const suggestions = useMemo(() => {
|
||
if (!profile) return []
|
||
const list: { reason: string; label: string; why: string }[] = []
|
||
|
||
// 有违纪记录 → 建议过错解除
|
||
if (profile.disciplinaryRecords?.length > 0) {
|
||
const severe = profile.disciplinaryRecords.filter((d) => d.action === 'TERMINATION' || d.type === 'INSUBORDINATION' || d.type === 'MISCONDUCT')
|
||
if (severe.length > 0) {
|
||
list.push({ reason: 'FAULT', label: '过错解除', why: `有${severe.length}条严重违纪记录,可依据规章制度解除` })
|
||
} else {
|
||
list.push({ reason: 'FAULT', label: '过错解除', why: `有${profile.disciplinaryRecords.length}条违纪记录,可考虑过错解除` })
|
||
}
|
||
}
|
||
|
||
// 绩效不佳 → 建议非过错解除
|
||
const badPerf = profile.performanceRecords?.filter((p) => p.result === 'NEED_IMPROVE' || p.result === 'UNQUALIFIED')
|
||
if (badPerf?.length > 0) {
|
||
const hasTraining = profile.trainingRecords?.length > 0
|
||
list.push({
|
||
reason: 'NONFAULT',
|
||
label: '非过错解除',
|
||
why: hasTraining
|
||
? `有${badPerf.length}次绩效不佳且已培训/调岗,可按不胜任解除`
|
||
: `有${badPerf.length}次绩效不佳,需先培训或调岗后才能按不胜任解除`,
|
||
})
|
||
}
|
||
|
||
// 合同到期 → 建议不续签
|
||
const latestContract = profile.contracts?.[0]
|
||
if (latestContract?.endDate) {
|
||
const daysToExpire = Math.floor((new Date(latestContract.endDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
|
||
if (daysToExpire <= 30 && daysToExpire >= -90) {
|
||
list.push({ reason: 'EXPIRED', label: '合同到期不续签', why: `合同将于${latestContract.endDate.slice(0, 10)}到期,可选择不续签` })
|
||
}
|
||
}
|
||
|
||
// 未签合同 → 提示双倍工资风险
|
||
if (!latestContract?.signDate || latestContract?.contractType === 'UNSIGNED') {
|
||
const days = Math.floor((new Date().getTime() - new Date(profile.hireDate).getTime()) / (1000 * 60 * 60 * 24))
|
||
if (days > 30) {
|
||
list.push({ reason: 'NEGOTIATED', label: '协商解除', why: `未签合同已${days}天,协商解除可同时解决双倍工资问题` })
|
||
}
|
||
}
|
||
|
||
// 孕期/哺乳期/工伤 → 风险提示
|
||
if (profile.isPregnant) list.push({ reason: '', label: '⚠️ 孕期禁止解除', why: '该员工在孕期/哺乳期,法律禁止以非过错理由解除' })
|
||
if (profile.isWorkInjured) list.push({ reason: '', label: '⚠️ 工伤期间禁止解除', why: '工伤期间不得解除劳动合同' })
|
||
if (profile.isInMedicalPeriod) list.push({ reason: '', label: '⚠️ 医疗期保护', why: '医疗期内不得以非过错理由解除' })
|
||
|
||
// 默认推荐协商解除
|
||
if (list.length === 0 || !list.some((s) => s.reason !== '')) {
|
||
list.push({ reason: 'NEGOTIATED', label: '协商解除', why: '无特殊风险因素,推荐协商解除,成本最低、风险最小' })
|
||
}
|
||
|
||
return list
|
||
}, [profile])
|
||
|
||
const { data: checklistItems } = useQuery<{
|
||
key: string; label: string; autoChecked?: boolean | null; autoSource?: string; suggestion?: string; suggestionType?: string
|
||
}[]>({
|
||
queryKey: ['checklist', reason, employeeId],
|
||
queryFn: async () => {
|
||
const res = await api.get(`/termination/checklist/${reason}`, { params: { employeeId } }) as any
|
||
return res.data
|
||
},
|
||
enabled: !!reason && !!employeeId && step >= 2,
|
||
})
|
||
|
||
// checklist 加载后自动预填系统判断结果
|
||
useEffect(() => {
|
||
if (checklistItems) {
|
||
const prefilled: Record<string, boolean> = {}
|
||
checklistItems.forEach((item) => {
|
||
if (item.autoChecked === true) prefilled[item.key] = true
|
||
else if (item.autoChecked === false) prefilled[item.key] = false
|
||
})
|
||
setChecklist(prefilled)
|
||
}
|
||
}, [checklistItems])
|
||
|
||
const { data: riskAssessment } = useQuery<{ level: string; warnings: string[] }>({
|
||
queryKey: ['assess', employeeId, reason],
|
||
queryFn: async () => {
|
||
const res = await api.get(`/termination/assess/${employeeId}`, { params: { reason } }) as any
|
||
return res.data
|
||
},
|
||
enabled: !!employeeId && !!reason && step >= 1,
|
||
})
|
||
|
||
const saveMutation = useMutation({
|
||
mutationFn: (data: any) => api.post('/termination', data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||
setStep(4)
|
||
},
|
||
})
|
||
|
||
const { data: evidenceChain } = useQuery({
|
||
queryKey: ['evidence-chain', employeeId],
|
||
queryFn: async () => {
|
||
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
|
||
return res.data
|
||
},
|
||
enabled: !!employeeId && step === 4 && saveMutation.isSuccess,
|
||
})
|
||
|
||
const reasonLabel = REASONS.find((r) => r.value === reason)?.label || ''
|
||
const reasonLegalBasis = REASONS.find((r) => r.value === reason)?.legalBasis || ''
|
||
|
||
const costResult = useMemo(() => {
|
||
if (!selectedEmployee || !terminationDate) return null
|
||
const hire = new Date(selectedEmployee.hireDate)
|
||
const leave = new Date(terminationDate)
|
||
const totalMonths = (leave.getFullYear() - hire.getFullYear()) * 12 + (leave.getMonth() - hire.getMonth())
|
||
const years = Math.floor(totalMonths / 12)
|
||
const remainingMonths = totalMonths % 12
|
||
let compMonths: number
|
||
if (remainingMonths >= 6) compMonths = years + 1
|
||
else if (remainingMonths > 0) compMonths = years + 0.5
|
||
else compMonths = years
|
||
if (compMonths <= 0) compMonths = 0.5
|
||
|
||
const wage = selectedEmployee.monthlySalary || 0
|
||
let capped = false
|
||
let cappedWage = wage
|
||
let cappedMonths = compMonths
|
||
if (socialAvgWage > 0 && wage > socialAvgWage * 3) {
|
||
cappedWage = socialAvgWage * 3
|
||
cappedMonths = Math.min(compMonths, 12)
|
||
capped = true
|
||
}
|
||
|
||
const reasonMap: Record<string, { multiplier: number; notice: boolean }> = {
|
||
NEGOTIATED: { multiplier: 1, notice: false },
|
||
FAULT: { multiplier: 0, notice: false },
|
||
NONFAULT: { multiplier: 1, notice: true },
|
||
LAYOFF: { multiplier: 1, notice: false },
|
||
EXPIRED: { multiplier: 1, notice: false },
|
||
ILLEGAL: { multiplier: 2, notice: false },
|
||
}
|
||
const r = reasonMap[reason] || { multiplier: 1, notice: false }
|
||
const basePay = cappedWage * cappedMonths
|
||
const severancePay = basePay * r.multiplier
|
||
const noticePay = r.notice ? cappedWage : 0
|
||
const totalSeverance = severancePay + noticePay
|
||
|
||
// 双倍工资计算(未签合同)
|
||
const contract = selectedEmployee.latestContract
|
||
const hasContract = contract && contract.signDate && contract.contractType !== 'UNSIGNED'
|
||
let doublePay = 0
|
||
let doubleMonths = 0
|
||
let doubleStartDate = ''
|
||
let doubleEndDate = ''
|
||
if (!hasContract) {
|
||
const startDate = new Date(hire)
|
||
startDate.setMonth(startDate.getMonth() + 1)
|
||
startDate.setDate(startDate.getDate() + 1)
|
||
let endDate = new Date(hire)
|
||
endDate.setFullYear(endDate.getFullYear() + 1)
|
||
if (leave < endDate) endDate = leave
|
||
doubleMonths = Math.min(
|
||
Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)),
|
||
11,
|
||
)
|
||
doubleMonths = Math.max(doubleMonths, 0)
|
||
doublePay = wage * doubleMonths
|
||
doubleStartDate = startDate.toISOString().slice(0, 10)
|
||
doubleEndDate = endDate.toISOString().slice(0, 10)
|
||
}
|
||
|
||
return {
|
||
years, remainingMonths, compMonths, wage, cappedWage, cappedMonths, capped,
|
||
basePay, severancePay, noticePay, totalSeverance,
|
||
doublePay, doubleMonths, doubleStartDate, doubleEndDate, hasContract,
|
||
noComp: r.multiplier === 0,
|
||
isIllegal: r.multiplier === 2,
|
||
grandTotal: totalSeverance + doublePay,
|
||
}
|
||
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
|
||
|
||
const canProceed = () => {
|
||
if (step === 0) return !!employeeId && !selectedEmployee?.hasTermination
|
||
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
|
||
if (step === 2) return true
|
||
if (step === 3) return true
|
||
return false
|
||
}
|
||
|
||
const handleSave = () => {
|
||
saveMutation.mutate({
|
||
employeeId,
|
||
reason,
|
||
terminationDate: new Date(terminationDate).toISOString(),
|
||
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
|
||
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
|
||
compensation: costResult?.totalSeverance || 0,
|
||
checklist,
|
||
remark: '',
|
||
})
|
||
}
|
||
|
||
// 模拟计算:只计算并暂存,不实际保存解聘记录
|
||
const handleSimulate = () => {
|
||
if (!costResult || !selectedEmployee) return
|
||
setSavedItems((prev) => {
|
||
if (prev.some((item) => item.employeeId === employeeId)) {
|
||
return prev.map((item) =>
|
||
item.employeeId === employeeId
|
||
? {
|
||
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,
|
||
}
|
||
: item
|
||
)
|
||
}
|
||
return [
|
||
...prev,
|
||
{
|
||
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,
|
||
},
|
||
]
|
||
})
|
||
handleReset()
|
||
}
|
||
|
||
// 保存成功后暂存到右侧列表
|
||
useEffect(() => {
|
||
if (saveMutation.isSuccess && costResult && selectedEmployee) {
|
||
setSavedItems((prev) => {
|
||
if (prev.some((item) => item.employeeId === employeeId)) {
|
||
return prev.map((item) =>
|
||
item.employeeId === employeeId
|
||
? {
|
||
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,
|
||
}
|
||
: item
|
||
)
|
||
}
|
||
return [
|
||
...prev,
|
||
{
|
||
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,
|
||
},
|
||
]
|
||
})
|
||
}
|
||
}, [saveMutation.isSuccess])
|
||
|
||
const handleReset = () => {
|
||
setStep(0)
|
||
setReason('')
|
||
setEmployeeId('')
|
||
setTerminationDate('')
|
||
setSocialInsEndMonth('')
|
||
setHousingFundEndMonth('')
|
||
setChecklist({})
|
||
setAcknowledgeRisk(false)
|
||
}
|
||
|
||
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">
|
||
<h1 className="text-xs font-medium">解聘补偿</h1>
|
||
|
||
<div className="flex gap-4">
|
||
{/* 左侧:向导 */}
|
||
<div className="flex-1 min-w-0">
|
||
|
||
{/* 进度条 */}
|
||
<div className="flex items-center gap-1">
|
||
{STEPS.map((s, i) => (
|
||
<div key={i} className="flex items-center">
|
||
<div className={`w-2.5 h-2.5 rounded-full ${i <= step ? 'bg-primary' : 'bg-gray-300'}`} />
|
||
{i < STEPS.length - 1 && <div className={`w-8 h-0.5 ${i < step ? 'bg-primary' : 'bg-gray-300'}`} />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Card>
|
||
<div className="mb-2 text-xs text-gray-500">Step {step + 1}/5:{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 && (
|
||
<div className="text-danger font-medium mt-1">⚠️ 该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣</div>
|
||
)}
|
||
{selectedEmployee.latestContract ? (
|
||
<div>合同状态:{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}</div>
|
||
) : (
|
||
<div className="text-warning">⚠️ 无合同记录</div>
|
||
)}
|
||
{selectedEmployee.counts && (
|
||
<div className="flex gap-3 flex-wrap mt-2">
|
||
{selectedEmployee.counts.disciplinaryRecords > 0 && (
|
||
<span className="text-danger">违纪记录:{selectedEmployee.counts.disciplinaryRecords}条</span>
|
||
)}
|
||
{selectedEmployee.counts.performanceRecords > 0 && (
|
||
<span>绩效记录:{selectedEmployee.counts.performanceRecords}条</span>
|
||
)}
|
||
{selectedEmployee.counts.attendanceRecords > 0 && (
|
||
<span>考勤记录:{selectedEmployee.counts.attendanceRecords}条</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{profile && suggestions.length > 0 && (
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium">📋 解聘方式建议</div>
|
||
{suggestions.map((s, i) => (
|
||
<div
|
||
key={i}
|
||
className={`px-3 py-2 rounded-md text-xs ${s.reason === '' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}
|
||
>
|
||
<div className="font-medium">{s.label}</div>
|
||
<div className="text-xs mt-0.5">{s.why}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{employeeId && !profile && (
|
||
<div className="text-xs text-gray-400">加载员工档案中...</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 2: 解聘方式 */}
|
||
{step === 1 && (
|
||
<div className="space-y-3">
|
||
{suggestions.length > 0 && (
|
||
<div className="bg-blue-50 rounded-md p-3 space-y-1">
|
||
<div className="text-xs font-medium text-blue-700">💡 系统建议</div>
|
||
{suggestions.filter((s) => s.reason).map((s, i) => (
|
||
<div key={i} className="text-xs text-blue-600">
|
||
{s.label}:{s.why}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
{REASONS.map((r) => {
|
||
const suggested = suggestions.find((s) => s.reason === r.value)
|
||
return (
|
||
<label
|
||
key={r.value}
|
||
className={`flex items-start gap-3 p-3 rounded-md border cursor-pointer hover:bg-gray-50 ${suggested ? 'border-primary bg-primary/5' : ''}`}
|
||
>
|
||
<input type="radio" name="reason" value={r.value} checked={reason === r.value} onChange={(e) => setReason(e.target.value)} className="mt-0.5" />
|
||
<div className="flex-1">
|
||
<div className="text-xs flex items-center gap-2">
|
||
{r.label}
|
||
{suggested && <span className="text-xs text-primary font-medium">推荐</span>}
|
||
</div>
|
||
{suggested && (
|
||
<div className="text-xs text-gray-500 mt-0.5">{suggested.why}</div>
|
||
)}
|
||
</div>
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
<div>
|
||
<Label>解聘日期</Label>
|
||
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
|
||
</div>
|
||
<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) => (
|
||
<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>
|
||
))}
|
||
<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">
|
||
<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>
|
||
{checklistItems?.map((item) => {
|
||
const checked = checklist[item.key] || false
|
||
const isAutoChecked = item.autoChecked !== null && item.autoChecked !== undefined
|
||
const suggestionColor =
|
||
item.suggestionType === 'warning' ? 'bg-amber-50 text-amber-700' :
|
||
item.suggestionType === 'required' ? 'bg-red-50 text-red-700' :
|
||
'bg-gray-50 text-gray-600'
|
||
return (
|
||
<div key={item.key} className="border rounded-md p-3 space-y-2">
|
||
<label className="flex items-center gap-3 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={(e) => setChecklist({ ...checklist, [item.key]: e.target.checked })}
|
||
/>
|
||
<span className="text-xs font-medium">{item.label}</span>
|
||
{isAutoChecked && (
|
||
<span className={`px-1.5 py-0.5 rounded text-xs ${item.autoChecked ? 'bg-green-50 text-safe' : 'bg-red-50 text-danger'}`}>
|
||
系统判断:{item.autoChecked ? '是' : '否'}
|
||
</span>
|
||
)}
|
||
</label>
|
||
{item.autoSource && (
|
||
<div className="text-xs text-gray-500 pl-7 flex items-start gap-1">
|
||
<Info className="w-3 h-3 mt-0.5 shrink-0" />
|
||
<span>{item.autoSource}</span>
|
||
</div>
|
||
)}
|
||
{item.suggestion && (
|
||
<div className={`text-xs px-2 py-1.5 rounded-md ml-7 flex items-start gap-1 ${suggestionColor}`}>
|
||
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
|
||
<span>{item.suggestion}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 4: 费用结算 */}
|
||
{step === 3 && (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>当地社平工资(选填)</Label>
|
||
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
|
||
</div>
|
||
{costResult && (
|
||
<div className="space-y-3">
|
||
{/* 员工概况 */}
|
||
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||
<div className="font-medium">{selectedEmployee?.name}({selectedEmployee?.department})</div>
|
||
<div>工作年限:{costResult.years}年{costResult.remainingMonths}个月</div>
|
||
<div>月工资:¥{fmt(costResult.wage)}/月</div>
|
||
{costResult.capped && (
|
||
<div className="text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</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-danger">¥{fmt(costResult.grandTotal)}</span>
|
||
</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">
|
||
{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.error as any)?.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 < 4 && (
|
||
<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 < 3 ? (
|
||
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
|
||
下一步<ChevronRight className="w-4 h-4 ml-1" />
|
||
</Button>
|
||
) : (
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" onClick={handleSimulate}>
|
||
<Calculator className="w-4 h-4 mr-1" />模拟计算
|
||
</Button>
|
||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||
<Shield className="w-4 h-4 mr-1" />确认保存
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</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-xs font-medium flex items-center gap-1.5">
|
||
<List className="w-4 h-4" />已计算列表
|
||
</h2>
|
||
{savedItems.length > 0 && (
|
||
<Button variant="secondary" size="sm" onClick={() => setSavedItems([])}>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{savedItems.length === 0 ? (
|
||
<div className="text-center py-6 text-gray-400 text-xs">
|
||
计算完一人后<br />暂存结果将显示在此
|
||
</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>
|
||
)
|
||
}
|