init: AI HR Compliance Assistant

This commit is contained in:
freedakgmail
2026-07-23 12:34:43 +08:00
commit 820579e98d
81 changed files with 19327 additions and 0 deletions
+604
View File
@@ -0,0 +1,604 @@
import { useState, useMemo, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Calculator, AlertCircle, Info, Save, Check, Upload, Zap, Bell } 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'
type Tab = 'overtime' | 'social' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('overtime')
const tabs: { key: Tab; label: string }[] = [
{ key: 'overtime', label: '加班费计算' },
{ key: 'social', label: '社保公积金' },
{ key: 'payslip', 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 === 'overtime' && <OvertimeCalculator />}
{tab === 'social' && <SocialInsuranceCalculator />}
{tab === 'payslip' && <PayslipManager />}
</div>
)
}
function OvertimeCalculator() {
const queryClient = useQueryClient()
const [monthlyWage, setMonthlyWage] = useState(8000)
const [weekdayHours, setWeekdayHours] = useState(0)
const [weekendHours, setWeekendHours] = useState(0)
const [holidayHours, setHolidayHours] = useState(0)
const [selectedEmployee, setSelectedEmployee] = useState('')
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const fileInputRef = useRef<HTMLInputElement>(null)
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string; monthlySalary: number }[] }>({
queryKey: ['employees-for-overtime'],
queryFn: async () => {
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
return res.data
},
})
const saveMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll/overtime', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
alert('加班费记录已保存')
},
})
const batchImportMutation = useMutation({
mutationFn: (data: any[]) => api.post('/payroll/overtime/batch', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
alert('批量导入成功')
},
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
const lines = text.split('\n').filter(l => l.trim())
const items: any[] = []
const empList = employees?.items || []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map(c => c.trim())
const empName = cols[0]
const emp = empList.find(e => e.name === empName)
if (!emp) continue
const salary = Number(emp.monthlySalary) || 8000
items.push({
employeeId: emp.id,
month: cols[4] || month,
monthlyWage: salary,
weekdayHours: Number(cols[1]) || 0,
weekendHours: Number(cols[2]) || 0,
holidayHours: Number(cols[3]) || 0,
})
}
if (items.length > 0) {
batchImportMutation.mutate(items)
} else {
alert('未匹配到员工,请确保CSV第一列为员工姓名')
}
}
reader.readAsText(file)
}
const result = useMemo(() => {
const hourlyWage = monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * weekdayHours
const weekendPay = hourlyWage * 2.0 * weekendHours
const holidayPay = hourlyWage * 3.0 * holidayHours
const total = weekdayPay + weekendPay + holidayPay
const totalHours = weekdayHours + weekendHours + holidayHours
return { hourlyWage, weekdayPay, weekendPay, holidayPay, total, totalHours }
}, [monthlyWage, weekdayHours, weekendHours, holidayHours])
return (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4">
<div>
<Label></Label>
<Select value={selectedEmployee} onChange={(e) => {
setSelectedEmployee(e.target.value)
const emp = employees?.items.find((x) => x.id === e.target.value)
if (emp) {
const salary = Number(emp.monthlySalary) || 8000
setMonthlyWage(salary)
}
}}>
<option value=""></option>
{employees?.items.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="number" value={monthlyWage} onChange={(e) => setMonthlyWage(Number(e.target.value) || 0)} />
</div>
<div>
<Label></Label>
<Input type="number" value={weekdayHours} onChange={(e) => setWeekdayHours(Number(e.target.value) || 0)} />
</div>
<div>
<Label></Label>
<Input type="number" value={weekendHours} onChange={(e) => setWeekendHours(Number(e.target.value) || 0)} />
</div>
<div>
<Label></Label>
<Input type="number" value={holidayHours} onChange={(e) => setHolidayHours(Number(e.target.value) || 0)} />
</div>
</div>
</Card>
<Card>
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
<div className="space-y-3">
<div className="text-sm text-gray-500"><span className="text-gray-900 font-medium">¥{result.hourlyWage.toFixed(2)}</span></div>
<div className="space-y-2">
<ResultRow label={`工作日 ${weekdayHours}h × 1.5`} value={result.weekdayPay} />
<ResultRow label={`休息日 ${weekendHours}h × 2.0`} value={result.weekendPay} />
<ResultRow label={`节假日 ${holidayHours}h × 3.0`} value={result.holidayPay} />
</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-primary">¥{result.total.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
{result.totalHours > 36 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
<AlertCircle className="w-4 h-4" />
{result.totalHours}36
</div>
)}
{result.totalHours > 0 && result.totalHours <= 36 && (
<div className="text-sm text-safe">{result.totalHours}36 </div>
)}
{selectedEmployee && (
<Button
className="w-full"
onClick={() => saveMutation.mutate({
employeeId: selectedEmployee,
month,
monthlyWage,
weekdayHours,
weekendHours,
holidayHours,
})}
disabled={saveMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saveMutation.isPending ? '保存中...' : '保存加班费记录'}
</Button>
)}
<div className="border-t pt-3">
<input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
<Button
variant="secondary"
className="w-full"
onClick={() => fileInputRef.current?.click()}
disabled={batchImportMutation.isPending}
>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '批量导入加班数据(CSV'}
</Button>
<div className="text-xs text-gray-400 mt-1">
CSV格式,,,,
</div>
</div>
</div>
</Card>
</div>
)
}
function ResultRow({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">{label}</span>
<span className="font-medium">¥{value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
)
}
function PayslipManager() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({
employeeId: '',
baseSalary: 8000,
allowance: 0,
deduction: 0,
})
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
queryKey: ['employees-for-payslip'],
queryFn: async () => {
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
return res.data
},
})
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month],
queryFn: async () => {
const res = await api.get('/payroll/payslip', { params: { month } }) as any
return res.data
},
})
const generateMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll/payslip/generate', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payslips'] })
setShowCreate(false)
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/payroll/payslip/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }),
})
const batchGenerateMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll/payslip/batch-generate', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payslips'] })
alert('批量生成完成')
},
})
const confirmedCount = payslips?.filter((p: any) => p.confirmedAt).length || 0
const unconfirmedCount = payslips ? payslips.length - confirmedCount : 0
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3 flex-wrap">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-40" />
{payslips && payslips.length > 0 && (
<div className="flex gap-2 text-sm">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning"> {unconfirmedCount}</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button onClick={() => setShowCreate(!showCreate)}></Button>
<Button
variant="secondary"
onClick={() => batchGenerateMutation.mutate({ month })}
disabled={batchGenerateMutation.isPending}
>
<Zap className="w-4 h-4 mr-1" />
{batchGenerateMutation.isPending ? '生成中...' : '一键全员生成'}
</Button>
</div>
</div>
{showCreate && (
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label></Label>
<Select value={createForm.employeeId} onChange={(e) => setCreateForm({ ...createForm, employeeId: e.target.value })}>
<option value=""></option>
{employees?.items.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.baseSalary} onChange={(e) => setCreateForm({ ...createForm, baseSalary: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.allowance} onChange={(e) => setCreateForm({ ...createForm, allowance: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.deduction} onChange={(e) => setCreateForm({ ...createForm, deduction: Number(e.target.value) || 0 })} />
</div>
</div>
<div className="mt-4 flex gap-2">
<Button
onClick={() => generateMutation.mutate({
employeeId: createForm.employeeId,
month,
baseSalary: createForm.baseSalary,
allowance: createForm.allowance,
deduction: createForm.deduction,
})}
disabled={!createForm.employeeId || generateMutation.isPending}
>
{generateMutation.isPending ? '生成中...' : '确认生成(自动关联加班费)'}
</Button>
<Button variant="secondary" onClick={() => setShowCreate(false)}></Button>
</div>
</Card>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !payslips || payslips.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2"></th>
<th className="py-2"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{payslips.map((p: any) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.employee?.name}</td>
<td className="py-2 text-gray-500">{p.employee?.department}</td>
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '¥0'}</td>
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-center">
{p.confirmedAt ? (
<span className="inline-flex items-center gap-1 text-safe text-xs">
<Check className="w-3 h-3" />
</span>
) : (
<span className="text-warning text-xs"></span>
)}
</td>
<td className="py-2">
<button
onClick={() => deleteMutation.mutate(p.id)}
className="text-xs text-gray-400 hover:text-danger"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
)
}
function SocialInsuranceCalculator() {
const queryClient = useQueryClient()
const [base, setBase] = useState(8000)
const [showConfig, setShowConfig] = useState(false)
const [configForm, setConfigForm] = useState<any>({})
const { data: config } = useQuery<any>({
queryKey: ['social-config'],
queryFn: async () => {
const res = await api.get('/social/config') as any
return res.data
},
})
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/calculate', { base }) as any
return res.data
},
})
const updateConfigMutation = useMutation({
mutationFn: (data: any) => api.put('/social/config', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['social-config'] }),
})
useMemo(() => {
if (config) setConfigForm(config)
}, [config])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-medium"></h2>
<Button variant="secondary" size="sm" onClick={() => setShowConfig(!showConfig)}>
{showConfig ? '收起配置' : '配置比例'}
</Button>
</div>
{showConfig && (
<Card>
<h3 className="font-medium mb-4 text-sm">{config?.city || '北京'}</h3>
<div className="grid md:grid-cols-3 gap-3 text-sm">
<div>
<Label></Label>
<Input value={configForm.city || ''} onChange={(e) => setConfigForm({ ...configForm, city: e.target.value })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.pensionOrg || 0} onChange={(e) => setConfigForm({ ...configForm, pensionOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.pensionEmp || 0} onChange={(e) => setConfigForm({ ...configForm, pensionEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.medicalOrg || 0} onChange={(e) => setConfigForm({ ...configForm, medicalOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.medicalEmp || 0} onChange={(e) => setConfigForm({ ...configForm, medicalEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.unemploymentOrg || 0} onChange={(e) => setConfigForm({ ...configForm, unemploymentOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.unemploymentEmp || 0} onChange={(e) => setConfigForm({ ...configForm, unemploymentEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.injuryOrg || 0} onChange={(e) => setConfigForm({ ...configForm, injuryOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.maternityOrg || 0} onChange={(e) => setConfigForm({ ...configForm, maternityOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.housingOrg || 0} onChange={(e) => setConfigForm({ ...configForm, housingOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.housingEmp || 0} onChange={(e) => setConfigForm({ ...configForm, housingEmp: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={configForm.baseMin || 0} onChange={(e) => setConfigForm({ ...configForm, baseMin: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={configForm.baseMax || 0} onChange={(e) => setConfigForm({ ...configForm, baseMax: Number(e.target.value) })} />
</div>
</div>
<div className="mt-4">
<Button size="sm" onClick={() => updateConfigMutation.mutate(configForm)} disabled={updateConfigMutation.isPending}>
{updateConfigMutation.isPending ? '保存中...' : '保存配置'}
</Button>
</div>
</Card>
)}
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4">
<div>
<Label></Label>
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
</div>
<Button onClick={() => calcMutate()} disabled={isPending}>
<Calculator className="w-4 h-4 mr-1" />
{isPending ? '计算中...' : '开始计算'}
</Button>
{config && (
<div className="text-xs text-gray-400">
{config.city} | {config.baseMin}~{config.baseMax}
</div>
)}
</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 font-medium">¥{result.actualBase.toLocaleString()}</span>
{result.capped && <span className="text-warning ml-2"></span>}
{result.floored && <span className="text-warning ml-2"></span>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-1.5"></th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right"></th>
<th className="py-1.5 text-right"></th>
</tr>
</thead>
<tbody>
{result.items.map((item: any) => (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
<td className="py-1.5 text-right">¥{item.orgAmount.toFixed(2)}</td>
<td className="py-1.5 text-right">¥{item.empAmount.toFixed(2)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-bold">
<td className="py-2" colSpan={3}></td>
<td className="py-2 text-right text-danger">¥{result.totalOrg.toFixed(2)}</td>
<td className="py-2 text-right text-warning">¥{result.totalEmp.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</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-primary">¥{result.total.toFixed(2)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{result.totalOrg.toFixed(2)} + ¥{result.totalEmp.toFixed(2)}
</div>
</div>
</div>
) : (
<div className="text-gray-400 text-sm"></div>
)}
</Card>
</div>
</div>
)
}