refactor: 社保公积金从薪税tab移至顶层导航,放在薪税和解聘之间

- 新建 SocialInsurance.tsx 独立页面
- App.tsx 添加 /social 路由
- TopNav/MobileTabBar 添加社保公积金导航项
- Money.tsx 移除 social tab 和 SocialInsuranceCalculator 函数
This commit is contained in:
freedakgmail
2026-07-23 16:48:10 +08:00
parent c618710a52
commit fa7aa01c48
5 changed files with 508 additions and 496 deletions
+2
View File
@@ -9,6 +9,7 @@ import ForgotPassword from './pages/auth/ForgotPassword'
import Dashboard from './pages/Dashboard'
import Contracts from './pages/Contracts'
import Money from './pages/Money'
import SocialInsurance from './pages/SocialInsurance'
import Roster from './pages/Roster'
import Termination from './pages/Termination'
import AIAssistant from './pages/AIAssistant'
@@ -65,6 +66,7 @@ export default function App() {
<Route path="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></AdminLayout></ProtectedRoute>} />
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
<Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></AdminLayout></ProtectedRoute>} />
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
@@ -1,11 +1,12 @@
import { Link, useLocation } from 'react-router-dom'
import { Home, FileText, Users, Calculator, UserX, Bot } from 'lucide-react'
import { Home, FileText, Users, Calculator, UserX, Bot, Shield } from 'lucide-react'
import clsx from 'clsx'
const tabs = [
{ path: '/', label: '总览', icon: Home },
{ path: '/roster', label: '花名册', icon: Users },
{ path: '/money', label: '薪税', icon: Calculator },
{ path: '/social', label: '社保', icon: Shield },
{ path: '/termination', label: '解聘', icon: UserX },
{ path: '/ai-assistant', label: 'AI', icon: Bot },
]
@@ -8,6 +8,7 @@ const tabs = [
{ path: '/', label: '总览' },
{ path: '/roster', label: '花名册' },
{ path: '/money', label: '薪税' },
{ path: '/social', label: '社保公积金' },
{ path: '/termination', label: '解聘' },
{ path: '/ai-assistant', label: 'AI顾问' },
]
+1 -495
View File
@@ -11,7 +11,7 @@ import Pagination from '../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
type Tab = 'batch' | 'template' | 'overtime' | 'social' | 'payslip'
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('batch')
@@ -20,7 +20,6 @@ export default function Money() {
{ key: 'batch', label: '发薪批次' },
{ key: 'template', label: '薪酬模版' },
{ key: 'overtime', label: '加班费计算' },
{ key: 'social', label: '社保公积金' },
{ key: 'payslip', label: '工资条管理' },
]
@@ -45,7 +44,6 @@ export default function Money() {
{tab === 'batch' && <BatchManager />}
{tab === 'template' && <TemplateManager />}
{tab === 'overtime' && <OvertimeCalculator />}
{tab === 'social' && <SocialInsuranceCalculator />}
{tab === 'payslip' && <PayslipManager />}
</div>
)
@@ -1155,495 +1153,3 @@ function PayslipManager() {
</div>
)
}
function SocialInsuranceCalculator() {
const queryClient = useQueryClient()
const [base, setBase] = useState(8000)
const [showNewVersion, setShowNewVersion] = useState(false)
const [showVersions, setShowVersions] = useState(false)
const [showAdjust, setShowAdjust] = useState(false)
const [adjustData, setAdjustData] = useState<any>(null)
const [editItems, setEditItems] = useState<Record<string, { socialBase: number; housingBase: number }>>({})
const [newVersion, setNewVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
pensionOrg: 16, pensionEmp: 8,
medicalOrg: 9.8, medicalEmp: 2,
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
injuryOrg: 0.2, maternityOrg: 0.8,
housingOrg: 12, housingEmp: 12,
baseMin: 6326, baseMax: 33891,
})
const { data: config } = useQuery<any>({
queryKey: ['social-config'],
queryFn: async () => {
const res = await api.get('/social/config') as any
return res.data
},
})
const { data: versions } = useQuery<any[]>({
queryKey: ['social-config-versions'],
queryFn: async () => {
const res = await api.get('/social/config/versions') as any
return res.data
},
enabled: showVersions,
})
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/calculate', { base }) as any
return res.data
},
})
const createVersionMutation = useMutation({
mutationFn: (data: any) => api.post('/social/config/versions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
setShowNewVersion(false)
alert('新版本已创建,旧版本已自动归档')
},
})
const previewAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any
return res.data
},
onSuccess: (data) => {
setAdjustData(data)
setShowAdjust(true)
},
})
const applyAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newSocialBase: number; newHousingBase: number }[] }) =>
api.post(`/social/config/${config?.id}/adjust-apply`, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保/公积金基数`)
},
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xs font-medium"></h2>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
{showVersions ? '收起历史' : '版本历史'}
</Button>
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 当前生效版本信息 */}
{config && (
<Card>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
<span className="text-xs text-gray-500">{config.effectiveFrom}</span>
<span className="text-xs text-gray-500">· {config.city}</span>
{config.adjustmentDone && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-400"></span>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => previewAdjustMutation.mutate()}
disabled={config.adjustmentDone || previewAdjustMutation.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{config.adjustmentDone ? '已调整' : previewAdjustMutation.isPending ? '加载中...' : '调整员工基数'}
</Button>
</div>
<div className="grid md:grid-cols-4 gap-3 text-xs">
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(config.baseMin)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(config.baseMax)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.pensionOrg}% / {config.pensionEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.medicalOrg}% / {config.medicalEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.unemploymentOrg}% / {config.unemploymentEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">()</span>
<span className="font-medium">{config.injuryOrg}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">()</span>
<span className="font-medium">{config.maternityOrg}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.housingOrg}% / {config.housingEmp}%</span>
</div>
</div>
</Card>
)}
{/* 调整预览 */}
{showAdjust && adjustData && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
</h3>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>
¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)}/
=
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, { socialBase: number; housingBase: number }> = {}
adjustData.items.forEach((i: any) => {
newEdits[i.employeeId] = { socialBase: i.suggestedSocialBase, housingBase: i.suggestedHousingBase }
})
setEditItems(newEdits)
}}>
<Check className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, { socialBase: number; housingBase: number }> = {}
adjustData.items.forEach((i: any) => {
newEdits[i.employeeId] = { socialBase: i.oldSocialBase, housingBase: i.oldHousingBase }
})
setEditItems(newEdits)
}}>
</Button>
<span className="text-xs text-gray-400"> {adjustData.total} </span>
</div>
<div className="overflow-x-auto mb-4">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></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-right">()</th>
<th className="py-2 text-right">()</th>
</tr>
</thead>
<tbody>
{adjustData.items.map((item: any) => {
const edit = editItems[item.employeeId]
const socialBase = edit?.socialBase ?? item.suggestedSocialBase
const housingBase = edit?.housingBase ?? item.suggestedHousingBase
const socialChanged = socialBase !== item.oldSocialBase
const housingChanged = housingBase !== item.oldHousingBase
return (
<tr key={item.employeeId} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-gray-500">{item.department}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldSocialBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedSocialBase)}</td>
<td className="py-1.5 text-right">
<Input
type="number"
className="w-24 text-right text-xs"
value={socialBase}
onChange={(e) => setEditItems({
...editItems,
[item.employeeId]: {
socialBase: Number(e.target.value) || 0,
housingBase: edit?.housingBase ?? item.suggestedHousingBase,
},
})}
/>
{socialChanged && <span className="text-warning ml-1"></span>}
</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldHousingBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedHousingBase)}</td>
<td className="py-1.5 text-right">
<Input
type="number"
className="w-24 text-right text-xs"
value={housingBase}
onChange={(e) => setEditItems({
...editItems,
[item.employeeId]: {
socialBase: edit?.socialBase ?? item.suggestedSocialBase,
housingBase: Number(e.target.value) || 0,
},
})}
/>
{housingChanged && <span className="text-warning ml-1"></span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
const items = adjustData.items.map((i: any) => {
const edit = editItems[i.employeeId]
return {
employeeId: i.employeeId,
newSocialBase: edit?.socialBase ?? i.suggestedSocialBase,
newHousingBase: edit?.housingBase ?? i.suggestedHousingBase,
}
})
applyAdjustMutation.mutate({ items })
}}
disabled={applyAdjustMutation.isPending}
>
{applyAdjustMutation.isPending ? '保存中...' : '确认保存'}
</Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}) }}>
</Button>
</div>
</Card>
)}
{/* 版本历史 */}
{showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" /></h3>
{!versions || versions.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-xs"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></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>
</tr>
</thead>
<tbody>
{versions.map((v: any) => (
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{v.effectiveFrom}</td>
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
<td className="py-2">{v.city}</td>
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
<td className="py-2 text-center">
{v.isCurrent
? <span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span>
: <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400"></span>
}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 新建版本 */}
{showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" /></h3>
<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>
7
</div>
</div>
<div className="grid md:grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="month" value={newVersion.effectiveFrom} onChange={(e) => setNewVersion({ ...newVersion, effectiveFrom: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={newVersion.city} onChange={(e) => setNewVersion({ ...newVersion, city: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={newVersion.baseMin} onChange={(e) => setNewVersion({ ...newVersion, baseMin: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={newVersion.baseMax} onChange={(e) => setNewVersion({ ...newVersion, baseMax: Number(e.target.value) })} />
</div>
</div>
<div className="grid md:grid-cols-4 gap-3">
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.pensionOrg} onChange={(e) => setNewVersion({ ...newVersion, pensionOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.pensionEmp} onChange={(e) => setNewVersion({ ...newVersion, pensionEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.medicalOrg} onChange={(e) => setNewVersion({ ...newVersion, medicalOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.medicalEmp} onChange={(e) => setNewVersion({ ...newVersion, medicalEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.unemploymentOrg} onChange={(e) => setNewVersion({ ...newVersion, unemploymentOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.unemploymentEmp} onChange={(e) => setNewVersion({ ...newVersion, unemploymentEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.injuryOrg} onChange={(e) => setNewVersion({ ...newVersion, injuryOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.maternityOrg} onChange={(e) => setNewVersion({ ...newVersion, maternityOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.housingOrg} onChange={(e) => setNewVersion({ ...newVersion, housingOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.housingEmp} onChange={(e) => setNewVersion({ ...newVersion, housingEmp: Number(e.target.value) })} />
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => createVersionMutation.mutate(newVersion)} disabled={createVersionMutation.isPending}>
{createVersionMutation.isPending ? '保存中...' : '创建版本'}
</Button>
<Button variant="secondary" onClick={() => setShowNewVersion(false)}></Button>
</div>
</div>
</Card>
)}
{/* 试算工具 */}
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="text-xs font-medium mb-3"></h2>
<div className="space-y-3">
<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} | {fmt(config.baseMin)}~{fmt(config.baseMax)}
</div>
)}
</div>
</Card>
<Card>
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" /></h2>
{result ? (
<div className="space-y-3">
<div className="text-xs text-gray-500">
<span className="text-gray-900 font-medium">¥{fmt(result.actualBase)}</span>
{result.capped && <span className="text-warning ml-2"></span>}
{result.floored && <span className="text-warning ml-2"></span>}
{result.configVersion && <span className="text-gray-400 ml-2">| {result.configVersion}</span>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<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">¥{fmt(item.orgAmount)}</td>
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</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">¥{fmt(result.totalOrg)}</td>
<td className="py-2 text-right text-warning">¥{fmt(result.totalEmp)}</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-lg font-bold text-primary">¥{fmt(result.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(result.totalOrg)} + ¥{fmt(result.totalEmp)}
</div>
</div>
</div>
) : (
<div className="text-gray-400 text-xs"></div>
)}
</Card>
</div>
<p className="text-xs text-gray-400">
/7
</p>
</div>
)
}
+502
View File
@@ -0,0 +1,502 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function SocialInsurance() {
const queryClient = useQueryClient()
const [base, setBase] = useState(8000)
const [showNewVersion, setShowNewVersion] = useState(false)
const [showVersions, setShowVersions] = useState(false)
const [showAdjust, setShowAdjust] = useState(false)
const [adjustData, setAdjustData] = useState<any>(null)
const [editItems, setEditItems] = useState<Record<string, { socialBase: number; housingBase: number }>>({})
const [newVersion, setNewVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
pensionOrg: 16, pensionEmp: 8,
medicalOrg: 9.8, medicalEmp: 2,
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
injuryOrg: 0.2, maternityOrg: 0.8,
housingOrg: 12, housingEmp: 12,
baseMin: 6326, baseMax: 33891,
})
const { data: config } = useQuery<any>({
queryKey: ['social-config'],
queryFn: async () => {
const res = await api.get('/social/config') as any
return res.data
},
})
const { data: versions } = useQuery<any[]>({
queryKey: ['social-config-versions'],
queryFn: async () => {
const res = await api.get('/social/config/versions') as any
return res.data
},
enabled: showVersions,
})
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/calculate', { base }) as any
return res.data
},
})
const createVersionMutation = useMutation({
mutationFn: (data: any) => api.post('/social/config/versions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
setShowNewVersion(false)
alert('新版本已创建,旧版本已自动归档')
},
})
const previewAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any
return res.data
},
onSuccess: (data) => {
setAdjustData(data)
setShowAdjust(true)
},
})
const applyAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newSocialBase: number; newHousingBase: number }[] }) =>
api.post(`/social/config/${config?.id}/adjust-apply`, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保/公积金基数`)
},
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h1 className="text-xs font-medium"></h1>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
{showVersions ? '收起历史' : '版本历史'}
</Button>
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 当前生效版本信息 */}
{config && (
<Card>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
<span className="text-xs text-gray-500">{config.effectiveFrom}</span>
<span className="text-xs text-gray-500">· {config.city}</span>
{config.adjustmentDone && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-400"></span>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => previewAdjustMutation.mutate()}
disabled={config.adjustmentDone || previewAdjustMutation.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{config.adjustmentDone ? '已调整' : previewAdjustMutation.isPending ? '加载中...' : '调整员工基数'}
</Button>
</div>
<div className="grid md:grid-cols-4 gap-3 text-xs">
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(config.baseMin)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(config.baseMax)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.pensionOrg}% / {config.pensionEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.medicalOrg}% / {config.medicalEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.unemploymentOrg}% / {config.unemploymentEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">()</span>
<span className="font-medium">{config.injuryOrg}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">()</span>
<span className="font-medium">{config.maternityOrg}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.housingOrg}% / {config.housingEmp}%</span>
</div>
</div>
</Card>
)}
{/* 调整预览 */}
{showAdjust && adjustData && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
</h3>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>
¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)}/
=
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, { socialBase: number; housingBase: number }> = {}
adjustData.items.forEach((i: any) => {
newEdits[i.employeeId] = { socialBase: i.suggestedSocialBase, housingBase: i.suggestedHousingBase }
})
setEditItems(newEdits)
}}>
<Check className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, { socialBase: number; housingBase: number }> = {}
adjustData.items.forEach((i: any) => {
newEdits[i.employeeId] = { socialBase: i.oldSocialBase, housingBase: i.oldHousingBase }
})
setEditItems(newEdits)
}}>
</Button>
<span className="text-xs text-gray-400"> {adjustData.total} </span>
</div>
<div className="overflow-x-auto mb-4">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></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-right">()</th>
<th className="py-2 text-right">()</th>
</tr>
</thead>
<tbody>
{adjustData.items.map((item: any) => {
const edit = editItems[item.employeeId]
const socialBase = edit?.socialBase ?? item.suggestedSocialBase
const housingBase = edit?.housingBase ?? item.suggestedHousingBase
const socialChanged = socialBase !== item.oldSocialBase
const housingChanged = housingBase !== item.oldHousingBase
return (
<tr key={item.employeeId} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-gray-500">{item.department}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldSocialBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedSocialBase)}</td>
<td className="py-1.5 text-right">
<Input
type="number"
className="w-24 text-right text-xs"
value={socialBase}
onChange={(e) => setEditItems({
...editItems,
[item.employeeId]: {
socialBase: Number(e.target.value) || 0,
housingBase: edit?.housingBase ?? item.suggestedHousingBase,
},
})}
/>
{socialChanged && <span className="text-warning ml-1"></span>}
</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldHousingBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedHousingBase)}</td>
<td className="py-1.5 text-right">
<Input
type="number"
className="w-24 text-right text-xs"
value={housingBase}
onChange={(e) => setEditItems({
...editItems,
[item.employeeId]: {
socialBase: edit?.socialBase ?? item.suggestedSocialBase,
housingBase: Number(e.target.value) || 0,
},
})}
/>
{housingChanged && <span className="text-warning ml-1"></span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
const items = adjustData.items.map((i: any) => {
const edit = editItems[i.employeeId]
return {
employeeId: i.employeeId,
newSocialBase: edit?.socialBase ?? i.suggestedSocialBase,
newHousingBase: edit?.housingBase ?? i.suggestedHousingBase,
}
})
applyAdjustMutation.mutate({ items })
}}
disabled={applyAdjustMutation.isPending}
>
{applyAdjustMutation.isPending ? '保存中...' : '确认保存'}
</Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}) }}>
</Button>
</div>
</Card>
)}
{/* 版本历史 */}
{showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" /></h3>
{!versions || versions.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-xs"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></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>
</tr>
</thead>
<tbody>
{versions.map((v: any) => (
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{v.effectiveFrom}</td>
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
<td className="py-2">{v.city}</td>
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
<td className="py-2 text-center">
{v.isCurrent
? <span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span>
: <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400"></span>
}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 新建版本 */}
{showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" /></h3>
<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>
7
</div>
</div>
<div className="grid md:grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="month" value={newVersion.effectiveFrom} onChange={(e) => setNewVersion({ ...newVersion, effectiveFrom: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={newVersion.city} onChange={(e) => setNewVersion({ ...newVersion, city: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={newVersion.baseMin} onChange={(e) => setNewVersion({ ...newVersion, baseMin: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={newVersion.baseMax} onChange={(e) => setNewVersion({ ...newVersion, baseMax: Number(e.target.value) })} />
</div>
</div>
<div className="grid md:grid-cols-4 gap-3">
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.pensionOrg} onChange={(e) => setNewVersion({ ...newVersion, pensionOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.pensionEmp} onChange={(e) => setNewVersion({ ...newVersion, pensionEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.medicalOrg} onChange={(e) => setNewVersion({ ...newVersion, medicalOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.medicalEmp} onChange={(e) => setNewVersion({ ...newVersion, medicalEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.unemploymentOrg} onChange={(e) => setNewVersion({ ...newVersion, unemploymentOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.unemploymentEmp} onChange={(e) => setNewVersion({ ...newVersion, unemploymentEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.injuryOrg} onChange={(e) => setNewVersion({ ...newVersion, injuryOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.maternityOrg} onChange={(e) => setNewVersion({ ...newVersion, maternityOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.housingOrg} onChange={(e) => setNewVersion({ ...newVersion, housingOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.housingEmp} onChange={(e) => setNewVersion({ ...newVersion, housingEmp: Number(e.target.value) })} />
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => createVersionMutation.mutate(newVersion)} disabled={createVersionMutation.isPending}>
{createVersionMutation.isPending ? '保存中...' : '创建版本'}
</Button>
<Button variant="secondary" onClick={() => setShowNewVersion(false)}></Button>
</div>
</div>
</Card>
)}
{/* 试算工具 */}
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="text-xs font-medium mb-3"></h2>
<div className="space-y-3">
<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} | {fmt(config.baseMin)}~{fmt(config.baseMax)}
</div>
)}
</div>
</Card>
<Card>
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" /></h2>
{result ? (
<div className="space-y-3">
<div className="text-xs text-gray-500">
<span className="text-gray-900 font-medium">¥{fmt(result.actualBase)}</span>
{result.capped && <span className="text-warning ml-2"></span>}
{result.floored && <span className="text-warning ml-2"></span>}
{result.configVersion && <span className="text-gray-400 ml-2">| {result.configVersion}</span>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<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">¥{fmt(item.orgAmount)}</td>
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</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">¥{fmt(result.totalOrg)}</td>
<td className="py-2 text-right text-warning">¥{fmt(result.totalEmp)}</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-lg font-bold text-primary">¥{fmt(result.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(result.totalOrg)} + ¥{fmt(result.totalEmp)}
</div>
</div>
</div>
) : (
<div className="text-gray-400 text-xs"></div>
)}
</Card>
</div>
<p className="text-xs text-gray-400">
/7
</p>
</div>
)
}