Files
TurboHR/frontend/src/pages/Compensation.tsx
T
selfrelease d79e3baa34 feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全
- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
2026-07-26 20:32:38 +08:00

365 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } 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">
<div className="flex items-center gap-2">
<Calculator 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>
<div className="flex gap-1 border-b">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm 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="text-sm 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="text-sm 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"> 312</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>116116</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="text-sm 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="text-sm 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>1211</span>
</div>
</div>
) : (
<div className="text-gray-400 text-xs"></div>
)}
</Card>
</div>
)
}