feat: 社保配置回退SocialYearStandard、月度办理tab切换、员工参保筛选分页
- 后端:dotenv/config 加载 ENCRYPTION_KEY,修复薪资数据解密 - 后端:社保/公积金 calculate API 回退到 SocialYearStandard(通过默认账户) - 后端:getSocialConfigByMonth/getHousingConfigByMonth 回退到 SocialYearStandard - 后端:active-declaration 查询条件改为 lte,当月办理的增员也显示在在保列表 - 后端:employee-enrollment API 增加部门/参保状态筛选和分页 - 后端:updateEmployeeSchema 增加 baseSalary/performanceSalary 字段 - 后端:payroll2.routes 批次详情包含 contracts 合同类型 - 前端:月度办理社保/公积金改为 tab 切换(不再同时展开) - 前端:员工参保列表增加筛选(部门/社保状态/公积金状态)和分页 - 前端:花名册基本信息增加月度工资只读显示(基本+绩效自动计算) - 前端:薪资批次详情表增加合同类型列 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Generated
+13
@@ -14,6 +14,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.19.0",
|
"express": "^4.19.0",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
@@ -1695,6 +1696,18 @@
|
|||||||
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.4.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
|
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/duck": {
|
"node_modules/duck": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
|
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.19.0",
|
"express": "^4.19.0",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
import app from './app'
|
import app from './app'
|
||||||
import { validateEnv } from './lib/config'
|
import { validateEnv } from './lib/config'
|
||||||
import logger from './lib/logger'
|
import logger from './lib/logger'
|
||||||
|
|||||||
@@ -1023,9 +1023,9 @@ router.post('/batches/:batchId/fetch-disciplinary', async (req: AuthRequest, res
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查询批次月份内的违纪扣款记录(按 violationDate 落在批次月份内)
|
// 查询批次月份内的违纪扣款记录(按 violationDate 落在批次月份内)
|
||||||
const monthStart = new Date(batch.month + '-01')
|
const monthStart = new Date(batch.month + '-01T00:00:00.000Z')
|
||||||
const monthEnd = new Date(monthStart)
|
const monthEnd = new Date(monthStart)
|
||||||
monthEnd.setMonth(monthEnd.getMonth() + 1)
|
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1)
|
||||||
|
|
||||||
const disciplinaryRecords = await prisma.disciplinaryRecord.findMany({
|
const disciplinaryRecords = await prisma.disciplinaryRecord.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -1037,6 +1037,7 @@ router.post('/batches/:batchId/fetch-disciplinary', async (req: AuthRequest, res
|
|||||||
},
|
},
|
||||||
select: { employeeId: true, deductionAmount: true },
|
select: { employeeId: true, deductionAmount: true },
|
||||||
})
|
})
|
||||||
|
console.log(`[fetch-disciplinary] batch.month=${batch.month} monthStart=${monthStart.toISOString()} monthEnd=${monthEnd.toISOString()} entries=${entries.length} records=${disciplinaryRecords.length}`)
|
||||||
|
|
||||||
// 按员工汇总扣款金额
|
// 按员工汇总扣款金额
|
||||||
const deductionMap = new Map<string, number>()
|
const deductionMap = new Map<string, number>()
|
||||||
|
|||||||
@@ -819,6 +819,41 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
|||||||
where: { ...whereBase, isCurrent: true },
|
where: { ...whereBase, isCurrent: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 回退到 SocialYearStandard(通过默认社保账户关联)
|
||||||
|
if (!config) {
|
||||||
|
const socialAccount = await prisma.socialAccount.findFirst({
|
||||||
|
where: { orgId, type: 'SOCIAL', isDefault: true },
|
||||||
|
})
|
||||||
|
if (socialAccount) {
|
||||||
|
const standard = await prisma.socialYearStandard.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: socialAccount.id,
|
||||||
|
effectiveFrom: { lte: month || '9999-12' },
|
||||||
|
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month || '0000-01' } }],
|
||||||
|
},
|
||||||
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
|
})
|
||||||
|
if (standard) {
|
||||||
|
config = {
|
||||||
|
baseMin: standard.baseMin,
|
||||||
|
baseMax: standard.baseMax,
|
||||||
|
medicalBaseMin: standard.medicalBaseMin,
|
||||||
|
medicalBaseMax: standard.medicalBaseMax,
|
||||||
|
pensionOrg: standard.pensionOrg,
|
||||||
|
pensionEmp: standard.pensionEmp,
|
||||||
|
medicalOrg: standard.medicalOrg,
|
||||||
|
medicalEmp: standard.medicalEmp,
|
||||||
|
medicalOrgExtra: standard.medicalOrgExtra,
|
||||||
|
medicalEmpExtra: standard.medicalEmpExtra,
|
||||||
|
unemploymentOrg: standard.unemploymentOrg,
|
||||||
|
unemploymentEmp: standard.unemploymentEmp,
|
||||||
|
injuryOrg: standard.injuryOrg,
|
||||||
|
maternityOrg: standard.maternityOrg,
|
||||||
|
extraInsurances: standard.extraInsurances,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `未找到${city || ''}的社保配置,请先在社保管理中创建` } })
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `未找到${city || ''}的社保配置,请先在社保管理中创建` } })
|
||||||
}
|
}
|
||||||
@@ -1004,6 +1039,30 @@ router.post('/housing-calculate', async (req: AuthRequest, res: Response, next:
|
|||||||
where: { ...whereBase, isCurrent: true },
|
where: { ...whereBase, isCurrent: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 回退到 SocialYearStandard(通过默认公积金账户关联)
|
||||||
|
if (!config) {
|
||||||
|
const housingAccount = await prisma.socialAccount.findFirst({
|
||||||
|
where: { orgId, type: 'HOUSING', isDefault: true },
|
||||||
|
})
|
||||||
|
if (housingAccount) {
|
||||||
|
const standard = await prisma.socialYearStandard.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: housingAccount.id,
|
||||||
|
effectiveFrom: { lte: month || '9999-12' },
|
||||||
|
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month || '0000-01' } }],
|
||||||
|
},
|
||||||
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
|
})
|
||||||
|
if (standard) {
|
||||||
|
config = {
|
||||||
|
baseMin: standard.housingBaseMin || standard.baseMin,
|
||||||
|
baseMax: standard.housingBaseMax || standard.baseMax,
|
||||||
|
housingOrg: standard.housingOrg,
|
||||||
|
housingEmp: standard.housingEmp,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `未找到${city || ''}的公积金配置,请先在公积金管理中创建` } })
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `未找到${city || ''}的公积金配置,请先在公积金管理中创建` } })
|
||||||
}
|
}
|
||||||
@@ -1251,7 +1310,7 @@ function calcHousingDetail(base: number, config: any) {
|
|||||||
return { actualBase, orgAmount, empAmount, total: orgAmount + empAmount }
|
return { actualBase, orgAmount, empAmount, total: orgAmount + empAmount }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按月份匹配社保配置版本 */
|
/** 按月份匹配社保配置版本(回退到 SocialYearStandard) */
|
||||||
async function getSocialConfigByMonth(orgId: string, month: string, city?: string) {
|
async function getSocialConfigByMonth(orgId: string, month: string, city?: string) {
|
||||||
const where: any = { orgId }
|
const where: any = { orgId }
|
||||||
if (city) where.city = city
|
if (city) where.city = city
|
||||||
@@ -1262,10 +1321,73 @@ async function getSocialConfigByMonth(orgId: string, month: string, city?: strin
|
|||||||
if (!config) {
|
if (!config) {
|
||||||
config = await prisma.socialInsuranceConfig.findFirst({ where: { ...where, isCurrent: true } })
|
config = await prisma.socialInsuranceConfig.findFirst({ where: { ...where, isCurrent: true } })
|
||||||
}
|
}
|
||||||
|
// 回退到 SocialYearStandard(通过默认社保账户关联)
|
||||||
|
if (!config) {
|
||||||
|
const socialAccount = await prisma.socialAccount.findFirst({
|
||||||
|
where: { orgId, type: 'SOCIAL', isDefault: true },
|
||||||
|
})
|
||||||
|
if (socialAccount) {
|
||||||
|
const standard = await prisma.socialYearStandard.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: socialAccount.id,
|
||||||
|
effectiveFrom: { lte: month },
|
||||||
|
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||||
|
},
|
||||||
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
|
})
|
||||||
|
if (!standard) {
|
||||||
|
const fallback = await prisma.socialYearStandard.findFirst({
|
||||||
|
where: { accountId: socialAccount.id, isCurrent: true },
|
||||||
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
|
})
|
||||||
|
if (fallback) {
|
||||||
|
config = {
|
||||||
|
city: city || '北京',
|
||||||
|
effectiveFrom: fallback.effectiveFrom,
|
||||||
|
baseMin: fallback.baseMin,
|
||||||
|
baseMax: fallback.baseMax,
|
||||||
|
medicalBaseMin: fallback.medicalBaseMin,
|
||||||
|
medicalBaseMax: fallback.medicalBaseMax,
|
||||||
|
pensionOrg: fallback.pensionOrg,
|
||||||
|
pensionEmp: fallback.pensionEmp,
|
||||||
|
medicalOrg: fallback.medicalOrg,
|
||||||
|
medicalEmp: fallback.medicalEmp,
|
||||||
|
medicalOrgExtra: fallback.medicalOrgExtra,
|
||||||
|
medicalEmpExtra: fallback.medicalEmpExtra,
|
||||||
|
unemploymentOrg: fallback.unemploymentOrg,
|
||||||
|
unemploymentEmp: fallback.unemploymentEmp,
|
||||||
|
injuryOrg: fallback.injuryOrg,
|
||||||
|
maternityOrg: fallback.maternityOrg,
|
||||||
|
extraInsurances: fallback.extraInsurances,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config = {
|
||||||
|
city: city || '北京',
|
||||||
|
effectiveFrom: standard.effectiveFrom,
|
||||||
|
baseMin: standard.baseMin,
|
||||||
|
baseMax: standard.baseMax,
|
||||||
|
medicalBaseMin: standard.medicalBaseMin,
|
||||||
|
medicalBaseMax: standard.medicalBaseMax,
|
||||||
|
pensionOrg: standard.pensionOrg,
|
||||||
|
pensionEmp: standard.pensionEmp,
|
||||||
|
medicalOrg: standard.medicalOrg,
|
||||||
|
medicalEmp: standard.medicalEmp,
|
||||||
|
medicalOrgExtra: standard.medicalOrgExtra,
|
||||||
|
medicalEmpExtra: standard.medicalEmpExtra,
|
||||||
|
unemploymentOrg: standard.unemploymentOrg,
|
||||||
|
unemploymentEmp: standard.unemploymentEmp,
|
||||||
|
injuryOrg: standard.injuryOrg,
|
||||||
|
maternityOrg: standard.maternityOrg,
|
||||||
|
extraInsurances: standard.extraInsurances,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按月份匹配公积金配置版本 */
|
/** 按月份匹配公积金配置版本(回退到 SocialYearStandard) */
|
||||||
async function getHousingConfigByMonth(orgId: string, month: string, city?: string) {
|
async function getHousingConfigByMonth(orgId: string, month: string, city?: string) {
|
||||||
const where: any = { orgId }
|
const where: any = { orgId }
|
||||||
if (city) where.city = city
|
if (city) where.city = city
|
||||||
@@ -1276,6 +1398,47 @@ async function getHousingConfigByMonth(orgId: string, month: string, city?: stri
|
|||||||
if (!config) {
|
if (!config) {
|
||||||
config = await prisma.housingFundConfig.findFirst({ where: { ...where, isCurrent: true } })
|
config = await prisma.housingFundConfig.findFirst({ where: { ...where, isCurrent: true } })
|
||||||
}
|
}
|
||||||
|
// 回退到 SocialYearStandard(通过默认公积金账户关联)
|
||||||
|
if (!config) {
|
||||||
|
const housingAccount = await prisma.socialAccount.findFirst({
|
||||||
|
where: { orgId, type: 'HOUSING', isDefault: true },
|
||||||
|
})
|
||||||
|
if (housingAccount) {
|
||||||
|
const standard = await prisma.socialYearStandard.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: housingAccount.id,
|
||||||
|
effectiveFrom: { lte: month },
|
||||||
|
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||||
|
},
|
||||||
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
|
})
|
||||||
|
if (!standard) {
|
||||||
|
const fallback = await prisma.socialYearStandard.findFirst({
|
||||||
|
where: { accountId: housingAccount.id, isCurrent: true },
|
||||||
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
|
})
|
||||||
|
if (fallback) {
|
||||||
|
config = {
|
||||||
|
city: city || '北京',
|
||||||
|
effectiveFrom: fallback.effectiveFrom,
|
||||||
|
baseMin: fallback.housingBaseMin || fallback.baseMin,
|
||||||
|
baseMax: fallback.housingBaseMax || fallback.baseMax,
|
||||||
|
housingOrg: fallback.housingOrg,
|
||||||
|
housingEmp: fallback.housingEmp,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config = {
|
||||||
|
city: city || '北京',
|
||||||
|
effectiveFrom: standard.effectiveFrom,
|
||||||
|
baseMin: standard.housingBaseMin || standard.baseMin,
|
||||||
|
baseMax: standard.housingBaseMax || standard.baseMax,
|
||||||
|
housingOrg: standard.housingOrg,
|
||||||
|
housingEmp: standard.housingEmp,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1547,8 +1710,8 @@ router.get('/active-declaration', async (req: AuthRequest, res: Response, next:
|
|||||||
const records = await prisma.employeeSocialInsRecord.findMany({
|
const records = await prisma.employeeSocialInsRecord.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId,
|
orgId,
|
||||||
startMonth: { lt: month },
|
startMonth: { lte: month },
|
||||||
OR: [{ endMonth: null }, { endMonth: { gt: month } }],
|
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||||
employee: { contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } },
|
employee: { contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } },
|
||||||
},
|
},
|
||||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } } } } },
|
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } } } } },
|
||||||
@@ -1715,8 +1878,8 @@ router.get('/housing/active-declaration', async (req: AuthRequest, res: Response
|
|||||||
const records = await prisma.employeeHousingFundRecord.findMany({
|
const records = await prisma.employeeHousingFundRecord.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId,
|
orgId,
|
||||||
startMonth: { lt: month },
|
startMonth: { lte: month },
|
||||||
OR: [{ endMonth: null }, { endMonth: { gt: month } }],
|
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||||
employee: { contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } },
|
employee: { contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } },
|
||||||
},
|
},
|
||||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } } } } },
|
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } } } } },
|
||||||
@@ -2180,13 +2343,19 @@ router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next:
|
|||||||
try {
|
try {
|
||||||
const orgId = req.user!.orgId
|
const orgId = req.user!.orgId
|
||||||
const keyword = (req.query.keyword as string) || ''
|
const keyword = (req.query.keyword as string) || ''
|
||||||
|
const department = (req.query.department as string) || ''
|
||||||
|
const socialStatus = (req.query.socialStatus as string) || '' // INSURED | UNINSURED
|
||||||
|
const housingStatus = (req.query.housingStatus as string) || '' // INSURED | UNINSURED
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1)
|
||||||
|
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 20))
|
||||||
|
|
||||||
// 查询所有在职员工
|
// 查询所有在职员工(先全量查,再在内存中按参保状态过滤,最后分页)
|
||||||
const employees = await prisma.employee.findMany({
|
const employees = await prisma.employee.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId,
|
orgId,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}),
|
...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}),
|
||||||
|
...(department ? { department } : {}),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -2217,7 +2386,7 @@ router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next:
|
|||||||
const socialMap = new Map(socialRecords.map(r => [r.employeeId, r]))
|
const socialMap = new Map(socialRecords.map(r => [r.employeeId, r]))
|
||||||
const housingMap = new Map(housingRecords.map(r => [r.employeeId, r]))
|
const housingMap = new Map(housingRecords.map(r => [r.employeeId, r]))
|
||||||
|
|
||||||
const list = employees.map(emp => {
|
let list = employees.map(emp => {
|
||||||
const social = socialMap.get(emp.id)
|
const social = socialMap.get(emp.id)
|
||||||
const housing = housingMap.get(emp.id)
|
const housing = housingMap.get(emp.id)
|
||||||
return {
|
return {
|
||||||
@@ -2236,7 +2405,14 @@ router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next:
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
res.json({ success: true, data: list })
|
// 按参保状态过滤
|
||||||
|
if (socialStatus) list = list.filter(e => e.socialInsStatus === socialStatus)
|
||||||
|
if (housingStatus) list = list.filter(e => e.housingFundStatus === housingStatus)
|
||||||
|
|
||||||
|
const total = list.length
|
||||||
|
const paged = list.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
|
||||||
|
res.json({ success: true, data: paged, total, page, pageSize })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -674,9 +674,14 @@ export const socialInsuranceApi = {
|
|||||||
/** 公积金活跃申报 */
|
/** 公积金活跃申报 */
|
||||||
housingActiveDeclaration: (month: string) =>
|
housingActiveDeclaration: (month: string) =>
|
||||||
get('/social/housing/active-declaration', { params: { month } }).then(unwrap<any>()),
|
get('/social/housing/active-declaration', { params: { month } }).then(unwrap<any>()),
|
||||||
/** 员工参保信息列表 */
|
/** 员工参保信息列表(支持筛选和分页,返回 { data, total, page, pageSize }) */
|
||||||
employeeEnrollment: (keyword?: string) =>
|
employeeEnrollment: (params?: { keyword?: string; department?: string; socialStatus?: string; housingStatus?: string; page?: number; pageSize?: number }) =>
|
||||||
get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap<any[]>()),
|
get('/social/employee-enrollment', { params: params || {} }).then((res: any) => {
|
||||||
|
const body = res?.data ?? res
|
||||||
|
// 兼容旧格式(纯数组)和新格式({ data, total, page, pageSize })
|
||||||
|
if (Array.isArray(body)) return { data: body, total: body.length, page: 1, pageSize: body.length }
|
||||||
|
return { data: body.data || [], total: body.total ?? 0, page: body.page ?? 1, pageSize: body.pageSize ?? 20 }
|
||||||
|
}),
|
||||||
/** 办理社保增员(批量创建社保记录) */
|
/** 办理社保增员(批量创建社保记录) */
|
||||||
enrollSocial: (employeeIds: string[], startMonth: string) =>
|
enrollSocial: (employeeIds: string[], startMonth: string) =>
|
||||||
post('/social/enroll-social', { employeeIds, startMonth }).then(unwrap<any>()),
|
post('/social/enroll-social', { employeeIds, startMonth }).then(unwrap<any>()),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function SocialInsurance() {
|
|||||||
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
|
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||||
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
|
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
|
||||||
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
|
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
|
||||||
|
const [monthlySubTab, setMonthlySubTab] = useState<'social' | 'housing'>('social')
|
||||||
// 账户管理相关 state
|
// 账户管理相关 state
|
||||||
const [showAccountForm, setShowAccountForm] = useState(false)
|
const [showAccountForm, setShowAccountForm] = useState(false)
|
||||||
const [editAccount, setEditAccount] = useState<any>(null)
|
const [editAccount, setEditAccount] = useState<any>(null)
|
||||||
@@ -495,16 +496,34 @@ export default function SocialInsurance() {
|
|||||||
<div className="bg-gray-50 px-4 py-2.5 flex items-center justify-between">
|
<div className="bg-gray-50 px-4 py-2.5 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs font-medium">{city}</span>
|
<span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs font-medium">{city}</span>
|
||||||
<span className="text-gray-400 text-xs">向{city}社保/公积金经办机构申报</span>
|
<span className="text-gray-400 text-xs">向{city}{monthlySubTab === 'social' ? '社保' : '公积金'}经办机构申报</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 space-y-3">
|
{/* 子 Tab 切换 */}
|
||||||
<CollapsibleSection
|
<div className="flex border-b px-4 pt-2 gap-1">
|
||||||
title="社保"
|
<button
|
||||||
icon={<Shield className="w-4 h-4 text-blue-500" />}
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${monthlySubTab === 'social' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||||
summary={`${sAddCity.length + sSubCity.length + sNormalCity.length} 人 | 企业 ¥${fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalOrg || 0), 0))} + 个人 ¥${fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalEmp || 0), 0))} = ¥${fmt(sTotal)}`}
|
onClick={() => setMonthlySubTab('social')}
|
||||||
defaultOpen={true}
|
>
|
||||||
action={sAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 ? (
|
<Shield className="w-4 h-4 inline mr-1 -mt-0.5" />社保
|
||||||
|
<span className="ml-1 text-xs text-gray-400">({sAddCity.length + sSubCity.length + sNormalCity.length}人)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${monthlySubTab === 'housing' ? 'border-green-500 text-green-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||||
|
onClick={() => setMonthlySubTab('housing')}
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4 inline mr-1 -mt-0.5" />公积金
|
||||||
|
<span className="ml-1 text-xs text-gray-400">({hAddCity.length + hSubCity.length + hNormalCity.length}人)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{monthlySubTab === 'social' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
企业 ¥{fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalOrg || 0), 0))} + 个人 ¥{fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalEmp || 0), 0))} = ¥{fmt(sTotal)}
|
||||||
|
</div>
|
||||||
|
{sAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -516,16 +535,17 @@ export default function SocialInsurance() {
|
|||||||
>
|
>
|
||||||
{enrollSocialMutation.isPending ? '办理中...' : `办理增员(${sAddCity.filter((i: any) => i.changeType === 'PENDING').length}人)`}
|
{enrollSocialMutation.isPending ? '办理中...' : `办理增员(${sAddCity.filter((i: any) => i.changeType === 'PENDING').length}人)`}
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined}
|
)}
|
||||||
>
|
</div>
|
||||||
{sTable}
|
{sTable}
|
||||||
</CollapsibleSection>
|
</div>
|
||||||
<CollapsibleSection
|
) : (
|
||||||
title="公积金"
|
<div className="space-y-2">
|
||||||
icon={<Home className="w-4 h-4 text-green-500" />}
|
<div className="flex items-center justify-between">
|
||||||
summary={`${hAddCity.length + hSubCity.length + hNormalCity.length} 人 | 企业 ¥${fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.orgAmount || 0), 0))} + 个人 ¥${fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.empAmount || 0), 0))} = ¥${fmt(hTotal)}`}
|
<div className="text-xs text-gray-500">
|
||||||
defaultOpen={true}
|
企业 ¥{fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.orgAmount || 0), 0))} + 个人 ¥{fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.empAmount || 0), 0))} = ¥{fmt(hTotal)}
|
||||||
action={hAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 ? (
|
</div>
|
||||||
|
{hAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -537,10 +557,11 @@ export default function SocialInsurance() {
|
|||||||
>
|
>
|
||||||
{enrollHousingMutation.isPending ? '办理中...' : `办理增员(${hAddCity.filter((i: any) => i.changeType === 'PENDING').length}人)`}
|
{enrollHousingMutation.isPending ? '办理中...' : `办理增员(${hAddCity.filter((i: any) => i.changeType === 'PENDING').length}人)`}
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined}
|
)}
|
||||||
>
|
</div>
|
||||||
{hTable}
|
{hTable}
|
||||||
</CollapsibleSection>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -362,6 +362,12 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: base + perf })
|
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: base + perf })
|
||||||
}} placeholder="可为0" />
|
}} placeholder="可为0" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>月度工资</Label>
|
||||||
|
<div className="px-3 py-2 rounded-md bg-gray-50 text-sm text-gray-600 border border-gray-200">
|
||||||
|
¥{fmt(Number(form.monthlySalary) || 0)} <span className="text-xs text-gray-400">(自动计算:基本 + 绩效)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div><Label>紧急联系人</Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
|
<div><Label>紧急联系人</Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
|
||||||
<div><Label>紧急联系电话</Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
|
<div><Label>紧急联系电话</Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
|
||||||
<div className="md:col-span-2"><Label>住址</Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
|
<div className="md:col-span-2"><Label>住址</Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Search, CheckCircle, XCircle } from 'lucide-react'
|
import { Search, CheckCircle, XCircle, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { socialInsuranceApi } from '../../lib/api-services'
|
import { socialInsuranceApi, rosterApi } from '../../lib/api-services'
|
||||||
import { Input } from '../../components/ui/Input'
|
import { Input } from '../../components/ui/Input'
|
||||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
||||||
|
|
||||||
@@ -10,34 +10,107 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
|||||||
export default function EmployeeEnrollmentTab() {
|
export default function EmployeeEnrollmentTab() {
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const debouncedSearch = useDebouncedValue(keyword, 300)
|
const debouncedSearch = useDebouncedValue(keyword, 300)
|
||||||
|
const [department, setDepartment] = useState('')
|
||||||
|
const [socialStatus, setSocialStatus] = useState('')
|
||||||
|
const [housingStatus, setHousingStatus] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const { data: list = [], isLoading } = useQuery<any[]>({
|
const params = useMemo(() => ({
|
||||||
queryKey: ['social-employee-enrollment', debouncedSearch],
|
keyword: debouncedSearch || undefined,
|
||||||
queryFn: () => socialInsuranceApi.employeeEnrollment(debouncedSearch || undefined),
|
department: department || undefined,
|
||||||
|
socialStatus: socialStatus || undefined,
|
||||||
|
housingStatus: housingStatus || undefined,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}), [debouncedSearch, department, socialStatus, housingStatus, page, pageSize])
|
||||||
|
|
||||||
|
const { data: result, isLoading } = useQuery<any>({
|
||||||
|
queryKey: ['social-employee-enrollment', params],
|
||||||
|
queryFn: () => socialInsuranceApi.employeeEnrollment(params),
|
||||||
|
placeholderData: (prev: any) => prev,
|
||||||
})
|
})
|
||||||
|
|
||||||
const insuredCount = list.filter(e => e.socialInsStatus === 'INSURED').length
|
const { data: departments = [] } = useQuery<string[]>({
|
||||||
const housingCount = list.filter(e => e.housingFundStatus === 'INSURED').length
|
queryKey: ['roster-departments'],
|
||||||
|
queryFn: () => rosterApi.departments(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = result?.data || []
|
||||||
|
const total = result?.total || 0
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
|
||||||
|
const insuredCount = list.filter((e: any) => e.socialInsStatus === 'INSURED').length
|
||||||
|
const housingCount = list.filter((e: any) => e.housingFundStatus === 'INSURED').length
|
||||||
|
|
||||||
|
// 筛选变化时重置到第一页
|
||||||
|
const handleFilterChange = (setter: (v: string) => void) => (v: string) => {
|
||||||
|
setter(v)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
{/* 筛选栏 */}
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-gray-500">共 {list.length} 人</span>
|
|
||||||
<span className="text-gray-500">社保参保 <span className="font-medium text-primary">{insuredCount}</span></span>
|
|
||||||
<span className="text-gray-500">公积金参保 <span className="font-medium text-primary">{housingCount}</span></span>
|
|
||||||
</div>
|
|
||||||
<div className="relative w-48">
|
<div className="relative w-48">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
<Input
|
<Input
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
|
||||||
placeholder="搜索员工姓名"
|
placeholder="搜索员工姓名"
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
value={department}
|
||||||
|
onChange={(e) => handleFilterChange(setDepartment)(e.target.value)}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">全部部门</option>
|
||||||
|
{departments.map((d: string) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={socialStatus}
|
||||||
|
onChange={(e) => handleFilterChange(setSocialStatus)(e.target.value)}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">社保状态(全部)</option>
|
||||||
|
<option value="INSURED">已参保</option>
|
||||||
|
<option value="UNINSURED">未参保</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={housingStatus}
|
||||||
|
onChange={(e) => handleFilterChange(setHousingStatus)(e.target.value)}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">公积金状态(全部)</option>
|
||||||
|
<option value="INSURED">已参保</option>
|
||||||
|
<option value="UNINSURED">未参保</option>
|
||||||
|
</select>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1) }}
|
||||||
|
className="px-2 py-1 text-sm border rounded-md bg-white focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={20}>20 条/页</option>
|
||||||
|
<option value={50}>50 条/页</option>
|
||||||
|
<option value={100}>100 条/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 统计 */}
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-gray-500">共 <span className="font-medium text-gray-700">{total}</span> 人</span>
|
||||||
|
<span className="text-gray-500">社保参保 <span className="font-medium text-primary">{insuredCount}</span></span>
|
||||||
|
<span className="text-gray-500">公积金参保 <span className="font-medium text-primary">{housingCount}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -59,7 +132,7 @@ export default function EmployeeEnrollmentTab() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr><td colSpan={11} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
<tr><td colSpan={11} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<tr><td colSpan={11} className="py-8 text-center text-gray-400">暂无员工参保信息</td></tr>
|
<tr><td colSpan={11} className="py-8 text-center text-gray-400">暂无符合条件的员工</td></tr>
|
||||||
) : list.map((emp: any) => (
|
) : list.map((emp: any) => (
|
||||||
<tr key={emp.id} className="border-b hover:bg-gray-50">
|
<tr key={emp.id} className="border-b hover:bg-gray-50">
|
||||||
<td className="py-2 pr-4 font-medium">{emp.name}</td>
|
<td className="py-2 pr-4 font-medium">{emp.name}</td>
|
||||||
@@ -98,6 +171,32 @@ export default function EmployeeEnrollmentTab() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
第 {(page - 1) * pageSize + 1}-{Math.min(page * pageSize, total)} 条,共 {total} 条
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="p-1.5 rounded border disabled:opacity-40 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600">{page} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="p-1.5 rounded border disabled:opacity-40 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user