diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bdf1b7b..9ee57fe 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -183,6 +183,11 @@ model Organization { attendancePublishes AttendancePublish[] specialStatuses EmployeeSpecialStatus[] companyFiles CompanyFile[] + commercialInsPlans CommercialInsurancePlan[] + commercialInsEnrollments CommercialInsuranceEnrollment[] + benefitPlans EmployeeBenefitPlan[] + benefitEnrollments EmployeeBenefitEnrollment[] + eSignRecords ESignRecord[] } model User { @@ -268,6 +273,9 @@ model Employee { calendarEvents CalendarEvent[] workProcesses WorkProcess[] specialStatuses EmployeeSpecialStatus[] + commercialInsEnrollments CommercialInsuranceEnrollment[] + benefitEnrollments EmployeeBenefitEnrollment[] + eSignRecords ESignRecord[] @@unique([orgId, idCardHash]) } @@ -1365,3 +1373,114 @@ model AcceptanceTest { @@unique([orgId, verifierName]) @@index([orgId, status]) } + +// ========== 商业保险 ========== +model CommercialInsurancePlan { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + name String + type String // ACCIDENT | SUPPLEMENTARY_MEDICAL | EMPLOYER_LIABILITY | CRITICAL_ILLNESS | GROUP_LIFE | OTHER + provider String // 保险公司 + policyNo String? + premium Float // 年保费 + coverageAmount Float // 保额 + effectiveFrom String // YYYY-MM-DD + effectiveTo String? // null = 长期 + description String? + status String @default("ACTIVE") // ACTIVE | EXPIRED | CANCELLED + enrollments CommercialInsuranceEnrollment[] + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, status]) + @@index([orgId, type]) +} + +model CommercialInsuranceEnrollment { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + planId String + plan CommercialInsurancePlan @relation(fields: [planId], references: [id], onDelete: Cascade) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + premium Float // 个人保费 + effectiveFrom String // YYYY-MM-DD + effectiveTo String? + status String @default("ACTIVE") // ACTIVE | TERMINATED + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, planId]) + @@index([employeeId]) +} + +// ========== 员工福利 ========== +model EmployeeBenefitPlan { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + name String + category String // TRANSPORT | MEAL | HOUSING | COMMUNICATION | HEALTH_CHECK | HOLIDAY | BIRTHDAY | OTHER + amount Float // 每月金额(或每次金额) + frequency String @default("MONTHLY") // MONTHLY | QUARTERLY | YEARLY | ONE_TIME + taxDeductible Boolean @default(false) // 是否税前扣除 + description String? + status String @default("ACTIVE") + enrollments EmployeeBenefitEnrollment[] + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, status]) + @@index([orgId, category]) +} + +model EmployeeBenefitEnrollment { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + planId String + plan EmployeeBenefitPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + effectiveFrom String // YYYY-MM + effectiveTo String? // null = 至今 + amount Float? // 覆盖默认金额(个别调整) + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, planId]) + @@index([employeeId]) +} + +// ========== 电子签署(易签宝) ========== +model ESignRecord { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + contractId String? // 关联 LaborContract + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + flowId String? // 易签宝流程ID + documentTitle String // 文件标题 + documentContent String? // 文件内容(HTML/PDF base64) + status String @default("PENDING") // PENDING | SIGNING | COMPLETED | REJECTED | EXPIRED | CANCELLED + signUrl String? // 签署链接 + signedPdfUrl String? // 签署完成后的PDF链接 + initiatedBy String // 发起人(HR用户ID) + completedAt DateTime? + expiredAt DateTime? + callbackData Json? // 易签宝回调数据 + remark String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, status]) + @@index([employeeId]) + @@index([contractId]) +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 9f9f393..800f802 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -66,6 +66,9 @@ import companyFileRoutes from './routes/company-file.routes' import acceptanceTestRoutes from './routes/acceptance-test.routes' import leaveRoutes from './routes/leave.routes' import salaryRoutes from './routes/salary.routes' +import commercialInsuranceRoutes from './routes/commercial-insurance.routes' +import benefitRoutes from './routes/benefit.routes' +import esignRoutes from './routes/esign.routes' app.use('/api/v1/auth', authRoutes) app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/employees', employeeRoutes) @@ -95,6 +98,9 @@ app.use('/api/v1/company-files', companyFileRoutes) app.use('/api/v1/acceptance-tests', acceptanceTestRoutes) app.use('/api/v1/leaves', leaveRoutes) app.use('/api/v1/salary', salaryRoutes) +app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes) +app.use('/api/v1/benefits', benefitRoutes) +app.use('/api/v1/esign', esignRoutes) app.use(errorHandler) diff --git a/backend/src/routes/benefit.routes.ts b/backend/src/routes/benefit.routes.ts new file mode 100644 index 0000000..1d531d0 --- /dev/null +++ b/backend/src/routes/benefit.routes.ts @@ -0,0 +1,173 @@ +import { Router, Response, NextFunction } from 'express' +import { z } from 'zod' +import prisma from '../lib/prisma' +import { authMiddleware, AuthRequest } from '../middleware/auth' + +const router = Router() +router.use(authMiddleware) + +const planSchema = z.object({ + name: z.string().min(1), + category: z.string(), + amount: z.number().default(0), + frequency: z.string().default('MONTHLY'), + taxDeductible: z.boolean().default(false), + description: z.string().optional(), +}) + +const BENEFIT_CATEGORIES: Record = { + TRANSPORT: '交通补贴', + MEAL: '餐补', + HOUSING: '住房补贴', + COMMUNICATION: '通讯补贴', + HEALTH_CHECK: '体检', + HOLIDAY: '节日福利', + BIRTHDAY: '生日福利', + OTHER: '其他', +} + +// 福利方案列表 +router.get('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const plans = await prisma.employeeBenefitPlan.findMany({ + where: { orgId: req.user!.orgId }, + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { enrollments: { where: { status: 'ACTIVE' } } } } }, + }) + res.json({ success: true, data: plans }) + } catch (err) { next(err) } +}) + +// 创建福利方案 +router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = planSchema.parse(req.body) + const plan = await prisma.employeeBenefitPlan.create({ + data: { ...data, orgId: req.user!.orgId, createdBy: req.user!.id }, + }) + res.json({ success: true, data: plan }) + } catch (err) { next(err) } +}) + +// 更新福利方案 +router.put('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = planSchema.partial().parse(req.body) + const plan = await prisma.employeeBenefitPlan.update({ + where: { id: req.params.planId, orgId: req.user!.orgId }, + data, + }) + res.json({ success: true, data: plan }) + } catch (err) { next(err) } +}) + +// 删除福利方案 +router.delete('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + await prisma.employeeBenefitPlan.delete({ + where: { id: req.params.planId, orgId: req.user!.orgId }, + }) + res.json({ success: true }) + } catch (err) { next(err) } +}) + +// 方案参保人员 +router.get('/plans/:planId/enrollments', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const enrollments = await prisma.employeeBenefitEnrollment.findMany({ + where: { orgId: req.user!.orgId, planId: req.params.planId }, + include: { employee: { select: { id: true, name: true, department: true } } }, + orderBy: { createdAt: 'desc' }, + }) + const data = enrollments.map((e: any) => ({ + id: e.id, + employeeId: e.employeeId, + name: e.employee.name, + department: e.employee.department, + effectiveFrom: e.effectiveFrom, + effectiveTo: e.effectiveTo, + amount: e.amount, + status: e.status, + })) + res.json({ success: true, data }) + } catch (err) { next(err) } +}) + +// 批量参保 +router.post('/plans/:planId/enroll', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { employeeIds, effectiveFrom } = req.body as { employeeIds: string[]; effectiveFrom: string } + const plan = await prisma.employeeBenefitPlan.findFirst({ where: { id: req.params.planId, orgId: req.user!.orgId } }) + if (!plan) return res.status(404).json({ success: false, error: { message: '方案不存在' } }) + + const existing = await prisma.employeeBenefitEnrollment.findMany({ + where: { planId: plan.id, employeeId: { in: employeeIds }, status: 'ACTIVE' }, + select: { employeeId: true }, + }) + const existingIds = new Set(existing.map((e: any) => e.employeeId)) + const newIds = employeeIds.filter((id) => !existingIds.has(id)) + + if (newIds.length > 0) { + await prisma.employeeBenefitEnrollment.createMany({ + data: newIds.map((empId) => ({ + orgId: req.user!.orgId, + planId: plan.id, + employeeId: empId, + effectiveFrom: effectiveFrom || new Date().toISOString().slice(0, 7), + })), + }) + } + res.json({ success: true, data: { enrolled: newIds.length, skipped: existingIds.size } }) + } catch (err) { next(err) } +}) + +// 退福利 +router.post('/enrollments/:enrollmentId/terminate', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { effectiveTo } = req.body as { effectiveTo: string } + const enrollment = await prisma.employeeBenefitEnrollment.update({ + where: { id: req.params.enrollmentId, orgId: req.user!.orgId }, + data: { status: 'TERMINATED', effectiveTo: effectiveTo || new Date().toISOString().slice(0, 7) }, + }) + res.json({ success: true, data: enrollment }) + } catch (err) { next(err) } +}) + +// 员工福利汇总(按员工维度) +router.get('/employee-summary', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const enrollments = await prisma.employeeBenefitEnrollment.findMany({ + where: { orgId: req.user!.orgId, status: 'ACTIVE' }, + include: { + employee: { select: { id: true, name: true, department: true } }, + plan: { select: { id: true, name: true, category: true, amount: true } }, + }, + }) + const summary: Record = {} + for (const e of enrollments) { + if (!summary[e.employeeId]) { + summary[e.employeeId] = { + employeeId: e.employeeId, + name: e.employee.name, + department: e.employee.department, + benefits: [], + totalMonthly: 0, + } + } + const amount = e.amount ?? e.plan.amount + summary[e.employeeId].benefits.push({ + planId: e.planId, + planName: e.plan.name, + category: e.plan.category, + categoryLabel: BENEFIT_CATEGORIES[e.plan.category] || e.plan.category, + amount, + }) + if (e.plan.frequency === 'MONTHLY') { + summary[e.employeeId].totalMonthly += amount + } + } + res.json({ success: true, data: Object.values(summary) }) + } catch (err) { next(err) } +}) + +export default router diff --git a/backend/src/routes/commercial-insurance.routes.ts b/backend/src/routes/commercial-insurance.routes.ts new file mode 100644 index 0000000..d874b7c --- /dev/null +++ b/backend/src/routes/commercial-insurance.routes.ts @@ -0,0 +1,132 @@ +import { Router, Response, NextFunction } from 'express' +import { z } from 'zod' +import prisma from '../lib/prisma' +import { authMiddleware, AuthRequest } from '../middleware/auth' + +const router = Router() +router.use(authMiddleware) + +const planSchema = z.object({ + name: z.string().min(1), + type: z.string(), + provider: z.string().min(1), + policyNo: z.string().optional(), + premium: z.number().default(0), + coverageAmount: z.number().default(0), + effectiveFrom: z.string(), + effectiveTo: z.string().optional(), + description: z.string().optional(), +}) + +// 方案列表 +router.get('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const plans = await prisma.commercialInsurancePlan.findMany({ + where: { orgId: req.user!.orgId }, + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { enrollments: { where: { status: 'ACTIVE' } } } } }, + }) + res.json({ success: true, data: plans }) + } catch (err) { next(err) } +}) + +// 方案详情(含参保人员) +router.get('/plans/:planId/enrollments', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const enrollments = await prisma.commercialInsuranceEnrollment.findMany({ + where: { orgId: req.user!.orgId, planId: req.params.planId }, + include: { + employee: { select: { id: true, name: true, department: true, idCardNumber: true } }, + }, + orderBy: { createdAt: 'desc' }, + }) + const data = enrollments.map((e: any) => ({ + id: e.id, + employeeId: e.employeeId, + name: e.employee.name, + department: e.employee.department, + idCardMasked: e.employee.idCardNumber ? e.employee.idCardNumber.slice(0, 3) + '****' + e.employee.idCardNumber.slice(-4) : null, + premium: e.premium, + effectiveFrom: e.effectiveFrom, + effectiveTo: e.effectiveTo, + status: e.status, + })) + res.json({ success: true, data }) + } catch (err) { next(err) } +}) + +// 创建方案 +router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = planSchema.parse(req.body) + const plan = await prisma.commercialInsurancePlan.create({ + data: { ...data, orgId: req.user!.orgId, createdBy: req.user!.id }, + }) + res.json({ success: true, data: plan }) + } catch (err) { next(err) } +}) + +// 更新方案 +router.put('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = planSchema.partial().parse(req.body) + const plan = await prisma.commercialInsurancePlan.update({ + where: { id: req.params.planId, orgId: req.user!.orgId }, + data, + }) + res.json({ success: true, data: plan }) + } catch (err) { next(err) } +}) + +// 删除方案 +router.delete('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + await prisma.commercialInsurancePlan.delete({ + where: { id: req.params.planId, orgId: req.user!.orgId }, + }) + res.json({ success: true }) + } catch (err) { next(err) } +}) + +// 批量参保 +router.post('/plans/:planId/enroll', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { employeeIds, premium, effectiveFrom } = req.body as { employeeIds: string[]; premium: number; effectiveFrom: string } + const plan = await prisma.commercialInsurancePlan.findFirst({ where: { id: req.params.planId, orgId: req.user!.orgId } }) + if (!plan) return res.status(404).json({ success: false, error: { message: '方案不存在' } }) + + const existing = await prisma.commercialInsuranceEnrollment.findMany({ + where: { planId: plan.id, employeeId: { in: employeeIds }, status: 'ACTIVE' }, + select: { employeeId: true }, + }) + const existingIds = new Set(existing.map((e: any) => e.employeeId)) + const newIds = employeeIds.filter((id) => !existingIds.has(id)) + + if (newIds.length > 0) { + await prisma.commercialInsuranceEnrollment.createMany({ + data: newIds.map((empId) => ({ + orgId: req.user!.orgId, + planId: plan.id, + employeeId: empId, + premium: premium || plan.premium, + effectiveFrom: effectiveFrom || plan.effectiveFrom, + })), + }) + } + res.json({ success: true, data: { enrolled: newIds.length, skipped: existingIds.size } }) + } catch (err) { next(err) } +}) + +// 退保 +router.post('/enrollments/:enrollmentId/terminate', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { effectiveTo } = req.body as { effectiveTo: string } + const enrollment = await prisma.commercialInsuranceEnrollment.update({ + where: { id: req.params.enrollmentId, orgId: req.user!.orgId }, + data: { status: 'TERMINATED', effectiveTo: effectiveTo || new Date().toISOString().slice(0, 10) }, + }) + res.json({ success: true, data: enrollment }) + } catch (err) { next(err) } +}) + +export default router diff --git a/backend/src/routes/esign.routes.ts b/backend/src/routes/esign.routes.ts new file mode 100644 index 0000000..75b071c --- /dev/null +++ b/backend/src/routes/esign.routes.ts @@ -0,0 +1,149 @@ +import { Router, Response, NextFunction } from 'express' +import { z } from 'zod' +import prisma from '../lib/prisma' +import { authMiddleware, AuthRequest } from '../middleware/auth' + +const router = Router() +router.use(authMiddleware) + +const createSignSchema = z.object({ + contractId: z.string().optional(), + employeeId: z.string().min(1), + documentTitle: z.string().min(1), + documentContent: z.string().optional(), + remark: z.string().optional(), +}) + +// 签署记录列表 +router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const status = req.query.status as string | undefined + const records = await prisma.eSignRecord.findMany({ + where: { + orgId: req.user!.orgId, + ...(status && { status }), + }, + include: { + employee: { select: { id: true, name: true, department: true, phone: true } }, + }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ success: true, data: records }) + } catch (err) { next(err) } +}) + +// 发起签署 +// TODO: 对接易签宝 API — 此处为框架预留 +// 1. 调用易签宝创建签署流程 +// 2. 获取签署链接 +// 3. 保存 flowId 和 signUrl +router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = createSignSchema.parse(req.body) + + // 预留:调用易签宝 API 创建签署流程 + // const esignResult = await esignApi.createFlow({ + // title: data.documentTitle, + // signerPhone: employee.phone, + // signerName: employee.name, + // content: data.documentContent, + // }) + // const flowId = esignResult.flowId + // const signUrl = esignResult.signUrl + + const record = await prisma.eSignRecord.create({ + data: { + orgId: req.user!.orgId, + contractId: data.contractId || null, + employeeId: data.employeeId, + documentTitle: data.documentTitle, + documentContent: data.documentContent || null, + // flowId: esignResult.flowId, // TODO: 易签宝对接后启用 + // signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用 + status: 'PENDING', + initiatedBy: req.user!.id, + createdBy: req.user!.id, + remark: data.remark || null, + expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天过期 + }, + }) + + res.json({ + success: true, + data: record, + message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。', + }) + } catch (err) { next(err) } +}) + +// 查询签署状态 +// TODO: 对接易签宝 API — 查询流程状态并同步 +router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const record = await prisma.eSignRecord.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } }) + + // 预留:调用易签宝 API 查询流程状态 + // const esignStatus = await esignApi.getFlowStatus(record.flowId) + // if (esignStatus !== record.status) { + // await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } }) + // } + + res.json({ success: true, data: record }) + } catch (err) { next(err) } +}) + +// 易签宝回调接口(无需认证) +router.post('/callback', async (req, res: Response, next: NextFunction) => { + try { + const { flowId, status, signedPdfUrl, ...rest } = req.body + + // TODO: 验证易签宝回调签名 + // if (!verifyEsignCallback(req.headers, req.body)) { + // return res.status(401).json({ success: false, error: { message: '无效回调' } }) + // } + + if (flowId) { + const record = await prisma.eSignRecord.findFirst({ where: { flowId } }) + if (record) { + await prisma.eSignRecord.update({ + where: { id: record.id }, + data: { + status: status || 'COMPLETED', + signedPdfUrl: signedPdfUrl || null, + completedAt: status === 'COMPLETED' ? new Date() : null, + callbackData: rest as any, + }, + }) + + // 如果关联了合同,更新合同的签署方式和电子合同URL + if (record.contractId && status === 'COMPLETED') { + await prisma.laborContract.update({ + where: { id: record.contractId }, + data: { + signMethod: 'ELECTRONIC', + electronicContractUrl: signedPdfUrl || null, + }, + }) + } + } + } + + res.json({ success: true }) + } catch (err) { next(err) } +}) + +// 取消签署 +router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const record = await prisma.eSignRecord.update({ + where: { id: req.params.id, orgId: req.user!.orgId }, + data: { status: 'CANCELLED' }, + }) + res.json({ success: true, data: record }) + } catch (err) { next(err) } +}) + +export default router diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cbb5dc3..4bfb4cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,9 @@ const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress' const ResignationApply = lazy(() => import('./pages/portal/ResignationApply')) const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter')) const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard')) +const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance')) +const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits')) +const ESign = lazy(() => import('./pages/ESign')) // 平台管理端 const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin')) @@ -198,6 +201,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> {/* 平台管理端 */} }>} /> diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 570699f..77c3015 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -15,6 +15,7 @@ import { Bell, ScrollText, Settings, ChevronDown, ChevronRight, Building2, CalendarDays, ClipboardList, Heart, CalendarClock, + Gift, PenTool, Umbrella, } from 'lucide-react' import Logo from '../ui/Logo' import { settingsApi } from '../../lib/api-services' @@ -55,6 +56,14 @@ const navGroups: NavGroup[] = [ { path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 }, ], }, + { + title: '福利保障', + items: [ + { path: '/commercial-insurance', label: '商业保险', icon: Umbrella }, + { path: '/benefits', label: '员工福利', icon: Gift }, + { path: '/esign', label: '电子签署', icon: PenTool }, + ], + }, { title: '时间', items: [ diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index f7d4b57..1c57e7b 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -572,6 +572,42 @@ export const commercialInsuranceApi = { /** 删除方案 */ removePlan: (id: string) => del(`/commercial-insurance/plans/${id}`), + /** 批量参保 */ + enroll: (planId: string, data: { employeeIds: string[]; premium?: number; effectiveFrom?: string }) => + post(`/commercial-insurance/plans/${planId}/enroll`, data), + /** 退保 */ + terminateEnrollment: (enrollmentId: string, effectiveTo?: string) => + post(`/commercial-insurance/enrollments/${enrollmentId}/terminate`, { effectiveTo }), +} + +// ========== 员工福利 ========== +export const benefitApi = { + plans: () => + get('/benefits/plans').then(unwrap()), + savePlan: (data: Record, editId?: string) => + editId ? put(`/benefits/plans/${editId}`, data) : post('/benefits/plans', data), + removePlan: (id: string) => + del(`/benefits/plans/${id}`), + enrollments: (planId: string) => + get(`/benefits/plans/${planId}/enrollments`).then(unwrap()), + enroll: (planId: string, data: { employeeIds: string[]; effectiveFrom?: string }) => + post(`/benefits/plans/${planId}/enroll`, data), + terminateEnrollment: (enrollmentId: string, effectiveTo?: string) => + post(`/benefits/enrollments/${enrollmentId}/terminate`, { effectiveTo }), + employeeSummary: () => + get('/benefits/employee-summary').then(unwrap()), +} + +// ========== 电子签署(易签宝) ========== +export const esignApi = { + list: (status?: string) => + get('/esign', { params: status ? { status } : {} }).then(unwrap()), + create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string }) => + post('/esign/create', data), + status: (id: string) => + get(`/esign/${id}/status`).then(unwrap()), + cancel: (id: string) => + post(`/esign/${id}/cancel`), } // ========== 离职相关 ========== diff --git a/frontend/src/pages/CommercialInsurance.tsx b/frontend/src/pages/CommercialInsurance.tsx new file mode 100644 index 0000000..914435a --- /dev/null +++ b/frontend/src/pages/CommercialInsurance.tsx @@ -0,0 +1,23 @@ +import { Shield } from 'lucide-react' +import PageGuide from '../components/ui/PageGuide' +import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab' + +export default function CommercialInsurance() { + return ( +
+
+ +
+

商业保险

+

管理商业保险方案、参保人员及保单信息

+
+
+ + 商业保险是社保之外的自愿性补充保障,包括意外伤害险、补充医疗、雇主责任险等。 + 点击「新增方案」创建商险计划,选择方案后可查看参保人员列表。 + 商业保险是系统增值服务模块,支持方案管理、参保人员追踪、保费统计。 + + +
+ ) +} diff --git a/frontend/src/pages/ESign.tsx b/frontend/src/pages/ESign.tsx new file mode 100644 index 0000000..b64c847 --- /dev/null +++ b/frontend/src/pages/ESign.tsx @@ -0,0 +1,233 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react' +import { esignApi, rosterApi } from '../lib/api-services' +import PageGuide from '../components/ui/PageGuide' +import Card from '../components/ui/Card' +import Button from '../components/ui/Button' +import { Input, Label } from '../components/ui/Input' +import { InlineAlert } from '../components/ui/InlineAlert' +import Modal from '../components/ui/Modal' + +const STATUS_CONFIG: Record = { + PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700' }, + SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700' }, + COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' }, + REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger' }, + EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500' }, + CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500' }, +} + +export default function ESign() { + const queryClient = useQueryClient() + const [filterStatus, setFilterStatus] = useState('') + const [showCreate, setShowCreate] = useState(false) + const [formData, setFormData] = useState({ + employeeId: '', + documentTitle: '', + remark: '', + }) + + const { data: records = [], isLoading } = useQuery({ + queryKey: ['esign-records', filterStatus], + queryFn: async () => { + return await esignApi.list(filterStatus || undefined) + }, + }) + + const { data: rosterData } = useQuery({ + queryKey: ['roster-for-esign'], + queryFn: async () => { + return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any + }, + enabled: showCreate, + }) + + const createMutation = useMutation({ + mutationFn: async (data: { employeeId: string; documentTitle: string; remark?: string }) => + esignApi.create(data) as any, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['esign-records'] }) + setShowCreate(false) + setFormData({ employeeId: '', documentTitle: '', remark: '' }) + toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接') + }, + onError: () => toast.error('创建失败'), + }) + + const cancelMutation = useMutation({ + mutationFn: (id: string) => esignApi.cancel(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['esign-records'] }) + toast.success('已取消签署') + }, + }) + + const refreshStatusMutation = useMutation({ + mutationFn: (id: string) => esignApi.status(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['esign-records'] }) + toast.success('状态已刷新') + }, + }) + + const handleCreate = () => { + if (!formData.employeeId) { toast.error('请选择员工'); return } + if (!formData.documentTitle.trim()) { toast.error('请填写文件标题'); return } + createMutation.mutate(formData) + } + + return ( +
+
+ +
+

电子签署

+

对接易签宝实现在线合同签署

+
+
+ + + 通过易签宝平台发起电子合同签署,员工在线完成签署后自动回传签署状态和PDF文件。 + 当前为框架预留阶段,对接易签宝API后将自动启用在线签署功能。 + + + + +
+ 对接状态:框架已就绪,待接入易签宝API +
+ 需要提供易签宝的 AppId、AppSecret 和 API 基础地址。对接后可实现: + ① HR在系统发起签署 → ② 调用易签宝创建签署流程 → ③ 员工收到签署链接 → ④ 签署完成自动回调更新状态 → ⑤ 合同自动关联电子版PDF +
+
+
+ +
+
+ +
+ +
+ + {/* 签署记录列表 */} + + {isLoading ? ( +
加载中...
+ ) : records.length === 0 ? ( +
暂无签署记录,点击「发起签署」创建
+ ) : ( +
+ + + + + + + + + + + + + + {records.map((r: any) => { + const statusCfg = STATUS_CONFIG[r.status] || STATUS_CONFIG.PENDING + return ( + + + + + + + + + + ) + })} + +
文件标题签署人部门状态发起时间完成时间操作
+
+ + {r.documentTitle} +
+ {r.remark &&
{r.remark}
} +
{r.employee?.name || '—'}{r.employee?.department || '—'} + {statusCfg.label} + {new Date(r.createdAt).toLocaleString('zh-CN')}{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'} +
+ {r.status === 'COMPLETED' && r.signedPdfUrl && ( + + 查看PDF + + )} + {(r.status === 'PENDING' || r.status === 'SIGNING') && ( + <> + + + + )} +
+
+
+ )} +
+ + {/* 发起签署 Modal */} + {showCreate && ( + setShowCreate(false)} title="发起电子签署" size="md"> +
+ + 选择员工并填写文件标题,系统将创建签署记录。对接易签宝后,将自动生成签署链接并发送给员工。 + +
+ + +
+
+ + setFormData({ ...formData, documentTitle: e.target.value })} + placeholder="如:2024年度劳动合同" /> +
+
+ + setFormData({ ...formData, remark: e.target.value })} + placeholder="可选" /> +
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/EmployeeBenefits.tsx b/frontend/src/pages/EmployeeBenefits.tsx new file mode 100644 index 0000000..7fe3789 --- /dev/null +++ b/frontend/src/pages/EmployeeBenefits.tsx @@ -0,0 +1,459 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useConfirm } from '../hooks/useConfirm' +import { Gift, Plus, Settings as SettingsIcon, X, Users } from 'lucide-react' +import { benefitApi, rosterApi } from '../lib/api-services' +import PageGuide from '../components/ui/PageGuide' +import Card from '../components/ui/Card' +import Button from '../components/ui/Button' +import { Input, Label } from '../components/ui/Input' +import { InlineAlert } from '../components/ui/InlineAlert' +import Modal from '../components/ui/Modal' + +const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + +const BENEFIT_CATEGORIES: Record = { + TRANSPORT: { label: '交通补贴', color: 'bg-blue-50 text-blue-700 border border-blue-200' }, + MEAL: { label: '餐补', color: 'bg-orange-50 text-orange-700 border border-orange-200' }, + HOUSING: { label: '住房补贴', color: 'bg-teal-50 text-teal-700 border border-teal-200' }, + COMMUNICATION: { label: '通讯补贴', color: 'bg-purple-50 text-purple-700 border border-purple-200' }, + HEALTH_CHECK: { label: '体检', color: 'bg-green-50 text-green-700 border border-green-200' }, + HOLIDAY: { label: '节日福利', color: 'bg-red-50 text-red-700 border border-red-200' }, + BIRTHDAY: { label: '生日福利', color: 'bg-pink-50 text-pink-700 border border-pink-200' }, + OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' }, +} + +const FREQUENCY_LABELS: Record = { + MONTHLY: '每月', + QUARTERLY: '每季', + YEARLY: '每年', + ONE_TIME: '一次性', +} + +const DEFAULT_PLAN = { + name: '', category: 'TRANSPORT', amount: 0, frequency: 'MONTHLY', + taxDeductible: false, description: '', +} + +export default function EmployeeBenefits() { + const queryClient = useQueryClient() + const confirm = useConfirm() + const [tab, setTab] = useState<'plans' | 'summary'>('plans') + const [showAddPlan, setShowAddPlan] = useState(false) + const [editingPlan, setEditingPlan] = useState(null) + const [selectedPlanId, setSelectedPlanId] = useState(null) + const [newPlan, setNewPlan] = useState({ ...DEFAULT_PLAN }) + const [showEnrollModal, setShowEnrollModal] = useState(false) + const [enrollEmployeeIds, setEnrollEmployeeIds] = useState([]) + const [enrollEffectiveFrom, setEnrollEffectiveFrom] = useState(new Date().toISOString().slice(0, 7)) + + const { data: plans = [], isLoading } = useQuery({ + queryKey: ['benefit-plans'], + queryFn: async () => { + return await benefitApi.plans() + }, + }) + + const { data: enrollments = [], isLoading: enrollLoading } = useQuery({ + queryKey: ['benefit-enrollments', selectedPlanId], + queryFn: async () => { + if (!selectedPlanId) return [] + return await benefitApi.enrollments(selectedPlanId) + }, + enabled: !!selectedPlanId, + }) + + const { data: employeeSummary = [] } = useQuery({ + queryKey: ['benefit-employee-summary'], + queryFn: async () => { + return await benefitApi.employeeSummary() + }, + enabled: tab === 'summary', + }) + + const { data: rosterData } = useQuery({ + queryKey: ['roster-for-benefit', ''], + queryFn: async () => { + return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any + }, + enabled: showEnrollModal, + }) + + const savePlanMutation = useMutation({ + mutationFn: async (data: any) => { + if (editingPlan) { + return benefitApi.savePlan(data, editingPlan.id) as any + } + return benefitApi.savePlan(data) as any + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['benefit-plans'] }) + setShowAddPlan(false) + setEditingPlan(null) + setNewPlan({ ...DEFAULT_PLAN }) + toast.success(editingPlan ? '福利方案已更新' : '福利方案已创建') + }, + onError: () => toast.error('保存失败'), + }) + + const deletePlanMutation = useMutation({ + mutationFn: (id: string) => benefitApi.removePlan(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['benefit-plans'] }) + setSelectedPlanId(null) + toast.success('福利方案已删除') + }, + }) + + const enrollMutation = useMutation({ + mutationFn: async (data: { employeeIds: string[]; effectiveFrom: string }) => + benefitApi.enroll(selectedPlanId!, data) as any, + onSuccess: (res: any) => { + queryClient.invalidateQueries({ queryKey: ['benefit-enrollments'] }) + queryClient.invalidateQueries({ queryKey: ['benefit-employee-summary'] }) + setShowEnrollModal(false) + setEnrollEmployeeIds([]) + toast.success(`已添加 ${res.data?.enrolled || 0} 名员工`) + }, + onError: () => toast.error('参保失败'), + }) + + const terminateMutation = useMutation({ + mutationFn: (enrollmentId: string) => benefitApi.terminateEnrollment(enrollmentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['benefit-enrollments'] }) + queryClient.invalidateQueries({ queryKey: ['benefit-employee-summary'] }) + toast.success('已终止福利') + }, + }) + + const handleEdit = (plan: any) => { + setEditingPlan(plan) + setNewPlan({ ...plan }) + setShowAddPlan(true) + } + + const handleSave = () => { + if (!newPlan.name?.trim()) { toast.error('请填写方案名称'); return } + savePlanMutation.mutate(newPlan) + } + + return ( +
+
+ +
+

员工福利

+

管理员工福利方案、参保人员及福利汇总

+
+
+ + + 员工福利包括交通补贴、餐补、住房补贴、通讯补贴、体检、节日福利等。 + 管理福利方案、批量参保、查看员工福利汇总。 + 员工福利是系统增值服务模块,支持按方案/按员工维度管理福利,可关联薪资计算。 + + + {/* Tab 切换 */} +
+ {(['plans', 'summary'] as const).map((t) => ( + + ))} + {tab === 'plans' && ( + + )} +
+ + {/* ========== 福利方案 Tab ========== */} + {tab === 'plans' && ( + <> + {isLoading ? ( +
加载中...
+ ) : plans.length === 0 ? ( +
暂无福利方案,点击「新增方案」创建
+ ) : ( +
+ {plans.map((plan: any) => { + const catCfg = BENEFIT_CATEGORIES[plan.category] || BENEFIT_CATEGORIES.OTHER + const isSelected = selectedPlanId === plan.id + return ( + +
setSelectedPlanId(isSelected ? null : plan.id)}> +
+
+ {catCfg.label} +

{plan.name}

+
+
e.stopPropagation()}> + + +
+
+
+
金额¥{fmt(plan.amount)} / {FREQUENCY_LABELS[plan.frequency] || plan.frequency}
+
税前扣除{plan.taxDeductible ? '是' : '否'}
+
参保人数{plan._count?.enrollments || 0} 人
+
+ {plan.description &&

{plan.description}

} +
+
+ ) + })} +
+ )} + + {/* 参保人员 */} + {selectedPlanId && ( + +
+

参保人员({enrollments.length}人)

+ +
+ {enrollLoading ? ( +
加载中...
+ ) : enrollments.length === 0 ? ( +
暂无参保人员
+ ) : ( +
+ + + + + + + + + + + + + {enrollments.map((e: any) => ( + + + + + + + + + ))} + +
姓名部门生效月份截止月份状态操作
{e.name}{e.department}{e.effectiveFrom || '—'}{e.effectiveTo || '至今'} + + {e.status === 'ACTIVE' ? '有效' : '已终止'} + + + {e.status === 'ACTIVE' && ( + + )} +
+
+ )} +
+ )} + + )} + + {/* ========== 员工汇总 Tab ========== */} + {tab === 'summary' && ( + + {employeeSummary.length === 0 ? ( +
暂无员工福利数据
+ ) : ( +
+ + + + + + + + + + + {employeeSummary.map((e: any) => ( + + + + + + + ))} + +
姓名部门福利项月度合计
{e.name}{e.department} +
+ {e.benefits.map((b: any, i: number) => ( + + {b.planName} ¥{fmt(b.amount)} + + ))} +
+
¥{fmt(e.totalMonthly)}
+
+ )} +
+ )} + + {/* 新增/编辑方案 Modal */} + {showAddPlan && ( +
setShowAddPlan(false)}> + +
e.stopPropagation()}> +
+

{editingPlan ? '编辑福利方案' : '新增福利方案'}

+ +
+
+
+ + setNewPlan({ ...newPlan, name: e.target.value })} placeholder="如:2024年度交通补贴" /> +
+
+
+ + +
+
+ + +
+
+
+
+ + setNewPlan({ ...newPlan, amount: parseFloat(e.target.value) || 0 })} /> +
+
+ + +
+
+
+ + setNewPlan({ ...newPlan, description: e.target.value })} placeholder="适用条件、发放规则等" /> +
+
+ + +
+
+
+
+
+ )} + + {/* 批量参保 Modal */} + {showEnrollModal && ( + setShowEnrollModal(false)} title="批量参保" size="lg"> +
+ + 选择需要参保的员工,设置生效月份后点击「确认参保」。 + +
+ + setEnrollEffectiveFrom(e.target.value)} className="!w-32" /> +
+
+ + + + + + + + + + + {rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').map((emp: any) => ( + + + + + + + ))} + +
+ e.status === 'ACTIVE').length || 0) && enrollEmployeeIds.length > 0} + onChange={(e) => { + if (e.target.checked) { + setEnrollEmployeeIds(rosterData?.items?.filter((emp: any) => emp.status === 'ACTIVE').map((emp: any) => emp.id) || []) + } else { + setEnrollEmployeeIds([]) + } + }} /> + 姓名部门状态
+ { + if (e.target.checked) setEnrollEmployeeIds([...enrollEmployeeIds, emp.id]) + else setEnrollEmployeeIds(enrollEmployeeIds.filter((id) => id !== emp.id)) + }} /> + {emp.name}{emp.department} + 在职 +
+
+
+ 已选 {enrollEmployeeIds.length} 人 +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index a1bcb81..f993170 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -11,7 +11,6 @@ import Button from '../components/ui/Button' import { Input, Label } from '../components/ui/Input' import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows' import SpecialDeductionTab from './social-insurance/SpecialDeductionTab' -import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab' // 金额格式化:保留两位小数 + 千分位 const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) @@ -19,7 +18,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig export default function SocialInsurance() { const queryClient = useQueryClient() const confirm = useConfirm() - const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'commercial'>('monthly') + const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly') const [city, setCity] = useState('北京') const [showAddCity, setShowAddCity] = useState(false) const [newCityName, setNewCityName] = useState('') @@ -367,7 +366,7 @@ export default function SocialInsurance() { {/* Tab 切换 + 城市选择 */}
- {(['monthly', 'social', 'housing', 'deduction', 'commercial'] as const).map((t) => ( + {(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => ( ))} {(tab === 'social' || tab === 'housing') && ( @@ -1148,11 +1147,6 @@ export default function SocialInsurance() { )} - {/* ========== 商险管理 Tab ========== */} -
- -
-
) }