Files
TurboHR/frontend/src/pages/SocialInsurance.tsx
T
selfrelease 55286819ae fix: HR系统优化批次1 - P0/P1问题修复
P0-3: 修复合同状态判断逻辑,有合同记录但signDate为null时不再误判未签
P0-5: 修复参保城市默认北京问题,导入和预览均改为null
P0-11: 添加全局ErrorBoundary防止白屏,三处布局均包裹
P1-2: 合同附件改为可选,允许先保存再补充上传
P1-7.2: 排班弹窗增加员工搜索(姓名/部门)
P1-8.2: 加班费导入支持Excel(xlsx/xls)格式,兼容中英文列名
P1-9: 社保/公积金基数月度办理支持逐人修改,后端返回recordId
2026-07-30 18:34:36 +08:00

1621 lines
90 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, useRef } 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, MapPin, Sparkles, Upload, X } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
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 [showAddCity, setShowAddCity] = useState(false)
const [newCityName, setNewCityName] = useState('')
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: '北京',
accountType: 'BASIC',
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: housingAllAccounts } = useQuery<any[]>({
queryKey: ['housing-config-all-accounts', city],
queryFn: async () => {
const res = await api.get('/social/housing-config/versions', { params: { city } }) as any
const current = (res.data || []).filter((v: any) => v.isCurrent)
return current
},
})
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, city }) 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, city }) 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 aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
const res = await api.post('/social/ai-suggest', vars) as any
return res.data
},
onSuccess: (data) => {
if (isHousing) {
activeSetNewVersion({
...activeNewVersion,
baseMin: data.baseMin ?? activeNewVersion.baseMin,
baseMax: data.baseMax ?? activeNewVersion.baseMax,
housingOrg: data.housingOrg ?? activeNewVersion.housingOrg,
housingEmp: data.housingEmp ?? activeNewVersion.housingEmp,
})
} else {
activeSetNewVersion({
...activeNewVersion,
baseMin: data.baseMin ?? activeNewVersion.baseMin,
baseMax: data.baseMax ?? activeNewVersion.baseMax,
medicalBaseMin: data.medicalBaseMin ?? 0,
medicalBaseMax: data.medicalBaseMax ?? 0,
pensionOrg: data.pensionOrg ?? activeNewVersion.pensionOrg,
pensionEmp: data.pensionEmp ?? activeNewVersion.pensionEmp,
medicalOrg: data.medicalOrg ?? activeNewVersion.medicalOrg,
medicalEmp: data.medicalEmp ?? activeNewVersion.medicalEmp,
unemploymentOrg: data.unemploymentOrg ?? activeNewVersion.unemploymentOrg,
unemploymentEmp: data.unemploymentEmp ?? activeNewVersion.unemploymentEmp,
injuryOrg: data.injuryOrg ?? activeNewVersion.injuryOrg,
maternityOrg: data.maternityOrg ?? activeNewVersion.maternityOrg,
extraInsurances: data.extraInsurances ?? [],
})
}
toast.success('AI建议已填入,请核对后保存')
},
onError: () => {
toast.error('AI建议获取失败,请手动填写')
},
})
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>
{showAddCity ? (
<div className="flex items-center gap-1">
<input
className="h-9 w-24 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={newCityName}
onChange={(e) => setNewCityName(e.target.value)}
placeholder="城市名"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
if (e.key === 'Escape') { setShowAddCity(false); setNewCityName('') }
}}
/>
<button className="h-9 px-2 text-xs text-primary" onClick={() => {
if (newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
}}></button>
<button className="h-9 px-2 text-xs text-gray-400" onClick={() => { setShowAddCity(false); setNewCityName('') }}></button>
</div>
) : (
<div className="flex items-center gap-1">
<select
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)}
>
{cities.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<button
className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-400 hover:text-primary hover:border-primary transition"
title="添加新城市"
onClick={() => setShowAddCity(true)}
>
<MapPin className="w-4 h-4" />
</button>
</div>
)}
</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 ? (
<>
{(housingAllAccounts || []).length > 1 && (
<div className="flex gap-2 mb-3">
{(housingAllAccounts || []).map((a: any) => (
<span key={a.id} className={`px-2 py-0.5 rounded text-xs ${a.accountType === 'SUPPLEMENTARY' ? 'bg-purple-50 text-purple-700' : 'bg-blue-50 text-blue-700'}`}>
{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}%
</span>
))}
</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">{activeConfig.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</span></div>
<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>
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
<div key={idx} className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">{ins.name}(/)</span>
<span className="font-medium">
{ins.baseType === 'fixed'
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
: `${ins.orgRate}% / ${ins.empRate}%`
}
</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>
{isHousing && <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>
{isHousing && <td className="py-2">{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</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>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
onClick={() => aiSuggestMut.mutate({ city: activeNewVersion.city, effectiveFrom: activeNewVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
disabled={aiSuggestMut.isPending}
>
<Sparkles className="w-4 h-4" />
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
</button>
<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>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })}>
{[...new Set([city, ...cities])].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</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-3 gap-3">
<div>
<Label></Label>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.accountType || 'BASIC'} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, accountType: e.target.value })}>
<option value="BASIC"></option>
<option value="SUPPLEMENTARY"></option>
</select>
</div>
<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" onCorrected={handleMonthlyProcess} />)}
{sub.map((i: any) => <MonthlyRow key={`ss-${city}-${i.employeeId}`} item={i} type="sub" onCorrected={handleMonthlyProcess} />)}
{normal.map((i: any) => <MonthlyRow key={`sn-${city}-${i.employeeId}`} item={i} type="normal" onCorrected={handleMonthlyProcess} />)}
</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" onCorrected={handleMonthlyProcess} />)}
{sub.map((i: any) => <MonthlyHousingRow key={`hs-${city}-${i.employeeId}`} item={i} type="sub" onCorrected={handleMonthlyProcess} />)}
{normal.map((i: any) => <MonthlyHousingRow key={`hn-${city}-${i.employeeId}`} item={i} type="normal" onCorrected={handleMonthlyProcess} />)}
</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, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
const [expanded, setExpanded] = useState(false)
const [editing, setEditing] = useState(false)
const [editBase, setEditBase] = useState(i.base?.toString() || '')
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
const correctMutation = useMutation({
mutationFn: (data: { base: number }) => api.put(`/social/records/social/${i.recordId}/correct`, data),
onSuccess: () => {
setEditing(false)
toast.success('基数已修改')
onCorrected?.()
},
onError: () => toast.error('修改失败'),
})
const handleSaveBase = () => {
const val = Number(editBase) || 0
if (val <= 0) { toast.error('基数必须大于0'); return }
correctMutation.mutate({ base: val })
}
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">
{editing ? (
<span onClick={(e) => e.stopPropagation()} className="inline-flex items-center gap-1">
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
onChange={(e) => setEditBase(e.target.value)} autoFocus />
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
{correctMutation.isPending ? '...' : '保存'}
</button>
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}></button>
</span>
) : (
<span className="inline-flex items-center gap-1">
¥{fmt(i.base)}
{i.recordId && type !== 'sub' && (
<button className="text-xs text-gray-400 hover:text-primary" onClick={(e) => { e.stopPropagation(); setEditing(true); setEditBase(i.base?.toString() || '') }}>
</button>
)}
</span>
)}
</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, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
const [editing, setEditing] = useState(false)
const [editBase, setEditBase] = useState(i.base?.toString() || '')
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
const correctMutation = useMutation({
mutationFn: (data: { base: number }) => api.put(`/social/records/housing/${i.recordId}/correct`, data),
onSuccess: () => {
setEditing(false)
toast.success('基数已修改')
onCorrected?.()
},
onError: () => toast.error('修改失败'),
})
const handleSaveBase = () => {
const val = Number(editBase) || 0
if (val <= 0) { toast.error('基数必须大于0'); return }
correctMutation.mutate({ base: val })
}
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">
{editing ? (
<span className="inline-flex items-center gap-1">
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
onChange={(e) => setEditBase(e.target.value)} autoFocus />
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
{correctMutation.isPending ? '...' : '保存'}
</button>
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}></button>
</span>
) : (
<span className="inline-flex items-center gap-1">
¥{fmt(i.base)}
{i.recordId && type !== 'sub' && (
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => { setEditing(true); setEditBase(i.base?.toString() || '') }}>
</button>
)}
</span>
)}
</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 [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importResult, setImportResult] = useState<any>(null)
const [importing, setImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(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: ['active-social-employees', month],
queryFn: async () => {
const res = await api.get('/social/active-declaration', { params: { month } }) as any
return (res.data?.items || []).map((item: any) => ({
id: item.employeeId,
name: item.name,
department: item.department,
}))
},
})
// 查询上月专项附加扣除数据(用于「复制上月」功能)
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 batchCopyMutation = useMutation({
mutationFn: async () => {
let copied = 0
for (const prev of prevRecords) {
await api.post('/social/special-deduction', {
employeeId: prev.employeeId,
month,
children: prev.children || 0,
elderly: prev.elderly || 0,
housing: prev.housing || 0,
education: prev.education || 0,
infant: prev.infant || 0,
remark: prev.remark || '',
})
copied++
}
return copied
},
onSuccess: (copied: number) => {
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
if (copied > 0) {
toast.success(`已复制 ${copied}${prevMonth} 的扣除数据到 ${month}`)
} else {
toast.info(`${prevMonth} 无可复制的扣除数据`)
}
},
onError: () => toast.error('复制上月数据失败'),
})
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>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => batchCopyMutation.mutate()}
disabled={batchCopyMutation.isPending || prevRecords.length === 0}
>
{batchCopyMutation.isPending ? '复制中...' : `复制上月(${prevMonth})`}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => setShowImport(true)}
>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
</div>
</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={() => { 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={() => { 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>
)}
{/* 批量导入弹窗 */}
{showImport && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
<Card className="max-w-lg w-full" >
<div onClick={(e) => e.stopPropagation()} className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {month}</h2>
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/special-deduction/template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '专项附加扣除导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('下载模板失败') }
}}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="special-deduction-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
<label htmlFor="special-deduction-import-file" className="cursor-pointer text-xs text-primary hover:underline">
{importFile ? importFile.name : '点击选择 Excel 文件'}
</label>
</div>
{importResult && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium"></div>
<div> {importResult.updated} {importResult.skipped} {importResult.total} </div>
{importResult.errors?.length > 0 && (
<div className="mt-1 pt-1 border-t border-green-200">
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
{importResult.errors.length > 5 && <div className="text-amber-600">... {importResult.errors.length - 5} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}></Button>
<Button size="sm" onClick={async () => {
if (!importFile) return toast.error('请选择文件')
setImporting(true)
setImportResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', importFile)
formData.append('month', month)
const res = await fetch('/api/v1/import/special-deduction', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) { toast.error(data.error?.message || '导入失败') }
else {
setImportResult(data.data)
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
toast.success(`导入完成:成功 ${data.data.updated}`)
}
} catch (e: any) { toast.error(e?.message || '导入失败') }
finally { setImporting(false) }
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
</div>
</div>
</div>
</Card>
</div>
)}
</Card>
)
}