c618710a52
- SocialInsuranceConfig 版本化(effectiveFrom/effectiveTo/isCurrent/adjustmentDone) - 社保配置版本管理接口(列表/新建/按月获取/当前版本) - 员工基数调整:预览全部在职员工、上年月均工资计算建议基数、可编辑表格、确认后批量保存 - payroll.service 按批次月份匹配对应版本社保配置 - risk.service / seed.ts 同步更新 - 前端社保tab重构为版本管理+调整+试算 - 加班费计算三步流程、CSV导入、批次导入 - UI组件、Dashboard、合同、薪酬等页面优化
359 lines
15 KiB
TypeScript
359 lines
15 KiB
TypeScript
import { useState, useMemo } from 'react'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { Calculator, Info, AlertCircle } 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'
|
||
|
||
// 金额格式化:保留两位小数 + 千分位
|
||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
|
||
interface EmployeeOption {
|
||
id: string
|
||
name: string
|
||
department: string
|
||
hireDate: string
|
||
monthlySalary: number
|
||
status: string
|
||
contracts?: any[]
|
||
}
|
||
|
||
function useEmployees() {
|
||
return useQuery<EmployeeOption[]>({
|
||
queryKey: ['roster-for-compensation'],
|
||
queryFn: async () => {
|
||
const res = await api.get('/roster') as any
|
||
return res.data
|
||
},
|
||
})
|
||
}
|
||
|
||
function EmployeeSelector({ employees, selectedId, onSelect }: {
|
||
employees?: EmployeeOption[]
|
||
selectedId: string
|
||
onSelect: (emp: EmployeeOption | null) => void
|
||
}) {
|
||
return (
|
||
<div>
|
||
<Label>选择员工(自动带出入职日期和工资)</Label>
|
||
<Select value={selectedId} onChange={(e) => {
|
||
const emp = employees?.find((x) => x.id === e.target.value)
|
||
onSelect(emp || null)
|
||
}}>
|
||
<option value="">-- 手动输入 --</option>
|
||
{employees?.map((emp) => (
|
||
<option key={emp.id} value={emp.id}>
|
||
{emp.name}({emp.department})
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Compensation() {
|
||
const [tab, setTab] = useState<'severance' | 'double'>('severance')
|
||
|
||
const tabs: { key: typeof tab; label: string }[] = [
|
||
{ key: 'severance', label: '经济补偿金' },
|
||
{ key: 'double', label: '双倍工资' },
|
||
]
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<h1 className="text-xs font-medium">补偿计算</h1>
|
||
|
||
<div className="flex gap-1 border-b">
|
||
{tabs.map((t) => (
|
||
<button
|
||
key={t.key}
|
||
onClick={() => setTab(t.key)}
|
||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === 'severance' && <SeveranceCalculator />}
|
||
{tab === 'double' && <DoubleSalaryCalculator />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SeveranceCalculator() {
|
||
const { data: employees } = useEmployees()
|
||
const [selectedEmpId, setSelectedEmpId] = useState('')
|
||
const [hireDate, setHireDate] = useState('')
|
||
const [leaveDate, setLeaveDate] = useState('')
|
||
const [avgWage, setAvgWage] = useState(8000)
|
||
const [reason, setReason] = useState('negotiated')
|
||
const [socialAvgWage, setSocialAvgWage] = useState(0)
|
||
const [result, setResult] = useState<any>(null)
|
||
|
||
const handleSelectEmp = (emp: EmployeeOption | null) => {
|
||
setSelectedEmpId(emp?.id || '')
|
||
if (emp) {
|
||
setHireDate(emp.hireDate?.toString().slice(0, 10) || '')
|
||
setAvgWage(emp.monthlySalary || 8000)
|
||
}
|
||
}
|
||
|
||
const reasonMap: Record<string, { label: string; multiplier: number; extra: string; illegal: boolean }> = {
|
||
negotiated: { label: '协商一致解除', multiplier: 1, extra: '', illegal: false },
|
||
fault: { label: '员工过错解除', multiplier: 0, extra: '员工过错解除,无需支付经济补偿金', illegal: false },
|
||
nonfault: { label: '非过错解除', multiplier: 1, extra: '额外支付1个月代通知金', illegal: false },
|
||
layoff: { label: '经济性裁员', multiplier: 1, extra: '', illegal: false },
|
||
expired: { label: '合同到期不续签', multiplier: 1, extra: '用人单位不续签或降低条件续签', illegal: false },
|
||
illegal: { label: '违法解除', multiplier: 2, extra: '违法解除劳动合同,按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)', illegal: true },
|
||
}
|
||
|
||
const handleCalculate = () => {
|
||
if (!hireDate || !leaveDate) return
|
||
const hire = new Date(hireDate)
|
||
const leave = new Date(leaveDate)
|
||
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
|
||
|
||
let wage = avgWage
|
||
let capped = false
|
||
if (socialAvgWage > 0 && avgWage > socialAvgWage * 3) {
|
||
wage = socialAvgWage * 3
|
||
compMonths = Math.min(compMonths, 12)
|
||
capped = true
|
||
}
|
||
|
||
const r = reasonMap[reason]
|
||
const basePay = wage * compMonths
|
||
let totalPay = basePay * r.multiplier
|
||
let noticePay = 0
|
||
if (reason === 'nonfault') {
|
||
noticePay = wage
|
||
totalPay += noticePay
|
||
}
|
||
|
||
setResult({ years, remainingMonths, compMonths, wage, totalPay, basePay, totalMonths, capped, reason: r.label, reasonNote: r.extra, noticePay, noComp: r.multiplier === 0, isIllegal: r.illegal })
|
||
}
|
||
|
||
return (
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<Card>
|
||
<h2 className="font-medium mb-4">填写信息</h2>
|
||
<div className="space-y-3">
|
||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||
<div>
|
||
<Label>入职日期</Label>
|
||
<Input type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<Label>离职日期</Label>
|
||
<Input type="date" value={leaveDate} onChange={(e) => setLeaveDate(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<Label>月平均工资(元)</Label>
|
||
<Input type="number" value={avgWage} onChange={(e) => setAvgWage(Number(e.target.value) || 0)} />
|
||
</div>
|
||
<div>
|
||
<Label>离职原因</Label>
|
||
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||
<option value="negotiated">协商一致解除</option>
|
||
<option value="fault">员工过错解除</option>
|
||
<option value="nonfault">非过错解除(额外1个月代通知金)</option>
|
||
<option value="layoff">经济性裁员</option>
|
||
<option value="expired">合同到期不续签</option>
|
||
<option value="illegal">违法解除(赔偿金×2)</option>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>当地社平工资(选填)</Label>
|
||
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
|
||
</div>
|
||
<Button onClick={handleCalculate} disabled={!hireDate || !leaveDate} className="w-full">
|
||
<Calculator className="w-4 h-4 mr-1" /> 计算
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card>
|
||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||
{result ? (
|
||
<div className="space-y-3">
|
||
<div className="text-xs text-gray-500">离职原因:<span className="text-gray-900">{result.reason}</span></div>
|
||
<div className="text-xs text-gray-500">工作年限:<span className="text-gray-900">{result.years}年{result.remainingMonths}个月</span></div>
|
||
{result.noComp ? (
|
||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
|
||
{result.reasonNote}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="text-xs text-gray-500">补偿月数:<span className="text-gray-900">{result.compMonths}个月</span></div>
|
||
{result.capped && (
|
||
<div className="text-xs text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||
)}
|
||
<div className="text-xs text-gray-500">计算基数:<span className="text-gray-900">¥{fmt(result.wage)}/月</span></div>
|
||
<div className="border-t pt-3 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-medium">{result.isIllegal ? '经济补偿金' : '应付金额'}</span>
|
||
<span className="font-medium">¥{fmt(result.basePay)}</span>
|
||
</div>
|
||
{result.isIllegal ? (
|
||
<>
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-medium text-danger">违法解除赔偿金(×2)</span>
|
||
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{fmt(result.wage)} × 2)</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-medium">{result.reason}</span>
|
||
<span className="text-lg font-bold text-primary">¥{fmt(result.totalPay)}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{fmt(result.wage)})
|
||
{result.noticePay > 0 && <span className="block">含代通知金 ¥{fmt(result.noticePay)}</span>}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
{result.reasonNote && (
|
||
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-xs ${result.isIllegal ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}>
|
||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||
<span>{result.reasonNote}</span>
|
||
</div>
|
||
)}
|
||
<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 className="text-gray-400 text-xs">填写信息后点击「计算」按钮</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DoubleSalaryCalculator() {
|
||
const { data: employees } = useEmployees()
|
||
const [selectedEmpId, setSelectedEmpId] = useState('')
|
||
const [monthlyWage, setMonthlyWage] = useState(8000)
|
||
const [hireDate, setHireDate] = useState('')
|
||
const [hasContract, setHasContract] = useState(false)
|
||
const [contractDate, setContractDate] = useState('')
|
||
|
||
const handleSelectEmp = (emp: EmployeeOption | null) => {
|
||
setSelectedEmpId(emp?.id || '')
|
||
if (emp) {
|
||
setHireDate(emp.hireDate?.toString().slice(0, 10) || '')
|
||
setMonthlyWage(emp.monthlySalary || 8000)
|
||
const latestContract = emp.contracts?.find((c: any) => c.signDate)
|
||
if (latestContract) {
|
||
setHasContract(true)
|
||
setContractDate(latestContract.signDate?.toString().slice(0, 10) || '')
|
||
} else {
|
||
setHasContract(false)
|
||
setContractDate('')
|
||
}
|
||
}
|
||
}
|
||
|
||
const result = useMemo(() => {
|
||
if (!hireDate) return null
|
||
const hire = new Date(hireDate)
|
||
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 (hasContract && contractDate) {
|
||
const contract = new Date(contractDate)
|
||
const daysDiff = Math.floor((contract.getTime() - hire.getTime()) / (1000 * 60 * 60 * 24))
|
||
if (daysDiff > 30) {
|
||
endDate = contract
|
||
}
|
||
}
|
||
|
||
const months = Math.min(
|
||
Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)),
|
||
11,
|
||
)
|
||
const totalPay = monthlyWage * Math.max(months, 0)
|
||
|
||
return { startDate, endDate, months: Math.max(months, 0), totalPay }
|
||
}, [monthlyWage, hireDate, hasContract, contractDate])
|
||
|
||
return (
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<Card>
|
||
<h2 className="font-medium mb-4">填写信息</h2>
|
||
<div className="space-y-3">
|
||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||
<div>
|
||
<Label>月工资(元)</Label>
|
||
<Input type="number" value={monthlyWage} onChange={(e) => setMonthlyWage(Number(e.target.value) || 0)} />
|
||
</div>
|
||
<div>
|
||
<Label>入职日期</Label>
|
||
<Input type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<Label>合同签订情况</Label>
|
||
<Select value={hasContract ? 'yes' : 'no'} onChange={(e) => setHasContract(e.target.value === 'yes')}>
|
||
<option value="no">未签订</option>
|
||
<option value="yes">已签订</option>
|
||
</Select>
|
||
</div>
|
||
{hasContract && (
|
||
<div>
|
||
<Label>合同签订日期</Label>
|
||
<Input type="date" value={contractDate} onChange={(e) => setContractDate(e.target.value)} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
<Card>
|
||
<h2 className="font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" />计算结果</h2>
|
||
{result ? (
|
||
<div className="space-y-3">
|
||
<div className="text-xs text-gray-500">入职日期:<span className="text-gray-900">{hireDate}</span></div>
|
||
<div className="text-xs text-gray-500">合同签订:<span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
|
||
<div className="text-xs text-gray-500">双倍工资起算:<span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
|
||
<div className="text-xs text-gray-500">双倍工资截止:<span className="text-gray-900">{result.endDate.toISOString().slice(0, 10)}</span></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(result.totalPay)}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-400 mt-1">({result.months}个月 × ¥{fmt(monthlyWage)})</div>
|
||
</div>
|
||
<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个月没签合同,从第2个月起要付双倍工资,最多11个月</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-gray-400 text-xs">请填写入职日期</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|