1287 lines
73 KiB
TypeScript
1287 lines
73 KiB
TypeScript
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>
|
||
)
|
||
}
|