Files
TurboHR/frontend/src/pages/SocialInsurance.tsx
T
selfrelease 54d138cc73 ux: 新建账户按钮移到社保/公积金Tab内部,按类型显示
按钮从页面顶部移到Tab内容区顶部,文案改为"新建社保账户"/"新建公积金账户",
明确表示新建的是当前Tab对应类型的账户。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:10:38 +08:00

498 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Calculator, Check, Plus, Download, AlertCircle, Clock } from 'lucide-react'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
import { socialInsuranceApi, socialAccountApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
import { AccountCard } from './social-insurance/AccountCard'
import { AccountFormModal } from './social-insurance/AccountFormModal'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function SocialInsurance() {
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
const initialTab = (searchParams.get('tab') as 'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment') || 'monthly'
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>(initialTab)
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
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)
// 账户管理相关 state
const [showAccountForm, setShowAccountForm] = useState(false)
const [editAccount, setEditAccount] = useState<any>(null)
// 获取账户列表(按当前 tab 类型)
const accountType = tab === 'housing' ? 'HOUSING' : 'SOCIAL'
const { data: accounts = [] } = useQuery<any[]>({
queryKey: ['social-accounts', accountType],
queryFn: () => socialAccountApi.list(accountType),
})
// 账户 CRUD mutations
const createAccountMutation = useMutation({
mutationFn: (data: any) => socialAccountApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setShowAccountForm(false)
toast.success('账户已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
const updateAccountMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => socialAccountApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setEditAccount(null)
setShowAccountForm(false)
toast.success('已更新')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
})
const deleteAccountMutation = useMutation({
mutationFn: (id: string) => socialAccountApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
toast.success('已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
// 已办理月份列表(进入月度办理Tab时自动加载)
const { data: processedList, refetch: refetchProcessedList } = useQuery<any[]>({
queryKey: ['monthly-process-list'],
queryFn: async () => {
return await socialInsuranceApi.monthlyProcessList()
},
enabled: tab === 'monthly',
})
// 进入月度办理Tab时自动查询当前月状态
useEffect(() => {
if (tab === 'monthly') {
socialInsuranceApi.monthlyProcessStatus(monthlyMonth).then((res: any) => {
setProcessStatus(res)
}).catch(() => {})
refetchProcessedList()
}
}, [tab])
const { mutateAsync: fetchMonthlyChanges, isPending: monthlyLoading, data: monthlyChanges } = useMutation<any>({
mutationFn: async () => {
const [socialRes, housingRes, socialActiveRes, housingActiveRes] = await Promise.all([
socialInsuranceApi.monthlyChanges(monthlyMonth) as any,
socialInsuranceApi.housingMonthlyChanges(monthlyMonth) as any,
socialInsuranceApi.activeDeclaration(monthlyMonth) as any,
socialInsuranceApi.housingActiveDeclaration(monthlyMonth) as any,
])
return {
social: socialRes,
housing: housingRes,
socialActive: socialActiveRes,
housingActive: housingActiveRes,
}
},
})
const handleMonthlyProcess = async () => {
try {
await fetchMonthlyChanges()
setMonthlyProcessed(true)
// 查询该月办理状态
const statusRes = await socialInsuranceApi.monthlyProcessStatus(monthlyMonth) as any
setProcessStatus(statusRes)
} 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 socialInsuranceApi.completeMonthlyProcess({
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 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)
}
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<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>
{/* Tab 切换 */}
<div className="flex items-center gap-4 border-b">
{(['monthly', 'social', 'housing', 'enrollment', '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); setMonthlyProcessed(false); setProcessStatus(null) }}
>
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
</button>
))}
</div>
{/* ========== 社保 / 公积金 Tab:账户卡片列表 ========== */}
{(tab === 'social' || tab === 'housing') && (
<div className="space-y-3">
{/* 新建对应类型账户按钮 */}
<div className="flex justify-end">
<Button size="sm" onClick={() => { setEditAccount(null); setShowAccountForm(true) }}>
<Plus className="w-4 h-4 mr-1" />{tab === 'housing' ? '公积金' : '社保'}
</Button>
</div>
{accounts.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
{tab === 'housing' ? '公积金' : '社保'}{tab === 'housing' ? '公积金' : '社保'}
</div>
</Card>
) : (
accounts.map((a: any) => (
<AccountCard
key={a.id}
account={a}
isHousing={tab === 'housing'}
onEdit={(acc) => { setEditAccount(acc); setShowAccountForm(true) }}
onDelete={(acc) => deleteAccountMutation.mutate(acc.id)}
/>
))
)}
{/* 账户新建/编辑弹窗 */}
{showAccountForm && (
<AccountFormModal
type={accountType}
account={editAccount}
onClose={() => { setShowAccountForm(false); setEditAccount(null) }}
onSubmit={async (data, departmentIds) => {
if (editAccount) {
updateAccountMutation.mutate({ id: editAccount.id, data })
if (departmentIds) {
await socialAccountApi.linkDepartments(editAccount.id, departmentIds)
toast.success('账户已更新,部门关联已同步')
}
} else {
createAccountMutation.mutate(
{ ...data, type: accountType },
{
onSuccess: async (created: any) => {
if (departmentIds && departmentIds.length > 0) {
await socialAccountApi.linkDepartments(created.id, departmentIds)
}
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
},
}
)
}
}}
saving={createAccountMutation.isPending || updateAccountMutation.isPending}
/>
)}
</div>
)}
{/* ========== 月度办理 Tab ========== */}
{tab === 'monthly' && (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<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>
)}
<InlineAlert type="info" className="mb-3">
///
</InlineAlert>
{(() => {
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>
</div>
)}
{/* ========== 员工参保信息 Tab ========== */}
{tab === 'enrollment' && (
<EmployeeEnrollmentTab />
)}
{/* ========== 专项附加扣除 Tab ========== */}
{tab === 'deduction' && (
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
)}
</div>
)
}