feat: 社保公积金账户化重构

新增 SocialAccount(账户)+ SocialYearStandard(年度标准)两层实体,
替代原 SocialInsuranceConfig/HousingFundConfig 按城市管理的方式。

- DB: 新增 SocialAccount、SocialYearStandard 表,Department 加账户关联
- 迁移: 旧 Config 表数据迁移到 Account + YearStandard
- 后端: 新增账户 CRUD + 年度标准 API,薪资计算适配 accountId
- 前端: 设置页新增账户管理 Tab,组织架构提示 level=0 可关联账户
- 前端: SocialInsurance.tsx 城市选择器改为账户选择器
- 兼容: 旧 Config 表保留,薪资计算回退旧表

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-16 13:50:15 +08:00
parent 454b3d4b05
commit 63b6c9dcc7
8 changed files with 674 additions and 81 deletions
+34
View File
@@ -515,6 +515,40 @@ export const salaryDashboardApi = {
// ========== 社保公积金相关 ==========
// 账户管理(新)
export const socialAccountApi = {
/** 账户列表 */
list: (type?: string) =>
get('/social/accounts', { params: type ? { type } : {} }).then(unwrap<any[]>()),
/** 新建账户 */
create: (data: { type: string; name: string; city: string; accountNo?: string; bankName?: string; bankAccount?: string; orgName?: string; orgCode?: string; accountType?: string; isDefault?: boolean; remark?: string }) =>
post('/social/accounts', data).then(unwrap<any>()),
/** 编辑账户 */
update: (id: string, data: Partial<{ type: string; name: string; city: string; accountNo: string; bankName: string; bankAccount: string; orgName: string; orgCode: string; accountType: string; isDefault: boolean; remark: string; status: string }>) =>
put(`/social/accounts/${id}`, data).then(unwrap<any>()),
/** 删除账户 */
remove: (id: string) =>
del(`/social/accounts/${id}`).then(unwrap<any>()),
/** 设为默认 */
setDefault: (id: string) =>
put(`/social/accounts/${id}/default`).then(unwrap<any>()),
/** 按账户获取年度标准列表 */
standards: (accountId: string) =>
get(`/social/accounts/${accountId}/standards`).then(unwrap<any[]>()),
/** 按账户获取当前生效标准 */
currentStandard: (accountId: string) =>
get(`/social/accounts/${accountId}/current-standard`).then(unwrap<any>()),
/** 按账户+月份获取适用标准 */
standardByMonth: (accountId: string, month: string) =>
get(`/social/accounts/${accountId}/standard-by-month/${month}`).then(unwrap<any>()),
/** 新建年度标准 */
createStandard: (accountId: string, data: any) =>
post(`/social/accounts/${accountId}/standards`, data).then(unwrap<any>()),
/** 按员工获取适用账户 */
employeeAccount: (employeeId: string) =>
get(`/social/employee-account/${employeeId}`).then(unwrap<any>()),
}
export const socialInsuranceApi = {
/** 城市列表 */
cities: () =>
+12 -1
View File
@@ -5,7 +5,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Building2, Briefcase } from 'lucide-react'
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Building2, Briefcase, Shield } from 'lucide-react'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -80,6 +80,11 @@ export default function OrgChart() {
<Building2 className={`w-4 h-4 ${isRoot ? 'text-primary' : 'text-gray-400'}`} />
<span className="flex-1 text-sm">{d.name}</span>
{isRoot && <span className="text-xs text-primary"></span>}
{d.level === 0 && !isRoot && (
<span className="flex items-center gap-0.5 text-xs text-gray-400" title="分公司/子公司可在设置-社保公积金账户中单独关联账户">
<Shield className="w-3 h-3" />
</span>
)}
<span className="text-xs text-gray-400">{d._count?.employees || 0}</span>
{!isRoot && (
<>
@@ -162,6 +167,12 @@ export default function OrgChart() {
{showModal && (
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑部门' : '新增部门'}>
<div className="space-y-3">
{editing?.level === 0 && (
<div className="flex items-start gap-2 p-2 bg-blue-50 rounded text-xs text-blue-700">
<Shield className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>/ </span>
</div>
)}
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:技术部" />
+235 -3
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2 } from 'lucide-react'
import { settingsApi, notificationsApi } from '../lib/api-services'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2, Shield, Edit2 } from 'lucide-react'
import { settingsApi, notificationsApi, socialAccountApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
import Card from '../components/ui/Card'
@@ -15,7 +15,7 @@ import PageGuide from '../components/ui/PageGuide'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'medical' | 'import' | 'export'>('org')
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'medical' | 'socialAccounts' | 'import' | 'export'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
@@ -43,6 +43,7 @@ export default function Settings() {
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
{ key: 'medical' as const, label: '医疗期政策', icon: HeartPulse },
{ key: 'socialAccounts' as const, label: '社保公积金账户', icon: Shield },
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
{ key: 'export' as const, label: '数据导出', icon: Download },
]
@@ -86,6 +87,7 @@ export default function Settings() {
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
)}
{activeSection === 'medical' && <MedicalPeriodSettings />}
{activeSection === 'socialAccounts' && <SocialAccountSettings />}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && <ExportSettings />}
</div>
@@ -1574,3 +1576,233 @@ function MedicalPolicyForm({ policy, onSave, onClose }: { policy: any; onSave: (
)
}
// ========== 社保公积金账户管理 ==========
function SocialAccountSettings() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [accountType, setAccountType] = useState<'SOCIAL' | 'HOUSING'>('SOCIAL')
const [showForm, setShowForm] = useState(false)
const [editAccount, setEditAccount] = useState<any>(null)
const { data: accounts, isLoading } = useQuery({
queryKey: ['social-accounts', accountType],
queryFn: () => socialAccountApi.list(accountType),
})
const createMutation = useMutation({
mutationFn: (data: any) => socialAccountApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setShowForm(false)
toast.success('账户已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => socialAccountApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setEditAccount(null)
setShowForm(false)
toast.success('已更新')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
})
const deleteMutation = 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 || '删除失败'),
})
const setDefaultMutation = useMutation({
mutationFn: (id: string) => socialAccountApi.setDefault(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
toast.success('已设为默认')
},
})
return (
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium"></h2>
<p className="text-xs text-gray-500 mt-0.5">//</p>
</div>
<Button size="sm" onClick={() => { setEditAccount(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 类型切换 */}
<div className="flex gap-1 border-b">
{(['SOCIAL', 'HOUSING'] as const).map((t) => (
<button
key={t}
onClick={() => setAccountType(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
accountType === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{t === 'SOCIAL' ? '社保账户' : '公积金账户'}
</button>
))}
</div>
{/* 账户列表 */}
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !accounts || accounts.length === 0 ? (
<div className="text-center py-8 text-gray-400">
{accountType === 'SOCIAL' ? '社保' : '公积金'}"新建账户"
</div>
) : (
<div className="space-y-3">
{accounts.map((a: any) => (
<div key={a.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{a.name}</span>
{a.isDefault && <span className="px-1.5 py-0.5 rounded text-xs bg-green-50 text-safe"></span>}
{a.status === 'SUSPENDED' && <span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-500"></span>}
</div>
<div className="text-xs text-gray-500 space-y-0.5">
<div>{a.city} {a.accountNo && `· 账号:${a.accountNo}`}</div>
{a.orgName && <div>{a.orgName} {a.orgCode && `${a.orgCode}`}</div>}
{a.type === 'HOUSING' && a.bankName && <div>{a.bankName} {a.bankAccount && `· ${a.bankAccount}`}</div>}
{a.accountType && a.type === 'HOUSING' && <div>{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</div>}
<div>{(a._count?.deptSocialAccounts || 0) + (a._count?.deptHousingAccounts || 0)} · {(a._count?.socialRecords || 0) + (a._count?.housingRecords || 0)} </div>
</div>
</div>
<div className="flex items-center gap-1">
{!a.isDefault && a.status === 'ACTIVE' && (
<button onClick={() => setDefaultMutation.mutate(a.id)} className="text-xs text-primary hover:underline" title="设为默认"></button>
)}
<button onClick={() => { setEditAccount(a); setShowForm(true) }} className="p-1 text-gray-400 hover:text-primary" title="编辑">
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={async () => {
if (await confirm({ title: '删除确认', message: `确认删除账户"${a.name}"?关联的参保记录不会被删除。`, variant: 'danger' })) {
deleteMutation.mutate(a.id)
}
}}
className="p-1 text-gray-400 hover:text-danger" title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* 新建/编辑弹窗 */}
{showForm && (
<AccountFormModal
type={accountType}
account={editAccount}
onClose={() => { setShowForm(false); setEditAccount(null) }}
onSubmit={(data) => {
if (editAccount) {
updateMutation.mutate({ id: editAccount.id, data })
} else {
createMutation.mutate({ ...data, type: accountType })
}
}}
saving={createMutation.isPending || updateMutation.isPending}
/>
)}
</Card>
)
}
function AccountFormModal({ type, account, onClose, onSubmit, saving }: {
type: string
account: any
onClose: () => void
onSubmit: (data: any) => void
saving: boolean
}) {
const [name, setName] = useState(account?.name || '')
const [city, setCity] = useState(account?.city || '')
const [accountNo, setAccountNo] = useState(account?.accountNo || '')
const [orgName, setOrgName] = useState(account?.orgName || '')
const [orgCode, setOrgCode] = useState(account?.orgCode || '')
const [bankName, setBankName] = useState(account?.bankName || '')
const [bankAccount, setBankAccount] = useState(account?.bankAccount || '')
const [accountTypeVal, setAccountTypeVal] = useState(account?.accountType || 'BASIC')
const [isDefault, setIsDefault] = useState(account?.isDefault || false)
const [remark, setRemark] = useState(account?.remark || '')
return (
<Modal open onClose={onClose} title={account ? '编辑账户' : '新建账户'} size="md">
<div className="space-y-4">
<div>
<Label> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:北京总公司社保账户" />
</div>
<div>
<Label> *</Label>
<Input value={city} onChange={(e) => setCity(e.target.value)} placeholder="如:北京" />
</div>
<div>
<Label>{type === 'SOCIAL' ? '社保登记号' : '公积金单位账号'}</Label>
<Input value={accountNo} onChange={(e) => setAccountNo(e.target.value)} />
</div>
{type === 'HOUSING' && (
<>
<div>
<Label></Label>
<Select value={accountTypeVal} onChange={(e) => setAccountTypeVal(e.target.value)}>
<option value="BASIC"></option>
<option value="SUPPLEMENTARY"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="如:工商银行北京分行" />
</div>
<div>
<Label></Label>
<Input value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
</div>
</>
)}
<div>
<Label></Label>
<Input value={orgName} onChange={(e) => setOrgName(e.target.value)} placeholder="子公司/分公司名称" />
</div>
<div>
<Label></Label>
<Input value={orgCode} onChange={(e) => setOrgCode(e.target.value)} />
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button
disabled={!name || !city || saving}
onClick={() => onSubmit({ name, city, accountNo, orgName, orgCode, bankName, bankAccount, accountType: accountTypeVal, isDefault, remark })}
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
+54 -60
View File
@@ -3,10 +3,10 @@ import { useSearchParams } from 'react-router-dom'
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 } from 'lucide-react'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, Sparkles } from 'lucide-react'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
import { socialInsuranceApi } from '../lib/api-services'
import { socialInsuranceApi, socialAccountApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
@@ -24,6 +24,7 @@ export default function SocialInsurance() {
const initialTab = (searchParams.get('tab') as 'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment') || 'monthly'
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>(initialTab)
const [city, setCity] = useState<string>('北京')
const [selectedAccountId, setSelectedAccountId] = useState<string>('')
const [showAddCity, setShowAddCity] = useState(false)
const [newCityName, setNewCityName] = useState('')
const [base, setBase] = useState(8000)
@@ -56,14 +57,26 @@ export default function SocialInsurance() {
baseMin: 6326, baseMax: 33891,
})
// 获取城市列表
const { data: cities = [] } = useQuery<string[]>({
queryKey: ['social-config-cities'],
queryFn: async () => {
return await socialInsuranceApi.cities()
},
// 获取账户列表(按当前 tab 类型)
const accountType = tab === 'housing' ? 'HOUSING' : 'SOCIAL'
const { data: accounts = [] } = useQuery<any[]>({
queryKey: ['social-accounts', accountType],
queryFn: () => socialAccountApi.list(accountType),
})
// 选中账户变化时同步 city
useEffect(() => {
if (selectedAccountId) {
const acc = accounts.find((a: any) => a.id === selectedAccountId)
if (acc) setCity(acc.city)
} else if (accounts.length > 0) {
// 默认选第一个(或默认账户)
const def = accounts.find((a: any) => a.isDefault) || accounts[0]
setSelectedAccountId(def.id)
setCity(def.city)
}
}, [accounts, selectedAccountId])
const { data: config, isLoading: configLoading } = useQuery<any>({
queryKey: ['social-config', city],
queryFn: async () => {
@@ -185,10 +198,16 @@ export default function SocialInsurance() {
})
const createVersionMutation = useMutation({
mutationFn: (data: any) => socialInsuranceApi.createConfigVersion(data),
mutationFn: (data: any) => {
if (selectedAccountId) {
return socialAccountApi.createStandard(selectedAccountId, data)
}
return socialInsuranceApi.createConfigVersion(data)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setShowNewVersion(false)
toast.success('新版本已创建,旧版本已自动归档')
},
@@ -199,10 +218,16 @@ export default function SocialInsurance() {
})
const createHousingVersionMutation = useMutation({
mutationFn: (data: any) => socialInsuranceApi.createHousingConfigVersion(data),
mutationFn: (data: any) => {
if (selectedAccountId) {
return socialAccountApi.createStandard(selectedAccountId, data)
}
return socialInsuranceApi.createHousingConfigVersion(data)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
setShowNewVersion(false)
toast.success('公积金新版本已创建,旧版本已自动归档')
},
@@ -394,53 +419,23 @@ export default function SocialInsurance() {
))}
{(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>
)}
<label className="text-sm text-gray-500">:</label>
<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={selectedAccountId}
onChange={(e) => {
const acc = accounts.find((a: any) => a.id === e.target.value)
setSelectedAccountId(e.target.value)
if (acc) setCity(acc.city)
}}
>
{accounts.length === 0 && <option value=""></option>}
{accounts.map((a: any) => (
<option key={a.id} value={a.id}>
{a.name}{a.city}{a.isDefault ? ' · 默认' : ''}
</option>
))}
</select>
</div>
)}
</div>
@@ -708,9 +703,8 @@ export default function SocialInsurance() {
<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>
<Input value={city} disabled className="bg-gray-50" />
<p className="text-xs text-gray-400 mt-1"></p>
</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>