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:
+3
-1
@@ -290,8 +290,10 @@ JOIN "SocialAccount" a ON a."orgId" = c."orgId" AND a.city = c.city AND a.type =
|
|||||||
### 1. 设置页新增"社保公积金账户管理"
|
### 1. 设置页新增"社保公积金账户管理"
|
||||||
|
|
||||||
- 账户列表(按 type 分社保/公积金 Tab)
|
- 账户列表(按 type 分社保/公积金 Tab)
|
||||||
- 每个账户卡片:名称、城市、账户编号、缴费主体、是否默认
|
- 每个账户卡片:名称、城市、账户编号、缴费主体、关联范围(公司/分公司/子公司)、是否默认
|
||||||
- 新增/编辑/停用账户弹窗
|
- 新增/编辑/停用账户弹窗
|
||||||
|
- **关联级别**:公司(Organization)或根部门(Department level=0,代表分公司/子公司),不向下到普通部门
|
||||||
|
- 员工通过所属根部门自动继承账户,未关联根部门的员工使用公司默认账户
|
||||||
|
|
||||||
### 2. SocialInsurance.tsx 改造
|
### 2. SocialInsurance.tsx 改造
|
||||||
|
|
||||||
|
|||||||
@@ -486,6 +486,9 @@ model SocialAccount {
|
|||||||
socialRecords EmployeeSocialInsRecord[]
|
socialRecords EmployeeSocialInsRecord[]
|
||||||
housingRecords EmployeeHousingFundRecord[]
|
housingRecords EmployeeHousingFundRecord[]
|
||||||
monthlyProcesses SocialMonthlyProcess[]
|
monthlyProcesses SocialMonthlyProcess[]
|
||||||
|
// 部门关联(员工通过部门继承账户)
|
||||||
|
deptSocialAccounts Department[] @relation("DeptSocialAccount")
|
||||||
|
deptHousingAccounts Department[] @relation("DeptHousingAccount")
|
||||||
|
|
||||||
@@unique([orgId, type, name])
|
@@unique([orgId, type, name])
|
||||||
@@index([orgId, type, city])
|
@@index([orgId, type, city])
|
||||||
@@ -1675,6 +1678,11 @@ model Department {
|
|||||||
level Int @default(0) // 层级(0=根)
|
level Int @default(0) // 层级(0=根)
|
||||||
sortOrder Int @default(0) // 同级排序
|
sortOrder Int @default(0) // 同级排序
|
||||||
description String?
|
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
|
createdBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -34,6 +34,247 @@ const housingConfigFields = {
|
|||||||
baseMax: z.number().optional(),
|
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) => {
|
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,69 @@
|
|||||||
import prisma from '../lib/prisma'
|
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 }[] = [
|
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 } })
|
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||||||
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
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 cityWhere = employee.city ? { orgId, city: employee.city } : { orgId }
|
||||||
const [socialConfig, housingConfig] = await Promise.all([
|
if (!socialConfig) {
|
||||||
prisma.socialInsuranceConfig.findFirst({
|
socialConfig = await prisma.socialInsuranceConfig.findFirst({
|
||||||
where: {
|
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||||
...cityWhere,
|
|
||||||
effectiveFrom: { lte: month },
|
|
||||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
|
||||||
},
|
|
||||||
orderBy: { effectiveFrom: 'desc' },
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
}),
|
})
|
||||||
prisma.housingFundConfig.findFirst({
|
}
|
||||||
where: {
|
if (!housingConfig) {
|
||||||
...cityWhere,
|
housingConfig = await prisma.housingFundConfig.findFirst({
|
||||||
effectiveFrom: { lte: month },
|
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
|
||||||
},
|
|
||||||
orderBy: { effectiveFrom: 'desc' },
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
}),
|
})
|
||||||
])
|
}
|
||||||
|
|
||||||
// 社保基数:优先用员工核定基数,否则用基本工资
|
// 社保基数:优先用员工核定基数,否则用基本工资
|
||||||
const socialBase = employee.socialInsBase || inputs.baseSalary
|
const socialBase = employee.socialInsBase || inputs.baseSalary
|
||||||
|
|||||||
@@ -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 = {
|
export const socialInsuranceApi = {
|
||||||
/** 城市列表 */
|
/** 城市列表 */
|
||||||
cities: () =>
|
cities: () =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
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 Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import { Input, Label, Select } from '../components/ui/Input'
|
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'}`} />
|
<Building2 className={`w-4 h-4 ${isRoot ? 'text-primary' : 'text-gray-400'}`} />
|
||||||
<span className="flex-1 text-sm">{d.name}</span>
|
<span className="flex-1 text-sm">{d.name}</span>
|
||||||
{isRoot && <span className="text-xs text-primary">公司</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>
|
<span className="text-xs text-gray-400">{d._count?.employees || 0}人</span>
|
||||||
{!isRoot && (
|
{!isRoot && (
|
||||||
<>
|
<>
|
||||||
@@ -162,6 +167,12 @@ export default function OrgChart() {
|
|||||||
{showModal && (
|
{showModal && (
|
||||||
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑部门' : '新增部门'}>
|
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑部门' : '新增部门'}>
|
||||||
<div className="space-y-3">
|
<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>
|
<div>
|
||||||
<Label>部门名称 *</Label>
|
<Label>部门名称 *</Label>
|
||||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:技术部" />
|
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:技术部" />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2, Shield, Edit2 } from 'lucide-react'
|
||||||
import { settingsApi, notificationsApi } from '../lib/api-services'
|
import { settingsApi, notificationsApi, socialAccountApi } from '../lib/api-services'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
@@ -15,7 +15,7 @@ import PageGuide from '../components/ui/PageGuide'
|
|||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const queryClient = useQueryClient()
|
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>({
|
const { data: orgData } = useQuery<any>({
|
||||||
queryKey: ['org-settings'],
|
queryKey: ['org-settings'],
|
||||||
@@ -43,6 +43,7 @@ export default function Settings() {
|
|||||||
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
|
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
|
||||||
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
|
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
|
||||||
{ key: 'medical' as const, label: '医疗期政策', icon: HeartPulse },
|
{ key: 'medical' as const, label: '医疗期政策', icon: HeartPulse },
|
||||||
|
{ key: 'socialAccounts' as const, label: '社保公积金账户', icon: Shield },
|
||||||
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
|
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
|
||||||
{ key: 'export' as const, label: '数据导出', icon: Download },
|
{ key: 'export' as const, label: '数据导出', icon: Download },
|
||||||
]
|
]
|
||||||
@@ -86,6 +87,7 @@ export default function Settings() {
|
|||||||
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
|
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
|
||||||
)}
|
)}
|
||||||
{activeSection === 'medical' && <MedicalPeriodSettings />}
|
{activeSection === 'medical' && <MedicalPeriodSettings />}
|
||||||
|
{activeSection === 'socialAccounts' && <SocialAccountSettings />}
|
||||||
{activeSection === 'import' && <ImportSettings />}
|
{activeSection === 'import' && <ImportSettings />}
|
||||||
{activeSection === 'export' && <ExportSettings />}
|
{activeSection === 'export' && <ExportSettings />}
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useSearchParams } from 'react-router-dom'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
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 { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import PageGuide from '../components/ui/PageGuide'
|
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 Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import { Input, Label } from '../components/ui/Input'
|
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 initialTab = (searchParams.get('tab') as 'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment') || 'monthly'
|
||||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>(initialTab)
|
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>(initialTab)
|
||||||
const [city, setCity] = useState<string>('北京')
|
const [city, setCity] = useState<string>('北京')
|
||||||
|
const [selectedAccountId, setSelectedAccountId] = useState<string>('')
|
||||||
const [showAddCity, setShowAddCity] = useState(false)
|
const [showAddCity, setShowAddCity] = useState(false)
|
||||||
const [newCityName, setNewCityName] = useState('')
|
const [newCityName, setNewCityName] = useState('')
|
||||||
const [base, setBase] = useState(8000)
|
const [base, setBase] = useState(8000)
|
||||||
@@ -56,14 +57,26 @@ export default function SocialInsurance() {
|
|||||||
baseMin: 6326, baseMax: 33891,
|
baseMin: 6326, baseMax: 33891,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取城市列表
|
// 获取账户列表(按当前 tab 类型)
|
||||||
const { data: cities = [] } = useQuery<string[]>({
|
const accountType = tab === 'housing' ? 'HOUSING' : 'SOCIAL'
|
||||||
queryKey: ['social-config-cities'],
|
const { data: accounts = [] } = useQuery<any[]>({
|
||||||
queryFn: async () => {
|
queryKey: ['social-accounts', accountType],
|
||||||
return await socialInsuranceApi.cities()
|
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>({
|
const { data: config, isLoading: configLoading } = useQuery<any>({
|
||||||
queryKey: ['social-config', city],
|
queryKey: ['social-config', city],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -185,10 +198,16 @@ export default function SocialInsurance() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createVersionMutation = useMutation({
|
const createVersionMutation = useMutation({
|
||||||
mutationFn: (data: any) => socialInsuranceApi.createConfigVersion(data),
|
mutationFn: (data: any) => {
|
||||||
|
if (selectedAccountId) {
|
||||||
|
return socialAccountApi.createStandard(selectedAccountId, data)
|
||||||
|
}
|
||||||
|
return socialInsuranceApi.createConfigVersion(data)
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||||
setShowNewVersion(false)
|
setShowNewVersion(false)
|
||||||
toast.success('新版本已创建,旧版本已自动归档')
|
toast.success('新版本已创建,旧版本已自动归档')
|
||||||
},
|
},
|
||||||
@@ -199,10 +218,16 @@ export default function SocialInsurance() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createHousingVersionMutation = useMutation({
|
const createHousingVersionMutation = useMutation({
|
||||||
mutationFn: (data: any) => socialInsuranceApi.createHousingConfigVersion(data),
|
mutationFn: (data: any) => {
|
||||||
|
if (selectedAccountId) {
|
||||||
|
return socialAccountApi.createStandard(selectedAccountId, data)
|
||||||
|
}
|
||||||
|
return socialInsuranceApi.createHousingConfigVersion(data)
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||||
setShowNewVersion(false)
|
setShowNewVersion(false)
|
||||||
toast.success('公积金新版本已创建,旧版本已自动归档')
|
toast.success('公积金新版本已创建,旧版本已自动归档')
|
||||||
},
|
},
|
||||||
@@ -394,53 +419,23 @@ export default function SocialInsurance() {
|
|||||||
))}
|
))}
|
||||||
{(tab === 'social' || tab === 'housing') && (
|
{(tab === 'social' || tab === 'housing') && (
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<label className="text-sm text-gray-500">城市:</label>
|
<label className="text-sm text-gray-500">账户:</label>
|
||||||
{showAddCity ? (
|
<select
|
||||||
<div className="flex items-center gap-1">
|
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"
|
||||||
<input
|
value={selectedAccountId}
|
||||||
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"
|
onChange={(e) => {
|
||||||
value={newCityName}
|
const acc = accounts.find((a: any) => a.id === e.target.value)
|
||||||
onChange={(e) => setNewCityName(e.target.value)}
|
setSelectedAccountId(e.target.value)
|
||||||
placeholder="城市名"
|
if (acc) setCity(acc.city)
|
||||||
autoFocus
|
}}
|
||||||
onKeyDown={(e) => {
|
>
|
||||||
if (e.key === 'Enter' && newCityName.trim()) {
|
{accounts.length === 0 && <option value="">暂无账户,请到设置中创建</option>}
|
||||||
setCity(newCityName.trim())
|
{accounts.map((a: any) => (
|
||||||
setNewCityName('')
|
<option key={a.id} value={a.id}>
|
||||||
setShowAddCity(false)
|
{a.name}({a.city}){a.isDefault ? ' · 默认' : ''}
|
||||||
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
|
</option>
|
||||||
}
|
))}
|
||||||
if (e.key === 'Escape') { setShowAddCity(false); setNewCityName('') }
|
</select>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -708,9 +703,8 @@ export default function SocialInsurance() {
|
|||||||
<div className="grid md:grid-cols-3 gap-3">
|
<div className="grid md:grid-cols-3 gap-3">
|
||||||
<div><Label>生效月份</Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
|
<div><Label>生效月份</Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
|
||||||
<div><Label>城市</Label>
|
<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 })}>
|
<Input value={city} disabled className="bg-gray-50" />
|
||||||
{[...new Set([city, ...cities])].map((c) => <option key={c} value={c}>{c}</option>)}
|
<p className="text-xs text-gray-400 mt-1">从所选账户继承</p>
|
||||||
</select>
|
|
||||||
</div>
|
</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.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><Label>缴费基数上限</Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user