init: AI HR Compliance Assistant
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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'
|
||||
|
||||
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-4">
|
||||
<h1 className="text-lg font-semibold">补偿计算</h1>
|
||||
|
||||
<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="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-4">
|
||||
<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-sm text-gray-500">离职原因:<span className="text-gray-900">{result.reason}</span></div>
|
||||
<div className="text-sm 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-sm">
|
||||
{result.reasonNote}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-gray-500">补偿月数:<span className="text-gray-900">{result.compMonths}个月</span></div>
|
||||
{result.capped && (
|
||||
<div className="text-sm text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">计算基数:<span className="text-gray-900">¥{result.wage.toLocaleString()}/月</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">¥{result.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
{result.isIllegal ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-danger">违法解除赔偿金(×2)</span>
|
||||
<span className="text-xl font-bold text-danger">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{result.wage.toLocaleString()} × 2)</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.reason}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{result.wage.toLocaleString()})
|
||||
{result.noticePay > 0 && <span className="block">含代通知金 ¥{result.noticePay.toLocaleString()}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{result.reasonNote && (
|
||||
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-sm ${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-sm">
|
||||
<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-sm">填写信息后点击「计算」按钮</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-4">
|
||||
<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-sm text-gray-500">入职日期:<span className="text-gray-900">{hireDate}</span></div>
|
||||
<div className="text-sm text-gray-500">合同签订:<span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
|
||||
<div className="text-sm text-gray-500">双倍工资起算:<span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="text-sm 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-xl font-bold text-danger">¥{result.totalPay.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">({result.months}个月 × ¥{monthlyWage.toLocaleString()})</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>法律规定:入职1个月没签合同,从第2个月起要付双倍工资,最多11个月</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm">请填写入职日期</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user