diff --git a/20260816-社保优化.md b/20260816-社保优化.md index 45f953b..70717da 100644 --- a/20260816-社保优化.md +++ b/20260816-社保优化.md @@ -290,8 +290,10 @@ JOIN "SocialAccount" a ON a."orgId" = c."orgId" AND a.city = c.city AND a.type = ### 1. 设置页新增"社保公积金账户管理" - 账户列表(按 type 分社保/公积金 Tab) -- 每个账户卡片:名称、城市、账户编号、缴费主体、是否默认 +- 每个账户卡片:名称、城市、账户编号、缴费主体、关联范围(公司/分公司/子公司)、是否默认 - 新增/编辑/停用账户弹窗 +- **关联级别**:公司(Organization)或根部门(Department level=0,代表分公司/子公司),不向下到普通部门 +- 员工通过所属根部门自动继承账户,未关联根部门的员工使用公司默认账户 ### 2. SocialInsurance.tsx 改造 diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 42938f2..c376569 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -486,6 +486,9 @@ model SocialAccount { socialRecords EmployeeSocialInsRecord[] housingRecords EmployeeHousingFundRecord[] monthlyProcesses SocialMonthlyProcess[] + // 部门关联(员工通过部门继承账户) + deptSocialAccounts Department[] @relation("DeptSocialAccount") + deptHousingAccounts Department[] @relation("DeptHousingAccount") @@unique([orgId, type, name]) @@index([orgId, type, city]) @@ -1675,6 +1678,11 @@ model Department { level Int @default(0) // 层级(0=根) sortOrder Int @default(0) // 同级排序 description String? + // 社保公积金账户关联(员工通过部门继承账户) + socialAccountId String? // 社保账户 + socialAccount SocialAccount? @relation("DeptSocialAccount", fields: [socialAccountId], references: [id], onDelete: SetNull) + housingAccountId String? // 公积金账户 + housingAccount SocialAccount? @relation("DeptHousingAccount", fields: [housingAccountId], references: [id], onDelete: SetNull) createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index 661a5f6..1eb96e3 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -34,6 +34,247 @@ const housingConfigFields = { baseMax: z.number().optional(), } +// ========================================== +// 账户管理 API(新) +// ========================================== + +const accountSchema = z.object({ + type: z.enum(['SOCIAL', 'HOUSING']), + name: z.string().min(1), + city: z.string().min(1), + accountNo: z.string().optional(), + bankName: z.string().optional(), + bankAccount: z.string().optional(), + orgName: z.string().optional(), + orgCode: z.string().optional(), + accountType: z.string().optional(), + isDefault: z.boolean().optional(), + remark: z.string().optional(), +}) + +// 账户列表 +router.get('/accounts', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const type = req.query.type as string | undefined + const where: any = { orgId } + if (type) where.type = type + const accounts = await prisma.socialAccount.findMany({ + where, + orderBy: [{ type: 'asc' }, { isDefault: 'desc' }, { city: 'asc' }], + include: { + _count: { select: { socialRecords: true, housingRecords: true, deptSocialAccounts: true, deptHousingAccounts: true } }, + }, + }) + res.json({ success: true, data: accounts }) + } catch (err) { next(err) } +}) + +// 新建账户 +router.post('/accounts', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const data = accountSchema.parse(req.body) + // 如果设为默认,先取消同 type 其他默认 + if (data.isDefault) { + await prisma.socialAccount.updateMany({ where: { orgId, type: data.type, isDefault: true }, data: { isDefault: false } }) + } + const account = await prisma.socialAccount.create({ + data: { ...data, orgId, createdBy: req.user!.id }, + }) + res.json({ success: true, data: account }) + } catch (err) { next(err) } +}) + +// 编辑账户 +router.put('/accounts/:id', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const data = accountSchema.partial().parse(req.body) + if (data.isDefault) { + const account = await prisma.socialAccount.findUnique({ where: { id: req.params.id } }) + await prisma.socialAccount.updateMany({ where: { orgId, type: account?.type, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } }) + } + const account = await prisma.socialAccount.update({ + where: { id: req.params.id }, + data, + }) + res.json({ success: true, data: account }) + } catch (err) { next(err) } +}) + +// 删除账户(无关联记录时可删) +router.delete('/accounts/:id', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const account = await prisma.socialAccount.findFirst({ where: { id: req.params.id, orgId } }) + if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } }) + // 检查是否有关联记录 + const [socialCount, housingCount, deptCount] = await Promise.all([ + prisma.employeeSocialInsRecord.count({ where: { accountId: account.id } }), + prisma.employeeHousingFundRecord.count({ where: { accountId: account.id } }), + prisma.department.count({ where: { OR: [{ socialAccountId: account.id }, { housingAccountId: account.id }] } }), + ]) + if (socialCount + housingCount + deptCount > 0) { + return res.status(400).json({ success: false, error: { code: 'IN_USE', message: `账户仍关联 ${socialCount + housingCount} 条参保记录、${deptCount} 个部门,无法删除` } }) + } + await prisma.socialAccount.delete({ where: { id: account.id } }) + res.json({ success: true, data: { message: '已删除' } }) + } catch (err) { next(err) } +}) + +// 设为默认账户 +router.put('/accounts/:id/default', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const account = await prisma.socialAccount.findFirst({ where: { id: req.params.id, orgId } }) + if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } }) + await prisma.socialAccount.updateMany({ where: { orgId, type: account.type, isDefault: true }, data: { isDefault: false } }) + await prisma.socialAccount.update({ where: { id: account.id }, data: { isDefault: true } }) + res.json({ success: true, data: { message: '已设为默认' } }) + } catch (err) { next(err) } +}) + +// ========================================== +// 年度标准 API(新,按 accountId) +// ========================================== + +// 按账户获取年度标准列表 +router.get('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { accountId } = req.params + const standards = await prisma.socialYearStandard.findMany({ + where: { orgId, accountId }, + orderBy: { effectiveFrom: 'desc' }, + }) + res.json({ success: true, data: standards }) + } catch (err) { next(err) } +}) + +// 按账户获取当前生效标准 +router.get('/accounts/:accountId/current-standard', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { accountId } = req.params + const standard = await prisma.socialYearStandard.findFirst({ + where: { orgId, accountId, isCurrent: true }, + orderBy: { effectiveFrom: 'desc' }, + }) + res.json({ success: true, data: standard }) + } catch (err) { next(err) } +}) + +// 按账户+月份获取适用标准 +router.get('/accounts/:accountId/standard-by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { accountId, month } = req.params + const standard = await prisma.socialYearStandard.findFirst({ + where: { + orgId, accountId, + effectiveFrom: { lte: month }, + OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], + }, + orderBy: { effectiveFrom: 'desc' }, + }) + if (!standard) { + const current = await prisma.socialYearStandard.findFirst({ where: { orgId, accountId, isCurrent: true } }) + return res.json({ success: true, data: current }) + } + res.json({ success: true, data: standard }) + } catch (err) { next(err) } +}) + +// 新建年度标准 +const yearStandardSchema = z.object({ + effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/), + pensionOrg: z.number().optional(), + pensionEmp: z.number().optional(), + medicalOrg: z.number().optional(), + medicalEmp: z.number().optional(), + unemploymentOrg: z.number().optional(), + unemploymentEmp: z.number().optional(), + injuryOrg: z.number().optional(), + maternityOrg: z.number().optional(), + baseMin: z.number().optional(), + baseMax: z.number().optional(), + medicalBaseMin: z.number().optional(), + medicalBaseMax: z.number().optional(), + extraInsurances: z.any().optional(), + housingOrg: z.number().optional(), + housingEmp: z.number().optional(), +}) + +router.post('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { accountId } = req.params + const data = yearStandardSchema.parse(req.body) + const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } }) + if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } }) + + // 将旧当前版本标记为失效 + const current = await prisma.socialYearStandard.findFirst({ where: { accountId, isCurrent: true } }) + if (current) { + const prevMonth = data.effectiveFrom + await prisma.socialYearStandard.update({ + where: { id: current.id }, + data: { isCurrent: false, effectiveTo: prevMonth }, + }) + } + + const standard = await prisma.socialYearStandard.create({ + data: { ...data, orgId, accountId, isCurrent: true, createdBy: req.user!.id }, + }) + res.json({ success: true, data: standard }) + } catch (err) { next(err) } +}) + +// 按员工获取适用账户(通过根部门 level=0 继承,不向下到普通部门) +router.get('/employee-account/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { employeeId } = req.params + const emp = await prisma.employee.findFirst({ + where: { id: employeeId, orgId }, + include: { dept: true }, + }) + if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) + + // 向上找到 level=0 的根部门(代表分公司/子公司) + let currentDept: any = emp.dept + while (currentDept && currentDept.level > 0 && currentDept.parentId) { + currentDept = await prisma.department.findUnique({ + where: { id: currentDept.parentId }, + }) + } + const rootDeptId = currentDept?.id || null + + // 从根部门获取关联账户 + let socialAccount: any = null + let housingAccount: any = null + if (rootDeptId) { + const rootDept = await prisma.department.findUnique({ + where: { id: rootDeptId }, + include: { socialAccount: true, housingAccount: true }, + }) + socialAccount = rootDept?.socialAccount || null + housingAccount = rootDept?.housingAccount || null + } + + // 回退到公司默认账户 + if (!socialAccount) { + socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } }) + } + if (!housingAccount) { + housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } }) + } + + res.json({ success: true, data: { socialAccount, housingAccount } }) + } catch (err) { next(err) } +}) + // 获取当前生效版本(支持按城市筛选) router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => { try { diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index 168790d..b88b513 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -1,5 +1,69 @@ import prisma from '../lib/prisma' +// ========== 社保公积金账户辅助函数 ========== + +/** + * 通过员工获取适用的社保/公积金账户 + * 优先从员工所属根部门(level=0)关联的账户继承,回退到公司默认账户 + */ +async function getEmployeeAccounts(orgId: string, employeeId: string) { + const emp = await prisma.employee.findFirst({ + where: { id: employeeId, orgId }, + include: { dept: true }, + }) + if (!emp) return { socialAccount: null, housingAccount: null } + + // 向上找到 level=0 的根部门 + let currentDept: any = emp.dept + while (currentDept && currentDept.level > 0 && currentDept.parentId) { + currentDept = await prisma.department.findUnique({ where: { id: currentDept.parentId } }) + } + const rootDeptId = currentDept?.id || null + + let socialAccount: any = null + let housingAccount: any = null + if (rootDeptId) { + const rootDept = await prisma.department.findUnique({ + where: { id: rootDeptId }, + include: { socialAccount: true, housingAccount: true }, + }) + socialAccount = rootDept?.socialAccount || null + housingAccount = rootDept?.housingAccount || null + } + + // 回退到公司默认账户 + if (!socialAccount) { + socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } }) + } + if (!housingAccount) { + housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } }) + } + + return { socialAccount, housingAccount } +} + +/** + * 通过账户获取指定月份的年度标准 + */ +async function getStandardByAccountAndMonth(accountId: string, month: string) { + const standard = await prisma.socialYearStandard.findFirst({ + where: { + accountId, + effectiveFrom: { lte: month }, + OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], + }, + orderBy: { effectiveFrom: 'desc' }, + }) + if (!standard) { + // 回退到当前生效标准 + return prisma.socialYearStandard.findFirst({ + where: { accountId, isCurrent: true }, + orderBy: { effectiveFrom: 'desc' }, + }) + } + return standard +} + // ========== 薪酬模版 ========== const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [ @@ -142,25 +206,32 @@ export async function calcBatchEntry( const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } }) if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' } + // 通过员工账户查年度标准(新逻辑),回退到旧配置(兼容) + const { socialAccount, housingAccount } = await getEmployeeAccounts(orgId, employeeId) + let socialConfig: any = null + let housingConfig: any = null + + if (socialAccount) { + socialConfig = await getStandardByAccountAndMonth(socialAccount.id, month) + } + if (housingAccount) { + housingConfig = await getStandardByAccountAndMonth(housingAccount.id, month) + } + + // 回退到旧配置表(兼容未迁移数据) const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId } - const [socialConfig, housingConfig] = await Promise.all([ - prisma.socialInsuranceConfig.findFirst({ - where: { - ...cityWhere, - effectiveFrom: { lte: month }, - OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], - }, + if (!socialConfig) { + socialConfig = await prisma.socialInsuranceConfig.findFirst({ + where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] }, orderBy: { effectiveFrom: 'desc' }, - }), - prisma.housingFundConfig.findFirst({ - where: { - ...cityWhere, - effectiveFrom: { lte: month }, - OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], - }, + }) + } + if (!housingConfig) { + housingConfig = await prisma.housingFundConfig.findFirst({ + where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] }, orderBy: { effectiveFrom: 'desc' }, - }), - ]) + }) + } // 社保基数:优先用员工核定基数,否则用基本工资 const socialBase = employee.socialInsBase || inputs.baseSalary diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index d49c5e7..709f9e8 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -515,6 +515,40 @@ export const salaryDashboardApi = { // ========== 社保公积金相关 ========== +// 账户管理(新) +export const socialAccountApi = { + /** 账户列表 */ + list: (type?: string) => + get('/social/accounts', { params: type ? { type } : {} }).then(unwrap()), + /** 新建账户 */ + 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()), + /** 编辑账户 */ + 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()), + /** 删除账户 */ + remove: (id: string) => + del(`/social/accounts/${id}`).then(unwrap()), + /** 设为默认 */ + setDefault: (id: string) => + put(`/social/accounts/${id}/default`).then(unwrap()), + /** 按账户获取年度标准列表 */ + standards: (accountId: string) => + get(`/social/accounts/${accountId}/standards`).then(unwrap()), + /** 按账户获取当前生效标准 */ + currentStandard: (accountId: string) => + get(`/social/accounts/${accountId}/current-standard`).then(unwrap()), + /** 按账户+月份获取适用标准 */ + standardByMonth: (accountId: string, month: string) => + get(`/social/accounts/${accountId}/standard-by-month/${month}`).then(unwrap()), + /** 新建年度标准 */ + createStandard: (accountId: string, data: any) => + post(`/social/accounts/${accountId}/standards`, data).then(unwrap()), + /** 按员工获取适用账户 */ + employeeAccount: (employeeId: string) => + get(`/social/employee-account/${employeeId}`).then(unwrap()), +} + export const socialInsuranceApi = { /** 城市列表 */ cities: () => diff --git a/frontend/src/pages/OrgChart.tsx b/frontend/src/pages/OrgChart.tsx index f95043c..41693b6 100644 --- a/frontend/src/pages/OrgChart.tsx +++ b/frontend/src/pages/OrgChart.tsx @@ -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() { {d.name} {isRoot && 公司} + {d.level === 0 && !isRoot && ( + + 可关联社保账户 + + )} {d._count?.employees || 0}人 {!isRoot && ( <> @@ -162,6 +167,12 @@ export default function OrgChart() { {showModal && ( setShowModal(false)} title={editing ? '编辑部门' : '新增部门'}>
+ {editing?.level === 0 && ( +
+ + 此部门为分公司/子公司节点,可在「设置 → 社保公积金账户」中单独关联社保、公积金账户,员工将通过此关联自动继承对应账户。 +
+ )}
setForm({ ...form, name: e.target.value })} placeholder="如:技术部" /> diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 4d27c8c..0218679 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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({ 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() { updateOrgMutation.mutate(data)} /> )} {activeSection === 'medical' && } + {activeSection === 'socialAccounts' && } {activeSection === 'import' && } {activeSection === 'export' && }
@@ -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(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 ( + +
+
+

社保公积金账户管理

+

管理社保、公积金账户,关联公司/分公司/子公司。员工通过所属根部门自动继承账户。

+
+ +
+ + {/* 类型切换 */} +
+ {(['SOCIAL', 'HOUSING'] as const).map((t) => ( + + ))} +
+ + {/* 账户列表 */} + {isLoading ? ( +
加载中...
+ ) : !accounts || accounts.length === 0 ? ( +
+ 暂无{accountType === 'SOCIAL' ? '社保' : '公积金'}账户,点击"新建账户"创建 +
+ ) : ( +
+ {accounts.map((a: any) => ( +
+
+
+
+ {a.name} + {a.isDefault && 默认} + {a.status === 'SUSPENDED' && 已停用} +
+
+
城市:{a.city} {a.accountNo && `· 账号:${a.accountNo}`}
+ {a.orgName &&
缴费主体:{a.orgName} {a.orgCode && `(${a.orgCode})`}
} + {a.type === 'HOUSING' && a.bankName &&
开户行:{a.bankName} {a.bankAccount && `· ${a.bankAccount}`}
} + {a.accountType && a.type === 'HOUSING' &&
账户类型:{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}
} +
关联:{(a._count?.deptSocialAccounts || 0) + (a._count?.deptHousingAccounts || 0)} 个根部门 · {(a._count?.socialRecords || 0) + (a._count?.housingRecords || 0)} 条参保记录
+
+
+
+ {!a.isDefault && a.status === 'ACTIVE' && ( + + )} + + +
+
+
+ ))} +
+ )} + + {/* 新建/编辑弹窗 */} + {showForm && ( + { 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} + /> + )} +
+ ) +} + +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 ( + +
+
+ + setName(e.target.value)} placeholder="如:北京总公司社保账户" /> +
+
+ + setCity(e.target.value)} placeholder="如:北京" /> +
+
+ + setAccountNo(e.target.value)} /> +
+ {type === 'HOUSING' && ( + <> +
+ + +
+
+ + setBankName(e.target.value)} placeholder="如:工商银行北京分行" /> +
+
+ + setBankAccount(e.target.value)} /> +
+ + )} +
+ + setOrgName(e.target.value)} placeholder="子公司/分公司名称" /> +
+
+ + setOrgCode(e.target.value)} /> +
+
+ + setRemark(e.target.value)} /> +
+ +
+ + +
+
+
+ ) +} + diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index 0731f8b..13a4e3a 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -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('北京') + const [selectedAccountId, setSelectedAccountId] = useState('') 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({ - queryKey: ['social-config-cities'], - queryFn: async () => { - return await socialInsuranceApi.cities() - }, + // 获取账户列表(按当前 tab 类型) + const accountType = tab === 'housing' ? 'HOUSING' : 'SOCIAL' + const { data: accounts = [] } = useQuery({ + 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({ 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') && (
- - {showAddCity ? ( -
- 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('') } - }} - /> - - -
- ) : ( -
- - -
- )} + +
)}
@@ -708,9 +703,8 @@ export default function SocialInsurance() {
activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} />
- + +

从所选账户继承

activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} />
activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} />