Files
TurboHR/frontend/src/pages/SocialInsurance.tsx
T

1287 lines
73 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock } 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 confirm = useConfirm()
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
const [city, setCity] = useState<string>('北京')
const [base, setBase] = useState(8000)
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
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, number>>({})
const [editingId, setEditingId] = useState<string | null>(null)
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
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,
baseMin: 6326, baseMax: 33891,
medicalBaseMin: 0, medicalBaseMax: 0,
extraInsurances: [],
})
const [newHousingVersion, setNewHousingVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
housingOrg: 12, housingEmp: 12,
baseMin: 6326, baseMax: 33891,
})
// 获取城市列表
const { data: cities = [] } = useQuery<string[]>({
queryKey: ['social-config-cities'],
queryFn: async () => {
const res = await api.get('/social/config/cities') as any
return res.data
},
})
const { data: config, isLoading: configLoading } = useQuery<any>({
queryKey: ['social-config', city],
queryFn: async () => {
const res = await api.get('/social/config', { params: { city } }) as any
return res.data
},
})
const { data: housingConfig, isLoading: housingLoading } = useQuery<any>({
queryKey: ['housing-config', city],
queryFn: async () => {
const res = await api.get('/social/housing-config', { params: { city } }) as any
return res.data
},
})
const { data: versions } = useQuery<any[]>({
queryKey: ['social-config-versions', city],
queryFn: async () => {
const res = await api.get('/social/config/versions', { params: { city } }) as any
return res.data
},
enabled: showVersions && tab === 'social',
})
const { data: housingVersions } = useQuery<any[]>({
queryKey: ['housing-config-versions'],
queryFn: async () => {
const res = await api.get('/social/housing-config/versions') as any
return res.data
},
enabled: showVersions && tab === 'housing',
})
// 已办理月份列表(进入月度办理Tab时自动加载)
const { data: processedList, refetch: refetchProcessedList } = useQuery<any[]>({
queryKey: ['monthly-process-list'],
queryFn: async () => {
const res = await api.get('/social/monthly-process/list') as any
return res.data
},
enabled: tab === 'monthly',
})
// 进入月度办理Tab时自动查询当前月状态
useEffect(() => {
if (tab === 'monthly') {
api.get('/social/monthly-process/status', { params: { month: monthlyMonth } }).then((res: any) => {
setProcessStatus(res.data)
}).catch(() => {})
refetchProcessedList()
}
}, [tab])
const { mutateAsync: fetchMonthlyChanges, isPending: monthlyLoading, data: monthlyChanges } = useMutation<any>({
mutationFn: async () => {
const [socialRes, housingRes, socialActiveRes, housingActiveRes] = await Promise.all([
api.get('/social/monthly-changes', { params: { month: monthlyMonth } }) as any,
api.get('/social/housing/monthly-changes', { params: { month: monthlyMonth } }) as any,
api.get('/social/active-declaration', { params: { month: monthlyMonth } }) as any,
api.get('/social/housing/active-declaration', { params: { month: monthlyMonth } }) as any,
])
return {
social: socialRes.data,
housing: housingRes.data,
socialActive: socialActiveRes.data,
housingActive: housingActiveRes.data,
}
},
})
const handleMonthlyProcess = async () => {
try {
await fetchMonthlyChanges()
setMonthlyProcessed(true)
// 查询该月办理状态
const statusRes = await api.get('/social/monthly-process/status', { params: { month: monthlyMonth } }) as any
setProcessStatus(statusRes.data)
} catch {
toast.error('获取月度办理数据失败')
}
}
const completeProcessMutation = useMutation({
mutationFn: async (type: 'SOCIAL' | 'HOUSING') => {
const snapshot = type === 'SOCIAL' ? monthlyChanges.social : monthlyChanges.housing
const activeSnapshot = type === 'SOCIAL' ? monthlyChanges.socialActive : monthlyChanges.housingActive
const res = await api.post('/social/monthly-process/complete', {
month: monthlyMonth,
type,
snapshot: { changes: snapshot, active: activeSnapshot },
}) as any
return res.data
},
onSuccess: (data: any, type: 'SOCIAL' | 'HOUSING') => {
setProcessStatus((prev: any) => ({ ...prev, [type === 'SOCIAL' ? 'social' : 'housing']: data }))
refetchProcessedList()
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success(`${type === 'SOCIAL' ? '社保' : '公积金'}月度办理已完成并保存`)
},
onError: () => {
toast.error('保存办理记录失败')
},
})
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/calculate', { base }) as any
return res.data
},
})
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/housing-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)
toast.success('新版本已创建,旧版本已自动归档')
},
})
const createHousingVersionMutation = useMutation({
mutationFn: (data: any) => api.post('/social/housing-config/versions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
setShowNewVersion(false)
toast.success('公积金新版本已创建,旧版本已自动归档')
},
})
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 previewHousingAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/housing-config/${housingConfig?.id}/adjust-preview`) as any
return res.data
},
onSuccess: (data) => {
setAdjustData(data)
setShowAdjust(true)
},
})
const applyAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
api.post(`/social/config/${config?.id}/adjust-apply`, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
setEditingId(null)
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
},
})
const applyHousingAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
api.post(`/social/housing-config/${housingConfig?.id}/adjust-apply`, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
setEditingId(null)
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
},
})
const resetAdjustMutation = useMutation({
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`, { city }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config', city] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions', city] })
toast.success('社保基数调整已重置,可以重新调整')
},
})
const resetHousingAdjustMutation = useMutation({
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`, { city }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config', city] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions', city] })
toast.success('公积金基数调整已重置,可以重新调整')
},
})
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
if (!data?.items?.length) return
const headers = type === 'social'
? ['姓名', '部门', '社保基数', '企业部分', '个人部分', '合计', '开始年月', '截止年月', '变更类型']
: ['姓名', '部门', '公积金基数', '企业部分', '个人部分', '合计', '开始年月', '截止年月', '变更类型']
const rows = data.items.map((i: any) => {
const d = i.detail
if (type === 'social') {
return [i.name, i.department, i.base, d?.totalOrg || '', d?.totalEmp || '', d?.total || '', i.startMonth, i.endMonth || '', i.changeType]
}
return [i.name, i.department, i.base, d?.orgAmount || '', d?.empAmount || '', d?.total || '', i.startMonth, i.endMonth || '', i.changeType]
})
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${type === 'social' ? '社保' : '公积金'}_${data.month || monthlyMonth}.csv`
a.click()
URL.revokeObjectURL(url)
}
const isHousing = tab === 'housing'
const activeConfig = isHousing ? housingConfig : config
const activeVersions = isHousing ? housingVersions : versions
const activePreviewMut = isHousing ? previewHousingAdjustMutation : previewAdjustMutation
const activeApplyMut = isHousing ? applyHousingAdjustMutation : applyAdjustMutation
const activeCreateMut = isHousing ? createHousingVersionMutation : createVersionMutation
const activeNewVersion = isHousing ? newHousingVersion : newVersion
const activeSetNewVersion = isHousing ? setNewHousingVersion : setNewVersion
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<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-2">
{tab !== 'monthly' && (
<>
<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>
{/* Tab 切换 + 城市选择 */}
<div className="flex items-center gap-4 border-b">
{(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
>
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : '专项附加扣除'}
</button>
))}
{(tab === 'social' || tab === 'housing') && (
<div className="flex items-center gap-2 ml-auto">
<label className="text-sm text-gray-500">:</label>
<input
list="social-cities"
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="输入或选择城市"
/>
<datalist id="social-cities">
{cities.map((c) => <option key={c} value={c} />)}
</datalist>
</div>
)}
</div>
{/* ========== 社保 / 公积金 Tab ========== */}
{(tab === 'social' || tab === 'housing') && (
(isHousing ? housingLoading : configLoading) ? (
<Card><div className="text-center py-8 text-gray-500">...</div></Card>
) : activeConfig ? (
<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-sm text-gray-500">{activeConfig.effectiveFrom}</span>
<span className="text-sm text-gray-500">· {activeConfig.city}</span>
{activeConfig.adjustmentDone && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500"></span>
)}
</div>
<div className="flex items-center gap-2">
{activeConfig.adjustmentDone && (
<Button
variant="secondary"
size="sm"
onClick={async () => {
if (await confirm({ title: '重置确认', message: `确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`, variant: 'primary' })) {
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
}
}}
disabled={isHousing ? resetHousingAdjustMutation.isPending : resetAdjustMutation.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{isHousing ? resetHousingAdjustMutation.isPending ? '重置中...' : '重置调整' : resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => activePreviewMut.mutate()}
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
</Button>
</div>
</div>
{isHousing ? (
<div className="grid md:grid-cols-4 gap-3 text-sm">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
</div>
) : (
<div className="grid md:grid-cols-4 gap-3 text-sm">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
{activeConfig.medicalBaseMin > 0 && (
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMin)}</span></div>
)}
{activeConfig.medicalBaseMax > 0 && (
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMax)}</span></div>
)}
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
</div>
)}
</Card>
) : (
<Card><div className="text-center py-8 text-gray-500">{isHousing ? '公积金' : '社保'}</div></Card>
)
)}
{/* 调整预览 */}
{tab !== 'monthly' && showAdjust && adjustData && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />{isHousing ? '公积金' : '社保'}
</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)}{isHousing ? '公积金' : '社保'}
=
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, number> = {}
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.suggestedBase })
setEditItems(newEdits)
}}>
<Check className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, number> = {}
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.oldBase })
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-sm">
<thead>
<tr className="border-b text-xs 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">{isHousing ? '公积金' : '社保'}()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
</tr>
</thead>
<tbody>
{adjustData.items.map((item: any) => {
const edit = editItems[item.employeeId]
const newBase = edit ?? item.suggestedBase
const changed = newBase !== item.oldBase
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.oldBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
<td className="py-1.5 text-right">
{editingId === item.employeeId ? (
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
onBlur={() => setEditingId(null)}
autoFocus />
) : (
<span className="cursor-text inline-block !w-28 text-right"
onClick={() => setEditingId(item.employeeId)}>
¥{fmt(newBase)}
</span>
)}
{changed && <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) => ({ employeeId: i.employeeId, newBase: editItems[i.employeeId] ?? i.suggestedBase }))
activeApplyMut.mutate({ items })
}} disabled={activeApplyMut.isPending}>
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
</Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}></Button>
</div>
</Card>
)}
{/* 版本历史 */}
{tab !== 'monthly' && showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
{!activeVersions || activeVersions.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-xs"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs 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>
{isHousing ? (
<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>
{activeVersions.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>
{isHousing ? (
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</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-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>
)}
{/* 新建版本 */}
{tab !== 'monthly' && showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</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={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
<div><Label></Label><Input value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
</div>
{!isHousing && (
<div className="grid md:grid-cols-2 gap-3">
<div>
<Label>/</Label>
<Input type="number" value={activeNewVersion.medicalBaseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMin: Number(e.target.value) })} />
<p className="text-xs text-gray-400 mt-1"> 0 使</p>
</div>
<div>
<Label>/</Label>
<Input type="number" value={activeNewVersion.medicalBaseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMax: Number(e.target.value) })} />
<p className="text-xs text-gray-400 mt-1"> 0 使</p>
</div>
</div>
)}
{isHousing ? (
<div className="grid md:grid-cols-2 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: 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={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.injuryOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.maternityOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} /></div>
</div>
)}
{!isHousing && (
<div className="border rounded-md p-3 space-y-2 bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700">/</span>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => activeSetNewVersion({
...activeNewVersion,
extraInsurances: [...(activeNewVersion.extraInsurances || []), { name: '', orgRate: 0, empRate: 0, baseType: 'pension', fixedAmount: 0, empFixedAmount: 0 }],
})}
>
+
</button>
</div>
{(activeNewVersion.extraInsurances || []).map((ins: any, idx: number) => (
<div key={idx} className="grid grid-cols-5 gap-2 items-end">
<div><Label></Label><Input value={ins.name} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, name: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
<div>
<Label></Label>
<select className="w-full h-9 rounded-md border border-input px-2 text-sm" value={ins.baseType} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, baseType: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}>
<option value="pension"></option>
<option value="medical"></option>
<option value="fixed"></option>
</select>
</div>
{ins.baseType === 'fixed' ? (
<>
<div><Label>()</Label><Input type="number" value={ins.fixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, fixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
<div><Label>()</Label><Input type="number" value={ins.empFixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empFixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
</>
) : (
<>
<div><Label>%</Label><Input type="number" step="0.01" value={ins.orgRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, orgRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
<div><Label>%</Label><Input type="number" step="0.01" value={ins.empRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
</>
)}
<button type="button" className="text-xs text-danger h-9" onClick={() => { const arr = (activeNewVersion.extraInsurances || []).filter((_: any, i: number) => i !== idx); activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}></button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button onClick={() => activeCreateMut.mutate(activeNewVersion)} disabled={activeCreateMut.isPending}>
{activeCreateMut.isPending ? '保存中...' : '创建版本'}
</Button>
<Button variant="secondary" onClick={() => setShowNewVersion(false)}></Button>
</div>
</div>
</Card>
)}
{/* 试算工具 */}
{(tab === 'social' || tab === 'housing') && (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="text-sm font-medium mb-3">{isHousing ? '公积金' : '社保'}</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={() => isHousing ? calcHousingMutate() : calcMutate()} disabled={isHousing ? housingCalcPending : isPending}>
<Calculator className="w-4 h-4 mr-1" />
{(isHousing ? housingCalcPending : isPending) ? '计算中...' : '开始计算'}
</Button>
{activeConfig && (
<div className="text-sm text-gray-400">
{activeConfig.city} | {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
</div>
)}
</div>
</Card>
<Card>
<h2 className="text-sm font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" /></h2>
{(() => {
const r = isHousing ? housingResult : result
if (!r) return <div className="text-gray-400 text-sm"></div>
if (isHousing) {
return (
<div className="space-y-3">
<div className="text-sm text-gray-500">
<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
{r.capped && <span className="text-warning ml-2"></span>}
{r.floored && <span className="text-warning ml-2"></span>}
{r.configVersion && <span className="text-gray-400 ml-2">| {r.configVersion}</span>}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2 text-sm">
<span className="text-gray-500"></span>
<span className="font-medium text-danger">¥{fmt(r.housingOrg)}</span>
</div>
<div className="flex items-center justify-between border-b pb-2 text-sm">
<span className="text-gray-500"></span>
<span className="font-medium text-warning">¥{fmt(r.housingEmp)}</span>
</div>
</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(r.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(r.housingOrg)} + ¥{fmt(r.housingEmp)}
</div>
</div>
</div>
)
}
return (
<div className="space-y-3">
<div className="text-sm text-gray-500">
<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
{r.capped && <span className="text-warning ml-2"></span>}
{r.floored && <span className="text-warning ml-2"></span>}
{r.configVersion && <span className="text-gray-400 ml-2">| {r.configVersion}</span>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs 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>
{r.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(r.totalOrg)}</td>
<td className="py-2 text-right text-warning">¥{fmt(r.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(r.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(r.totalOrg)} + ¥{fmt(r.totalEmp)}
</div>
</div>
</div>
)
})()}
</Card>
</div>
)}
{/* ========== 月度办理 Tab ========== */}
{tab === 'monthly' && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<div className="flex items-center gap-2">
<Input type="month" value={monthlyMonth} onChange={(e) => { setMonthlyMonth(e.target.value); setMonthlyProcessed(false); setProcessStatus(null) }} className="!w-32" />
<Button size="sm" onClick={handleMonthlyProcess} disabled={monthlyLoading}>
{monthlyLoading ? '获取中...' : '获取'}
</Button>
{monthlyProcessed && monthlyChanges && (
<>
<Button variant="secondary" size="sm" onClick={() => handleExportCSV('social', monthlyChanges.social)}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => handleExportCSV('housing', monthlyChanges.housing)}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
</div>
</div>
{/* 办理状态总览:近12个月时间线 */}
{processedList && (() => {
const now = new Date()
const months: string[] = []
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
const socialMonths = new Set(processedList.filter((r: any) => r.type === 'SOCIAL').map((r: any) => r.month))
const housingMonths = new Set(processedList.filter((r: any) => r.type === 'HOUSING').map((r: any) => r.month))
const currentMonth = monthlyMonth
return (
<div className="mb-3 p-3 bg-gray-50 rounded-md">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-3.5 h-3.5 text-gray-400" />
<span className="text-xs font-medium text-gray-600">6</span>
</div>
<div className="flex gap-2 flex-wrap">
{months.map((m) => {
const sDone = socialMonths.has(m)
const hDone = housingMonths.has(m)
const isCurrent = m === currentMonth
const allDone = sDone && hDone
const partial = (sDone || hDone) && !allDone
return (
<button
key={m}
onClick={() => { setMonthlyMonth(m); setMonthlyProcessed(false); setProcessStatus(null) }}
className={`px-3 py-1.5 rounded-md text-xs border transition-all ${isCurrent ? 'ring-2 ring-primary/20 border-primary' : 'border-gray-200'} ${allDone ? 'bg-green-50' : partial ? 'bg-amber-50' : 'bg-white hover:bg-gray-100'}`}
>
<div className="font-medium">{m}</div>
<div className="flex gap-1 mt-0.5">
<span className={`px-1 rounded text-[10px] ${sDone ? 'bg-green-100 text-safe' : 'bg-gray-100 text-gray-400'}`}>{sDone ? '✓' : '×'}</span>
<span className={`px-1 rounded text-[10px] ${hDone ? 'bg-green-100 text-safe' : 'bg-gray-100 text-gray-400'}`}>{hDone ? '✓' : '×'}</span>
</div>
</button>
)
})}
</div>
{(() => {
const sDone = socialMonths.has(currentMonth)
const hDone = housingMonths.has(currentMonth)
if (sDone && hDone) return <div className="mt-2 text-xs text-safe flex items-center gap-1"><Check className="w-3.5 h-3.5" />{currentMonth} </div>
if (sDone || hDone) return <div className="mt-2 text-xs text-amber-600 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" />{currentMonth} {sDone ? '公积金' : '社保'}</div>
return <div className="mt-2 text-xs text-gray-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" />{currentMonth} </div>
})()}
</div>
)
})()}
{/* 办理完成按钮区 */}
{monthlyProcessed && monthlyChanges && (
<div className="flex items-center gap-3 mb-3 pb-3 border-b">
<Button size="sm" onClick={() => completeProcessMutation.mutate('SOCIAL')} disabled={completeProcessMutation.isPending}>
{processStatus?.social ? '重新办理完成(社保)' : '办理完成(社保)'}
</Button>
{processStatus?.social && (
<span className="text-xs text-safe flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> {new Date(processStatus.social.processedAt).toLocaleString('zh-CN')}
</span>
)}
<Button size="sm" onClick={() => completeProcessMutation.mutate('HOUSING')} disabled={completeProcessMutation.isPending}>
{processStatus?.housing ? '重新办理完成(公积金)' : '办理完成(公积金)'}
</Button>
{processStatus?.housing && (
<span className="text-xs text-safe flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> {new Date(processStatus.housing.processedAt).toLocaleString('zh-CN')}
</span>
)}
</div>
)}
<div className="bg-blue-50 text-blue-700 text-sm px-3 py-2 rounded-md mb-3">
///
</div>
{(() => {
if (!monthlyProcessed) {
return <div className="text-center py-8 text-gray-400 text-sm"></div>
}
if (monthlyLoading) return <div className="text-center py-4 text-gray-400 text-sm">...</div>
if (!monthlyChanges) return <div className="text-center py-4 text-gray-400 text-sm"></div>
const sAdd = monthlyChanges.social?.additions || []
const sSub = monthlyChanges.social?.reductions || []
const sNormal = monthlyChanges.socialActive?.items || []
const hAdd = monthlyChanges.housing?.additions || []
const hSub = monthlyChanges.housing?.reductions || []
const hNormal = monthlyChanges.housingActive?.items || []
if (sAdd.length === 0 && sSub.length === 0 && hAdd.length === 0 && hSub.length === 0 && sNormal.length === 0 && hNormal.length === 0) {
return <div className="text-center py-4 text-gray-400 text-sm">{monthlyMonth} </div>
}
const sConfigs = monthlyChanges.social?.configs || {}
const hConfigs = monthlyChanges.housing?.configs || {}
// 收集所有涉及的城市
const allCities = [...new Set([
...sAdd.map((i: any) => i.city), ...sSub.map((i: any) => i.city), ...sNormal.map((i: any) => i.city),
...hAdd.map((i: any) => i.city), ...hSub.map((i: any) => i.city), ...hNormal.map((i: any) => i.city),
])].filter(Boolean).sort()
const renderSocialTable = (city: string) => {
const add = sAdd.filter((i: any) => i.city === city)
const sub = sSub.filter((i: any) => i.city === city)
const normal = sNormal.filter((i: any) => i.city === city)
if (add.length === 0 && sub.length === 0 && normal.length === 0) return null
const cfg = sConfigs[city]
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs 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-left"></th>
</tr>
</thead>
<tbody>
{add.map((i: any) => <MonthlyRow key={`sa-${city}-${i.employeeId}`} item={i} type="add" />)}
{sub.map((i: any) => <MonthlyRow key={`ss-${city}-${i.employeeId}`} item={i} type="sub" />)}
{normal.map((i: any) => <MonthlyRow key={`sn-${city}-${i.employeeId}`} item={i} type="normal" />)}
</tbody>
{(add.length > 0 || normal.length > 0) && (
<tfoot>
<tr className="border-t-2 bg-gray-50 font-medium">
<td className="py-2" colSpan={4}></td>
<td className="py-2 text-right text-danger">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.totalOrg || 0), 0))}</td>
<td className="py-2 text-right text-warning">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.totalEmp || 0), 0))}</td>
<td className="py-2 text-right font-bold text-primary">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.total || 0), 0))}</td>
<td></td>
</tr>
</tfoot>
)}
</table>
{cfg && <div className="text-xs text-gray-400 mt-1">{cfg.effectiveFrom} | ¥{fmt(cfg.baseMin)}~¥{fmt(cfg.baseMax)}</div>}
</div>
)
}
const renderHousingTable = (city: string) => {
const add = hAdd.filter((i: any) => i.city === city)
const sub = hSub.filter((i: any) => i.city === city)
const normal = hNormal.filter((i: any) => i.city === city)
if (add.length === 0 && sub.length === 0 && normal.length === 0) return null
const cfg = hConfigs[city]
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs 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-left"></th>
</tr>
</thead>
<tbody>
{add.map((i: any) => <MonthlyHousingRow key={`ha-${city}-${i.employeeId}`} item={i} type="add" />)}
{sub.map((i: any) => <MonthlyHousingRow key={`hs-${city}-${i.employeeId}`} item={i} type="sub" />)}
{normal.map((i: any) => <MonthlyHousingRow key={`hn-${city}-${i.employeeId}`} item={i} type="normal" />)}
</tbody>
{(add.length > 0 || normal.length > 0) && (
<tfoot>
<tr className="border-t-2 bg-gray-50 font-medium">
<td className="py-2" colSpan={4}></td>
<td className="py-2 text-right text-danger">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.orgAmount || 0), 0))}</td>
<td className="py-2 text-right text-warning">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.empAmount || 0), 0))}</td>
<td className="py-2 text-right font-bold text-primary">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.total || 0), 0))}</td>
<td></td>
</tr>
</tfoot>
)}
</table>
{cfg && <div className="text-xs text-gray-400 mt-1">{cfg.effectiveFrom} | {cfg.housingOrg}% / {cfg.housingEmp}%</div>}
</div>
)
}
return (
<div className="space-y-4">
{allCities.map((city) => {
const sTable = renderSocialTable(city)
const hTable = renderHousingTable(city)
if (!sTable && !hTable) return null
return (
<div key={city} className="border rounded-lg p-3">
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs">{city}</span>
<span className="text-gray-400 text-xs">{city}/</span>
</h3>
{sTable && <div className="mb-3"><h4 className="text-xs font-medium text-gray-600 mb-1"></h4>{sTable}</div>}
{hTable && <div><h4 className="text-xs font-medium text-gray-600 mb-1"></h4>{hTable}</div>}
</div>
)
})}
</div>
)
})()}
</Card>
)}
{/* ========== 专项附加扣除 Tab ========== */}
{tab === 'deduction' && (
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
)}
<p className="text-sm text-gray-400">
/7
</p>
</div>
)
}
/** 月度办理社保行组件(可展开查看各险种明细) */
function MonthlyRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) {
const [expanded, setExpanded] = useState(false)
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
const d = i.detail
return (
<>
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setExpanded(!expanded)}>
<td className="py-1.5">{i.name} {d && <span className="text-gray-300 text-xs">{expanded ? '▾' : '▸'}</span>}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.totalOrg)}` : '-'}</td>
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.totalEmp)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth}` : type === 'sub' ? `${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
</tr>
{expanded && d && (
<tr className="bg-gray-50/50">
<td colSpan={8} className="py-2 px-8">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-400">
<th className="py-1 text-left"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
</tr>
</thead>
<tbody>
{d.items.map((item: any) => (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1">{item.name}</td>
<td className="py-1 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1 text-right text-gray-500">{item.empRate > 0 ? `${item.empRate}%` : '-'}</td>
<td className="py-1 text-right">¥{fmt(item.orgAmount)}</td>
<td className="py-1 text-right">{item.empAmount > 0 ? `¥${fmt(item.empAmount)}` : '-'}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
/** 月度办理公积金行组件 */
function MonthlyHousingRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) {
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
const d = i.detail
return (
<tr className="border-b last:border-0 hover:bg-gray-50">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.orgAmount)}` : '-'}</td>
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.empAmount)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth}` : type === 'sub' ? `${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
</tr>
)
}
/** 专项附加扣除按月录入组件 */
function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: string) => void }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<string | null>(null)
const [editForm, setEditForm] = useState<any>(null)
// 查询当月所有员工的专项附加扣除
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['special-deduction', month],
queryFn: async () => {
const res = await api.get('/social/special-deduction/batch', { params: { month } }) as any
return res.data
},
})
// 查询所有员工列表(用于添加未录入的员工)
const { data: employees = [] } = useQuery<any[]>({
queryKey: ['employees-for-deduction'],
queryFn: async () => {
const res = await api.get('/roster', { params: { pageSize: 999 } }) as any
return res.data?.items || res.data || []
},
})
// 查询上月专项附加扣除数据(用于「复制上月」功能)
const prevMonth = (() => {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
const { data: prevRecords = [] } = useQuery<any[]>({
queryKey: ['special-deduction', prevMonth],
queryFn: async () => {
const res = await api.get('/social/special-deduction/batch', { params: { month: prevMonth } }) as any
return res.data || []
},
})
const prevRecordMap = new Map(prevRecords.map((r: any) => [r.employeeId, r]))
const saveMutation = useMutation({
mutationFn: (data: any) => api.post('/social/special-deduction', { ...data, month }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
setEditing(null)
setEditForm(null)
},
})
const recordMap = new Map(records.map((r: any) => [r.employeeId, r]))
const unrecorded = employees.filter((e: any) => !recordMap.has(e.id))
const startEdit = (empId: string, existing?: any) => {
setEditing(empId)
setEditForm(existing ? {
children: existing.children,
elderly: existing.elderly,
housing: existing.housing,
education: existing.education,
infant: existing.infant,
remark: existing.remark,
} : { children: 0, elderly: 0, housing: 0, education: 0, infant: 0, remark: '' })
}
/** 复制上月数据到编辑表单 */
const copyFromPrevMonth = (empId: string) => {
const prev = prevRecordMap.get(empId)
if (prev) {
setEditForm({
children: prev.children || 0,
elderly: prev.elderly || 0,
housing: prev.housing || 0,
education: prev.education || 0,
infant: prev.infant || 0,
remark: prev.remark || '',
})
} else {
toast.info(`${prevMonth} 无该员工的扣除记录`)
}
}
const calcTotal = (f: any) => (f.children || 0) + (f.elderly || 0) + (f.housing || 0) + (f.education || 0) + (f.infant || 0)
return (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {month}</h2>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : (
<div className="space-y-3">
{/* 已录入列表 */}
{records.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 font-medium"></th>
<th className="py-2 font-medium"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{records.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
{editing === r.employeeId ? (
<>
<td className="py-1.5">{r.employee?.name}</td>
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></td>
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(calcTotal(editForm))}</td>
<td className="py-1"><Input className="!w-24 !h-8" value={editForm.remark || ''} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></td>
<td className="py-1">
<div className="flex gap-1">
<Button size="sm" className="!h-7 !px-2" onClick={() => saveMutation.mutate({ employeeId: r.employeeId, ...editForm })} disabled={saveMutation.isPending}></Button>
<Button size="sm" variant="secondary" className="!h-7 !px-2" onClick={() => copyFromPrevMonth(r.employeeId)} disabled={!prevRecordMap.has(r.employeeId)}></Button>
<Button size="sm" variant="secondary" className="!h-7 !px-2" onClick={() => { setEditing(null); setEditForm(null) }}></Button>
</div>
</td>
</>
) : (
<>
<td className="py-1.5">{r.employee?.name}</td>
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
<td className="py-1.5 text-right">{r.children > 0 ? `¥${fmt(r.children)}` : '-'}</td>
<td className="py-1.5 text-right">{r.elderly > 0 ? `¥${fmt(r.elderly)}` : '-'}</td>
<td className="py-1.5 text-right">{r.housing > 0 ? `¥${fmt(r.housing)}` : '-'}</td>
<td className="py-1.5 text-right">{r.education > 0 ? `¥${fmt(r.education)}` : '-'}</td>
<td className="py-1.5 text-right">{r.infant > 0 ? `¥${fmt(r.infant)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(r.amount)}</td>
<td className="py-1.5 text-gray-400 text-xs">{r.remark || '-'}</td>
<td className="py-1.5"><button className="text-xs text-primary hover:underline" onClick={() => startEdit(r.employeeId, r)}></button></td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 未录入员工 */}
{unrecorded.length > 0 && (
<div className="border-t pt-3">
<h3 className="text-xs font-medium text-gray-500 mb-2">{unrecorded.length}</h3>
<div className="flex flex-wrap gap-2">
{unrecorded.map((e: any) => (
<button
key={e.id}
className="px-2 py-1 rounded-md border border-gray-200 text-xs text-gray-600 hover:border-primary hover:text-primary"
onClick={() => startEdit(e.id)}
>
{e.name}{e.department}
</button>
))}
</div>
</div>
)}
{/* 新增/编辑表单 */}
{editing && !recordMap.has(editing) && (
<div className="border rounded-md p-3 bg-gray-50 space-y-2">
<h3 className="text-xs font-medium"> {employees.find((e: any) => e.id === editing)?.name}</h3>
<div className="grid grid-cols-5 gap-2">
<div><Label></Label><Input type="number" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></div>
</div>
<div className="flex items-center gap-3">
<div className="flex-1"><Label></Label><Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></div>
<div className="text-sm text-gray-500 pt-5"><span className="font-medium text-primary">¥{fmt(calcTotal(editForm))}</span></div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => saveMutation.mutate({ employeeId: editing, ...editForm })} disabled={saveMutation.isPending}></Button>
<Button size="sm" variant="secondary" onClick={() => copyFromPrevMonth(editing)} disabled={!prevRecordMap.has(editing)}></Button>
<Button size="sm" variant="secondary" onClick={() => { setEditing(null); setEditForm(null) }}></Button>
</div>
</div>
)}
{records.length === 0 && unrecorded.length === 0 && (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
)}
</Card>
)
}