diff --git a/backend/prisma/migration_add_special_status.sql b/backend/prisma/migration_add_special_status.sql new file mode 100644 index 0000000..6062daf --- /dev/null +++ b/backend/prisma/migration_add_special_status.sql @@ -0,0 +1,45 @@ +-- 员工特殊状态台账表(三期/工伤/医疗期等) +CREATE TABLE IF NOT EXISTS "EmployeeSpecialStatus" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "employeeId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "startDate" TIMESTAMP(3), + "endDate" TIMESTAMP(3), + "actualEndDate" TIMESTAMP(3), + "expectedDueDate" TIMESTAMP(3), + "maternityLeaveStart" TIMESTAMP(3), + "maternityLeaveEnd" TIMESTAMP(3), + "nursingEndDate" TIMESTAMP(3), + "injuryDate" TIMESTAMP(3), + "injuryDescription" TEXT, + "certificationDate" TIMESTAMP(3), + "certificationNo" TEXT, + "disabilityLevel" INTEGER, + "assessmentDate" TIMESTAMP(3), + "medicalMonths" INTEGER, + "medicalPeriodEnd" TIMESTAMP(3), + "description" TEXT, + "attachments" JSONB, + "timeline" JSONB, + "reminderDate" TIMESTAMP(3), + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmployeeSpecialStatus_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "EmployeeSpecialStatus_orgId_status_idx" ON "EmployeeSpecialStatus"("orgId", "status"); +CREATE INDEX IF NOT EXISTS "EmployeeSpecialStatus_orgId_type_idx" ON "EmployeeSpecialStatus"("orgId", "type"); +CREATE INDEX IF NOT EXISTS "EmployeeSpecialStatus_employeeId_idx" ON "EmployeeSpecialStatus"("employeeId"); + +-- 外键约束 +ALTER TABLE "EmployeeSpecialStatus" + ADD CONSTRAINT "EmployeeSpecialStatus_orgId_fkey" + FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE; + +ALTER TABLE "EmployeeSpecialStatus" + ADD CONSTRAINT "EmployeeSpecialStatus_employeeId_fkey" + FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 953e578..d66e757 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -256,6 +256,7 @@ model Employee { leaveRecords LeaveRecord[] calendarEvents CalendarEvent[] workProcesses WorkProcess[] + specialStatuses EmployeeSpecialStatus[] @@unique([orgId, idCardHash]) } @@ -1235,3 +1236,50 @@ model AttendancePublish { @@unique([orgId, month]) @@index([orgId, month]) } + +// ========== 员工特殊状态台账(三期/工伤/医疗期等) ========== +model EmployeeSpecialStatus { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + type String // PREGNANCY(三期) | WORK_INJURY(工伤) | MEDICAL_PERIOD(医疗期) | OTHER + status String @default("ACTIVE") // ACTIVE(进行中) | RESOLVED(已结束) | PENDING(待处理) + + // 通用时间节点 + startDate DateTime? // 开始日期 + endDate DateTime? // 预计结束日期 + actualEndDate DateTime? // 实际结束日期 + + // 三期专用 + expectedDueDate DateTime? // 预产期 + maternityLeaveStart DateTime? // 产假开始 + maternityLeaveEnd DateTime? // 产假结束 + nursingEndDate DateTime? // 哺乳期截止 + + // 工伤专用 + injuryDate DateTime? // 受伤日期 + injuryDescription String? // 伤情描述 + certificationDate DateTime? // 工伤认定日期 + certificationNo String? // 认定书编号 + disabilityLevel Int? // 伤残等级(1-10级) + assessmentDate DateTime? // 鉴定日期 + + // 医疗期专用 + medicalMonths Int? // 医疗期月数 + medicalPeriodEnd DateTime? // 医疗期截止日 + + // 通用 + description String? // 备注 + attachments Json? // 附件列表 [{name, url}] + timeline Json? // 操作时间线 [{time, action, by}] + reminderDate DateTime? // 提醒日期 + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, status]) + @@index([orgId, type]) + @@index([employeeId]) +} diff --git a/backend/src/app.ts b/backend/src/app.ts index bf525cf..c6dc00e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -60,6 +60,7 @@ import calendarRoutes from './routes/calendar.routes' import platformRoutes from './routes/platform.routes' import workProcessRoutes from './routes/work-process.routes' import enterpriseTemplateRoutes from './routes/enterprise-template.routes' +import specialStatusRoutes from './routes/special-status.routes' app.use('/api/v1/auth', authRoutes) app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/employees', employeeRoutes) @@ -84,6 +85,7 @@ app.use('/api/v1/calendar', calendarRoutes) app.use('/api/v1/platform', platformRoutes) app.use('/api/v1/work-processes', workProcessRoutes) app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes) +app.use('/api/v1/special-statuses', specialStatusRoutes) app.use(errorHandler) diff --git a/backend/src/routes/special-status.routes.ts b/backend/src/routes/special-status.routes.ts new file mode 100644 index 0000000..aaf64e3 --- /dev/null +++ b/backend/src/routes/special-status.routes.ts @@ -0,0 +1,201 @@ +/** + * 员工特殊状态台账路由 + * 管理三期/工伤/医疗期等特殊状态的 CRUD 和提醒 + */ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import prisma from '../lib/prisma' +import { + createSpecialStatus, + updateSpecialStatus, + deleteSpecialStatus, + getPendingReminders, + STATUS_TYPES, + getAlertLevel, +} from '../services/special-status.service' + +const router = Router() + +/** + * 获取特殊状态列表(分页 + 筛选) + */ +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const page = parseInt(req.query.page as string) || 1 + const pageSize = parseInt(req.query.pageSize as string) || 20 + const type = (req.query.type as string) || '' + const status = (req.query.status as string) || '' + const search = (req.query.search as string) || '' + + const where: any = { orgId } + if (type) where.type = type + if (status) where.status = status + if (search) { + where.employee = { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search } }, + ], + } + } + + const [list, total] = await Promise.all([ + (prisma as any).employeeSpecialStatus.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + employee: { + select: { id: true, name: true, department: true, phone: true, gender: true, status: true }, + }, + }, + }), + (prisma as any).employeeSpecialStatus.count({ where }), + ]) + + // 附加预警级别 + const listWithAlert = list.map((item: any) => ({ + ...item, + alertLevel: getAlertLevel(item.reminderDate, item.status), + })) + + res.json({ + success: true, + data: { list: listWithAlert, total, page, pageSize }, + }) + } catch (err) { + next(err) + } +}) + +/** + * 获取单条详情 + */ +router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const record = await (prisma as any).employeeSpecialStatus.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + include: { + employee: { + select: { id: true, name: true, department: true, phone: true, gender: true, hireDate: true }, + }, + }, + }) + if (!record) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) + } + res.json({ success: true, data: record }) + } catch (err) { + next(err) + } +}) + +/** + * 创建特殊状态 + */ +router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const record = await createSpecialStatus(req.user!.orgId, req.user!.id, req.body) + res.json({ success: true, data: record }) + } catch (err: any) { + if (err.code) { + return res.status(400).json({ success: false, error: { code: err.code, message: err.message } }) + } + next(err) + } +}) + +/** + * 更新特殊状态 + */ +router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const record = await updateSpecialStatus(req.user!.orgId, req.user!.id, req.params.id, req.body) + res.json({ success: true, data: record }) + } catch (err: any) { + if (err.code) { + return res.status(400).json({ success: false, error: { code: err.code, message: err.message } }) + } + next(err) + } +}) + +/** + * 删除特殊状态 + */ +router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + await deleteSpecialStatus(req.user!.orgId, req.params.id) + res.json({ success: true, data: { message: '已删除' } }) + } catch (err: any) { + if (err.code) { + return res.status(400).json({ success: false, error: { code: err.code, message: err.message } }) + } + next(err) + } +}) + +/** + * 获取待提醒列表 + */ +router.get('/reminders/pending', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const reminders = await getPendingReminders(req.user!.orgId) + res.json({ success: true, data: reminders }) + } catch (err) { + next(err) + } +}) + +/** + * 获取统计概览 + */ +router.get('/stats/overview', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const [total, active, pending, byType] = await Promise.all([ + (prisma as any).employeeSpecialStatus.count({ where: { orgId } }), + (prisma as any).employeeSpecialStatus.count({ where: { orgId, status: 'ACTIVE' } }), + (prisma as any).employeeSpecialStatus.count({ where: { orgId, status: 'PENDING' } }), + (prisma as any).employeeSpecialStatus.groupBy({ + by: ['type'], + where: { orgId, status: 'ACTIVE' }, + _count: true, + }), + ]) + + // 预警统计 + const now = new Date() + const soon7 = new Date() + soon7.setDate(soon7.getDate() + 7) + const soon30 = new Date() + soon30.setDate(soon30.getDate() + 30) + + const [redCount, yellowCount] = await Promise.all([ + (prisma as any).employeeSpecialStatus.count({ + where: { orgId, status: 'ACTIVE', reminderDate: { lte: soon7 } }, + }), + (prisma as any).employeeSpecialStatus.count({ + where: { orgId, status: 'ACTIVE', reminderDate: { gt: soon7, lte: soon30 } }, + }), + ]) + + res.json({ + success: true, + data: { + total, + active, + pending, + redAlert: redCount, + yellowAlert: yellowCount, + byType: byType.map((t: any) => ({ type: t.type, label: STATUS_TYPES[t.type]?.label || t.type, count: t._count })), + }, + }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index aedd593..0f3b1cf 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -305,6 +305,80 @@ export async function detectTerminationRisks(orgId: string) { return risks } +/** + * 检测特殊状态相关风险(三期/工伤/医疗期员工合同到期不得终止) + */ +async function detectSpecialStatusRisks(orgId: string) { + const today = new Date() + today.setHours(0, 0, 0, 0) + + // 查找所有进行中的特殊状态记录 + const specialStatuses = await (prisma as any).employeeSpecialStatus.findMany({ + where: { orgId, status: 'ACTIVE' }, + include: { + employee: { + select: { id: true, name: true, status: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, + }, + }, + }) + + const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] + + for (const ss of specialStatuses) { + const emp = ss.employee + if (!emp || emp.status !== 'ACTIVE') continue + const latestContract = emp.contracts[0] + if (!latestContract || !latestContract.endDate) continue + + const daysToExpire = daysBetween(latestContract.endDate, today) + const typeLabel = ss.type === 'PREGNANCY' ? '三期' : ss.type === 'WORK_INJURY' ? '工伤' : ss.type === 'MEDICAL_PERIOD' ? '医疗期' : '特殊状态' + + // 合同即将到期但处于特殊状态期间,不得终止 + if (daysToExpire >= 0 && daysToExpire <= 60) { + risks.push({ + employeeId: emp.id, + type: 'CONTRACT' as RiskType, + level: 'HIGH' as RiskLevel, + title: `${emp.name}处于${typeLabel}期间,合同${daysToExpire}天后到期,依法不得终止`, + description: `${typeLabel}员工在合同到期时,用人单位不得依照《劳动合同法》第四十条、第四十一条终止合同,需顺延至相应情形消失。`, + actionUrl: `/special-status?type=${ss.type}`, + }) + } + + // 工伤认定超期提醒(受伤30天内未认定) + if (ss.type === 'WORK_INJURY' && ss.injuryDate && !ss.certificationDate) { + const daysSinceInjury = daysBetween(today, new Date(ss.injuryDate)) + if (daysSinceInjury > 30) { + risks.push({ + employeeId: emp.id, + type: 'CONTRACT' as RiskType, + level: 'HIGH' as RiskLevel, + title: `${emp.name}工伤已${daysSinceInjury}天未完成认定,超30天限期`, + description: `用人单位应自事故伤害发生之日起30日内提出工伤认定申请,逾期将影响工伤待遇。`, + actionUrl: `/special-status?type=WORK_INJURY`, + }) + } + } + + // 医疗期即将到期 + if (ss.type === 'MEDICAL_PERIOD' && ss.medicalPeriodEnd) { + const daysToMedicalEnd = daysBetween(new Date(ss.medicalPeriodEnd), today) + if (daysToMedicalEnd >= 0 && daysToMedicalEnd <= 30) { + risks.push({ + employeeId: emp.id, + type: 'CONTRACT' as RiskType, + level: daysToMedicalEnd <= 7 ? 'HIGH' as RiskLevel : 'MEDIUM' as RiskLevel, + title: `${emp.name}医疗期${daysToMedicalEnd}天后到期,需提前处理`, + description: `医疗期届满后,员工仍需治疗的,用人单位应按规定处理。医疗期满不能从事原工作的,可依法解除合同但需支付经济补偿。`, + actionUrl: `/special-status?type=MEDICAL_PERIOD`, + }) + } + } + } + + return risks +} + export async function detectMonthlyTasks(orgId: string) { const setting = await prisma.notificationSetting.findUnique({ where: { orgId } }) if (!setting) return [] @@ -372,9 +446,10 @@ export async function runRiskDetection(orgId: string) { const terminationRisks = await detectTerminationRisks(orgId) const onboardingRisks = await detectOnboardingRisks(orgId) const monthlyTasks = await detectMonthlyTasks(orgId) + const specialStatusRisks = await detectSpecialStatusRisks(orgId) // 获取所有相关员工数据用于风险量化 - const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks] + const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks] .map(r => r.employeeId) .filter(Boolean) as string[] const employees = allEmployeeIds.length > 0 @@ -383,7 +458,7 @@ export async function runRiskDetection(orgId: string) { const empMap = new Map(employees.map(e => [e.id, e])) // 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重 - const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks] + const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks] const toCreate = [ ...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}:${r.actionUrl}`)), ...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)), diff --git a/backend/src/services/special-status.service.ts b/backend/src/services/special-status.service.ts new file mode 100644 index 0000000..20271a1 --- /dev/null +++ b/backend/src/services/special-status.service.ts @@ -0,0 +1,270 @@ +/** + * 员工特殊状态台账服务 + * 管理三期(孕期/产期/哺乳期)、工伤、医疗期等特殊状态的跟踪和提醒 + */ +import prisma from '../lib/prisma' + +/** 特殊状态类型 */ +export const STATUS_TYPES: Record = { + PREGNANCY: { label: '三期', color: 'pink' }, + WORK_INJURY: { label: '工伤', color: 'red' }, + MEDICAL_PERIOD: { label: '医疗期', color: 'orange' }, + OTHER: { label: '其他', color: 'gray' }, +} + +/** 状态文本映射 */ +export const STATUS_LABELS: Record = { + ACTIVE: '进行中', + PENDING: '待处理', + RESOLVED: '已结束', +} + +/** + * 获取预警级别(基于提醒日期) + * - red: 已过期或7天内到期 + * - yellow: 30天内到期 + * - green: 正常 + */ +export function getAlertLevel(reminderDate: Date | null, status: string): 'red' | 'yellow' | 'green' { + if (status === 'RESOLVED') return 'green' + if (!reminderDate) return 'green' + const now = new Date() + const days = Math.floor((reminderDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + if (days <= 7) return 'red' + if (days <= 30) return 'yellow' + return 'green' +} + +/** + * 根据三期数据自动计算关键日期 + * - 产假: 产前15天 + 产后75天(共98天,难产+15天) + * - 哺乳期: 至孩子满1周岁 + */ +export function calculatePregnancyDates(expectedDueDate: Date): { + maternityLeaveStart: Date + maternityLeaveEnd: Date + nursingEndDate: Date +} { + // 产假开始: 预产期前15天 + const maternityLeaveStart = new Date(expectedDueDate) + maternityLeaveStart.setDate(maternityLeaveStart.getDate() - 15) + + // 产假结束: 预产期后75天(共98天) + const maternityLeaveEnd = new Date(expectedDueDate) + maternityLeaveEnd.setDate(maternityLeaveEnd.getDate() + 75) + + // 哺乳期截止: 孩子满1周岁 + const nursingEndDate = new Date(expectedDueDate) + nursingEndDate.setFullYear(nursingEndDate.getFullYear() + 1) + + return { maternityLeaveStart, maternityLeaveEnd, nursingEndDate } +} + +/** + * 根据工龄计算医疗期月数 + * - 实际工作年限 < 5年: 本单位 < 3年 → 3个月, ≥ 3年 → 6个月 + * - 实际工作年限 ≥ 5年: 本单位 < 3年 → 6个月, 3~5年 → 9个月, 5~10年 → 12个月, ≥10年 → 24个月 + */ +export function calculateMedicalMonths(totalWorkYears: number, companyYears: number): number { + if (totalWorkYears < 5) { + return companyYears < 3 ? 3 : 6 + } + if (companyYears < 3) return 6 + if (companyYears < 5) return 9 + if (companyYears < 10) return 12 + return 24 +} + +/** + * 添加操作时间线条目 + */ +function addTimelineEntry(existing: any[], action: string, by: string): any[] { + const entry = { time: new Date().toISOString(), action, by } + return [...(existing || []), entry] +} + +/** + * 创建特殊状态记录 + */ +export async function createSpecialStatus(orgId: string, userId: string, data: any) { + const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } }) + if (!employee) { + throw { code: 'NOT_FOUND', message: '员工不存在' } + } + + // 三期自动计算 + let pregnancyData: any = {} + if (data.type === 'PREGNANCY' && data.expectedDueDate) { + const dates = calculatePregnancyDates(new Date(data.expectedDueDate)) + pregnancyData = dates + } + + // 医疗期自动计算截止日 + let medicalData: any = {} + if (data.type === 'MEDICAL_PERIOD' && data.startDate && data.medicalMonths) { + const end = new Date(data.startDate) + end.setMonth(end.getMonth() + data.medicalMonths) + medicalData.medicalPeriodEnd = end + } + + const record = await (prisma as any).employeeSpecialStatus.create({ + data: { + orgId, + employeeId: data.employeeId, + type: data.type, + status: data.status || 'ACTIVE', + startDate: data.startDate ? new Date(data.startDate) : null, + endDate: data.endDate ? new Date(data.endDate) : null, + expectedDueDate: data.expectedDueDate ? new Date(data.expectedDueDate) : null, + maternityLeaveStart: pregnancyData.maternityLeaveStart || null, + maternityLeaveEnd: pregnancyData.maternityLeaveEnd || null, + nursingEndDate: pregnancyData.nursingEndDate || null, + injuryDate: data.injuryDate ? new Date(data.injuryDate) : null, + injuryDescription: data.injuryDescription || null, + certificationDate: data.certificationDate ? new Date(data.certificationDate) : null, + certificationNo: data.certificationNo || null, + disabilityLevel: data.disabilityLevel || null, + assessmentDate: data.assessmentDate ? new Date(data.assessmentDate) : null, + medicalMonths: data.medicalMonths || null, + medicalPeriodEnd: medicalData.medicalPeriodEnd || null, + description: data.description || null, + attachments: data.attachments || null, + reminderDate: data.reminderDate ? new Date(data.reminderDate) : null, + timeline: addTimelineEntry([], '创建记录', userId), + createdBy: userId, + }, + }) + + // 同步更新 Employee 的布尔字段 + await syncEmployeeFlags(employee.id, data.type, true) + + return record +} + +/** + * 更新特殊状态记录 + */ +export async function updateSpecialStatus(orgId: string, userId: string, id: string, data: any) { + const existing = await (prisma as any).employeeSpecialStatus.findFirst({ + where: { id, orgId }, + }) + if (!existing) { + throw { code: 'NOT_FOUND', message: '记录不存在' } + } + + const updateData: any = {} + const fields = [ + 'type', 'status', 'startDate', 'endDate', 'actualEndDate', + 'expectedDueDate', 'maternityLeaveStart', 'maternityLeaveEnd', 'nursingEndDate', + 'injuryDate', 'injuryDescription', 'certificationDate', 'certificationNo', + 'disabilityLevel', 'assessmentDate', 'medicalMonths', 'medicalPeriodEnd', + 'description', 'attachments', 'reminderDate', + ] + for (const f of fields) { + if (data[f] !== undefined) { + updateData[f] = data[f] === null ? null : (data[f] instanceof Date || f === 'injuryDescription' || f === 'certificationNo' || f === 'description' || f === 'type' || f === 'status' || f === 'attachments' ? data[f] : new Date(data[f])) + } + } + + // 三期自动计算 + if (data.type === 'PREGNANCY' && data.expectedDueDate) { + const dates = calculatePregnancyDates(new Date(data.expectedDueDate)) + updateData.maternityLeaveStart = dates.maternityLeaveStart + updateData.maternityLeaveEnd = dates.maternityLeaveEnd + updateData.nursingEndDate = dates.nursingEndDate + } + + // 医疗期自动计算截止日 + if (data.type === 'MEDICAL_PERIOD' && data.startDate && data.medicalMonths) { + const end = new Date(data.startDate) + end.setMonth(end.getMonth() + data.medicalMonths) + updateData.medicalPeriodEnd = end + } + + // 记录时间线 + const actions: string[] = [] + if (data.status && data.status !== existing.status) { + actions.push(`状态变更为: ${STATUS_LABELS[data.status] || data.status}`) + } + if (data.certificationDate && !existing.certificationDate) { + actions.push('工伤认定完成') + } + if (data.assessmentDate && !existing.assessmentDate) { + actions.push('伤残鉴定完成') + } + if (data.actualEndDate && !existing.actualEndDate) { + actions.push('状态结束') + } + if (actions.length === 0) { + actions.push('更新记录') + } + updateData.timeline = addTimelineEntry(existing.timeline, actions.join('; '), userId) + + const record = await (prisma as any).employeeSpecialStatus.update({ + where: { id }, + data: updateData, + }) + + // 如果状态变为 RESOLVED,同步 Employee 布尔字段 + if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') { + await syncEmployeeFlags(existing.employeeId, existing.type, false) + } + + return record +} + +/** + * 删除特殊状态记录 + */ +export async function deleteSpecialStatus(orgId: string, id: string) { + const existing = await (prisma as any).employeeSpecialStatus.findFirst({ + where: { id, orgId }, + }) + if (!existing) { + throw { code: 'NOT_FOUND', message: '记录不存在' } + } + + await (prisma as any).employeeSpecialStatus.delete({ where: { id } }) + + // 同步 Employee 布尔字段 + if (existing.status === 'ACTIVE') { + await syncEmployeeFlags(existing.employeeId, existing.type, false) + } + + return { id } +} + +/** + * 同步 Employee 表的布尔标记字段 + */ +async function syncEmployeeFlags(employeeId: string, type: string, active: boolean) { + const updateData: any = {} + if (type === 'PREGNANCY') updateData.isPregnant = active + if (type === 'MEDICAL_PERIOD') updateData.isInMedicalPeriod = active + if (type === 'WORK_INJURY') updateData.isWorkInjured = active + + if (Object.keys(updateData).length > 0) { + await prisma.employee.update({ where: { id: employeeId }, data: updateData }) + } +} + +/** + * 获取需要提醒的特殊状态列表 + */ +export async function getPendingReminders(orgId: string) { + const now = new Date() + const soon = new Date() + soon.setDate(soon.getDate() + 7) + + return (prisma as any).employeeSpecialStatus.findMany({ + where: { + orgId, + status: 'ACTIVE', + reminderDate: { gte: now, lte: soon }, + }, + include: { + employee: { select: { id: true, name: true, department: true, phone: true } }, + }, + orderBy: { reminderDate: 'asc' }, + }) +} diff --git a/backend/src/services/termination.service.ts b/backend/src/services/termination.service.ts index a58c58b..c7ae034 100644 --- a/backend/src/services/termination.service.ts +++ b/backend/src/services/termination.service.ts @@ -220,6 +220,21 @@ export async function createTermination(orgId: string, userId: string, data: any throw { code: 'NOT_FOUND', message: '员工不存在' } } + // 特殊状态拦截:三期/工伤/医疗期员工不得违法终止 + const activeSpecialStatus = await (prisma as any).employeeSpecialStatus.findFirst({ + where: { orgId, employeeId: data.employeeId, status: 'ACTIVE' }, + }) + if (activeSpecialStatus) { + const typeLabel = activeSpecialStatus.type === 'PREGNANCY' ? '三期(孕期/产期/哺乳期)' + : activeSpecialStatus.type === 'WORK_INJURY' ? '工伤' + : activeSpecialStatus.type === 'MEDICAL_PERIOD' ? '医疗期' + : '特殊状态' + throw { + code: 'SPECIAL_STATUS_BLOCK', + message: `该员工处于${typeLabel}期间,依法不得终止劳动合同。如需操作,请先在特殊状态台账中结束该状态记录。`, + } + } + const { level } = assessRisk(employee, data.reason) return createTerminationRecord( diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 56009d7..217bb76 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -14,9 +14,9 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"] - } + }, + "ignoreDeprecations": "6.0" }, "include": ["src/**/*", "prisma/**/*"], - "exclude": ["node_modules", "dist"], - "ignoreDeprecations": "6.0" + "exclude": ["node_modules", "dist"] } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 909500e..a5456bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) const CalendarPage = lazy(() => import('./pages/Calendar')) const WorkProcess = lazy(() => import('./pages/WorkProcess')) const MyAttendance = lazy(() => import('./pages/portal/MyAttendance')) +const SpecialStatus = lazy(() => import('./pages/SpecialStatus')) // 平台管理端 const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin')) @@ -155,6 +156,7 @@ export default function App() { } /> } /> } /> + } /> {/* 平台管理端 */} }>} /> diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 7339703..eb056f6 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -13,7 +13,7 @@ import { Bot, BookMarked, Bell, ScrollText, Settings, ChevronDown, ChevronRight, - Building2, CalendarDays, ClipboardList, + Building2, CalendarDays, ClipboardList, Heart, } from 'lucide-react' import Logo from '../ui/Logo' @@ -43,6 +43,7 @@ const navGroups: NavGroup[] = [ { path: '/work-process', label: '用工办理', icon: ClipboardList }, { path: '/attendance', label: '考勤确认', icon: CalendarCheck }, { path: '/termination', label: '解聘补偿', icon: UserX }, + { path: '/special-status', label: '特殊状态', icon: Heart }, ], }, { diff --git a/frontend/src/pages/SpecialStatus.tsx b/frontend/src/pages/SpecialStatus.tsx new file mode 100644 index 0000000..428d928 --- /dev/null +++ b/frontend/src/pages/SpecialStatus.tsx @@ -0,0 +1,610 @@ +/** + * 员工特殊状态台账页面 + * 管理三期(孕期/产期/哺乳期)、工伤、医疗期等特殊状态的跟踪和提醒 + */ +import { useEffect, useState } from 'react' +import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react' +import api from '../lib/api' +import { Input, Select, Label } from '../components/ui/Input' +import Button from '../components/ui/Button' + +interface SpecialStatus { + id: string + type: string + status: string + startDate: string | null + endDate: string | null + actualEndDate: string | null + expectedDueDate: string | null + maternityLeaveStart: string | null + maternityLeaveEnd: string | null + nursingEndDate: string | null + injuryDate: string | null + injuryDescription: string | null + certificationDate: string | null + certificationNo: string | null + disabilityLevel: number | null + assessmentDate: string | null + medicalMonths: number | null + medicalPeriodEnd: string | null + description: string | null + attachments: any[] | null + timeline: any[] | null + reminderDate: string | null + alertLevel: 'red' | 'yellow' | 'green' + employee: { + id: string; name: string; department: string; phone: string; gender: string; status: string + } +} + +interface Stats { + total: number + active: number + pending: number + redAlert: number + yellowAlert: number + byType: { type: string; label: string; count: number }[] +} + +const TYPE_LABELS: Record = { + PREGNANCY: '三期', + WORK_INJURY: '工伤', + MEDICAL_PERIOD: '医疗期', + OTHER: '其他', +} + +const TYPE_ICONS: Record = { + PREGNANCY: Baby, + WORK_INJURY: Activity, + MEDICAL_PERIOD: HeartPulse, + OTHER: AlertTriangle, +} + +const TYPE_COLORS: Record = { + PREGNANCY: 'bg-pink-50 text-pink-700 border-pink-200', + WORK_INJURY: 'bg-red-50 text-red-700 border-red-200', + MEDICAL_PERIOD: 'bg-orange-50 text-orange-700 border-orange-200', + OTHER: 'bg-gray-50 text-gray-700 border-gray-200', +} + +const STATUS_LABELS: Record = { + ACTIVE: '进行中', + PENDING: '待处理', + RESOLVED: '已结束', +} + +const STATUS_COLORS: Record = { + ACTIVE: 'bg-emerald-50 text-emerald-700', + PENDING: 'bg-amber-50 text-amber-700', + RESOLVED: 'bg-gray-100 text-gray-500', +} + +const ALERT_STYLES: Record = { + red: { border: 'border-l-4 border-l-red-500', badge: 'bg-red-100 text-red-700', text: 'text-red-600' }, + yellow: { border: 'border-l-4 border-l-amber-500', badge: 'bg-amber-100 text-amber-700', text: 'text-amber-600' }, + green: { border: 'border-l-4 border-l-emerald-500', badge: 'bg-emerald-100 text-emerald-700', text: 'text-emerald-600' }, +} + +function formatDate(d: string | null): string { + if (!d) return '-' + return new Date(d).toLocaleDateString('zh-CN') +} + +function daysUntil(d: string | null): number | null { + if (!d) return null + const diff = new Date(d).getTime() - Date.now() + return Math.ceil(diff / (1000 * 60 * 60 * 24)) +} + +export default function SpecialStatus() { + const [list, setList] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize] = useState(20) + const [search, setSearch] = useState('') + const [typeFilter, setTypeFilter] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [loading, setLoading] = useState(true) + const [stats, setStats] = useState(null) + const [editOpen, setEditOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [employees, setEmployees] = useState([]) + const [form, setForm] = useState(getDefaultForm()) + + function getDefaultForm() { + return { + employeeId: '', + type: 'PREGNANCY', + status: 'ACTIVE', + startDate: '', + endDate: '', + actualEndDate: '', + expectedDueDate: '', + injuryDate: '', + injuryDescription: '', + certificationDate: '', + certificationNo: '', + disabilityLevel: '', + assessmentDate: '', + medicalMonths: '', + description: '', + reminderDate: '', + } + } + + const fetchList = async () => { + setLoading(true) + try { + const params: any = { page, pageSize } + if (search) params.search = search + if (typeFilter) params.type = typeFilter + if (statusFilter) params.status = statusFilter + const res = await api.get('/special-statuses', { params }) as any + setList(res.data.list) + setTotal(res.data.total) + } finally { + setLoading(false) + } + } + + const fetchStats = async () => { + try { + const res = await api.get('/special-statuses/stats/overview') as any + setStats(res.data) + } catch { + // 忽略 + } + } + + const fetchEmployees = async () => { + try { + const res = await api.get('/employees', { params: { pageSize: 999 } }) as any + setEmployees(res.data.list || []) + } catch { + // 忽略 + } + } + + useEffect(() => { fetchList(); fetchStats() }, [page, typeFilter, statusFilter]) + useEffect(() => { setPage(1) }, [search, typeFilter, statusFilter]) + + const handleOpenCreate = () => { + setEditing(null) + setForm(getDefaultForm()) + setEditOpen(true) + fetchEmployees() + } + + const handleOpenEdit = (item: SpecialStatus) => { + setEditing(item) + setForm({ + employeeId: item.employee.id, + type: item.type, + status: item.status, + startDate: item.startDate ? item.startDate.split('T')[0] : '', + endDate: item.endDate ? item.endDate.split('T')[0] : '', + actualEndDate: item.actualEndDate ? item.actualEndDate.split('T')[0] : '', + expectedDueDate: item.expectedDueDate ? item.expectedDueDate.split('T')[0] : '', + injuryDate: item.injuryDate ? item.injuryDate.split('T')[0] : '', + injuryDescription: item.injuryDescription || '', + certificationDate: item.certificationDate ? item.certificationDate.split('T')[0] : '', + certificationNo: item.certificationNo || '', + disabilityLevel: item.disabilityLevel || '', + assessmentDate: item.assessmentDate ? item.assessmentDate.split('T')[0] : '', + medicalMonths: item.medicalMonths || '', + description: item.description || '', + reminderDate: item.reminderDate ? item.reminderDate.split('T')[0] : '', + }) + setEditOpen(true) + fetchEmployees() + } + + const handleSave = async () => { + if (!form.employeeId) { alert('请选择员工'); return } + try { + const data: any = { ...form } + // 空字符串转 null + Object.keys(data).forEach((k) => { + if (data[k] === '') data[k] = null + }) + if (data.disabilityLevel) data.disabilityLevel = parseInt(data.disabilityLevel) + if (data.medicalMonths) data.medicalMonths = parseInt(data.medicalMonths) + + if (editing) { + await api.put(`/special-statuses/${editing.id}`, data) + } else { + await api.post('/special-statuses', data) + } + setEditOpen(false) + fetchList() + fetchStats() + } catch (err: any) { + alert(err.response?.data?.error?.message || '保存失败') + } + } + + const handleDelete = async () => { + if (!deleteTarget) return + try { + await api.delete(`/special-statuses/${deleteTarget.id}`) + setDeleteTarget(null) + fetchList() + fetchStats() + } catch (err: any) { + alert(err.response?.data?.error?.message || '删除失败') + } + } + + const totalPages = Math.ceil(total / pageSize) + + return ( +
+ {/* 标题 */} +
+

特殊状态台账

+

三期/工伤/医疗期等特殊员工状态跟踪与提醒

+
+ + {/* 统计卡片 */} + {stats && ( +
+
+
总记录
+
{stats.total}
+
+
+
进行中
+
{stats.active}
+
+
+
待处理
+
{stats.pending}
+
+
+
紧急提醒
+
{stats.redAlert}
+
+
+
即将到期
+
{stats.yellowAlert}
+
+
+
类型分布
+
+ {stats.byType.map((t) => ( + {t.label}: {t.count} + ))} +
+
+
+ )} + + {/* 筛选栏 */} +
+
+ + setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && fetchList()} + /> +
+ + + + +
+ + {/* 列表 */} + {loading ? ( +
加载中...
+ ) : list.length === 0 ? ( +
+ + 暂无特殊状态记录 +
+ ) : ( + <> +
+ {list.map((item) => { + const Icon = TYPE_ICONS[item.type] || AlertTriangle + const alert = ALERT_STYLES[item.alertLevel] || ALERT_STYLES.green + const reminderDays = daysUntil(item.reminderDate) + return ( +
+ {/* 头部 */} +
+
+ + + {TYPE_LABELS[item.type] || item.type} + + + {STATUS_LABELS[item.status] || item.status} + +
+
+ + +
+
+ + {/* 员工信息 */} +
+ {item.employee.name} + {item.employee.department} + {item.employee.status === 'RESIGNED' && ( + 已离职 + )} +
+ + {/* 关键日期 */} +
+ {item.type === 'PREGNANCY' && ( + <> + {item.expectedDueDate && } + {item.maternityLeaveStart && } + {item.maternityLeaveEnd && } + {item.nursingEndDate && } + + )} + {item.type === 'WORK_INJURY' && ( + <> + {item.injuryDate && } + {item.injuryDescription && } + {item.certificationDate ? ( + + ) : ( + + )} + {item.assessmentDate && } + {item.disabilityLevel && } + + )} + {item.type === 'MEDICAL_PERIOD' && ( + <> + {item.startDate && } + {item.medicalMonths && } + {item.medicalPeriodEnd && } + + )} + {item.type === 'OTHER' && ( + <> + {item.startDate && } + {item.endDate && } + + )} +
+ + {/* 提醒 */} + {item.status === 'ACTIVE' && reminderDays !== null && ( +
+ + {reminderDays < 0 ? `已过期 ${Math.abs(reminderDays)} 天` : `距提醒日还有 ${reminderDays} 天`} +
+ )} + {item.description && ( +
{item.description}
+ )} +
+ ) + })} +
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} + + )} + + {/* 新增/编辑弹窗 */} + {editOpen && ( +
setEditOpen(false)}> +
e.stopPropagation()}> +
+

{editing ? '编辑特殊状态' : '新增特殊状态'}

+ +
+ +
+ {/* 员工选择 */} +
+ + +
+ + {/* 类型 + 状态 */} +
+
+ + +
+
+ + +
+
+ + {/* 三期专用字段 */} + {form.type === 'PREGNANCY' && ( +
+

三期信息

+
+ + setForm({ ...form, expectedDueDate: e.target.value })} /> +

填写预产期后,系统将自动计算产假起止和哺乳期截止日期

+
+
+ )} + + {/* 工伤专用字段 */} + {form.type === 'WORK_INJURY' && ( +
+

工伤信息

+
+ + setForm({ ...form, injuryDate: e.target.value })} /> +
+
+ + setForm({ ...form, injuryDescription: e.target.value })} placeholder="简要描述伤情" /> +
+
+
+ + setForm({ ...form, certificationDate: e.target.value })} /> +
+
+ + setForm({ ...form, certificationNo: e.target.value })} /> +
+
+
+
+ + setForm({ ...form, disabilityLevel: e.target.value })} placeholder="未鉴定则留空" /> +
+
+ + setForm({ ...form, assessmentDate: e.target.value })} /> +
+
+
+ )} + + {/* 医疗期专用字段 */} + {form.type === 'MEDICAL_PERIOD' && ( +
+

医疗期信息

+
+
+ + setForm({ ...form, startDate: e.target.value })} /> +
+
+ + setForm({ ...form, medicalMonths: e.target.value })} placeholder="如3/6/9/12/24" /> +
+
+

填写开始日期和月数后,系统将自动计算截止日期

+
+ )} + + {/* 其他类型 */} + {form.type === 'OTHER' && ( +
+
+
+ + setForm({ ...form, startDate: e.target.value })} /> +
+
+ + setForm({ ...form, endDate: e.target.value })} /> +
+
+
+ )} + + {/* 通用字段 */} +
+ {form.status === 'RESOLVED' && ( +
+ + setForm({ ...form, actualEndDate: e.target.value })} /> +
+ )} +
+ + setForm({ ...form, reminderDate: e.target.value })} /> +

系统将在此日期前7天和30天分别发出提醒

+
+
+ + setForm({ ...form, description: e.target.value })} placeholder="补充说明" /> +
+
+ + {/* 操作按钮 */} +
+ + +
+
+
+
+ )} + + {/* 删除确认 */} + {deleteTarget && ( +
setDeleteTarget(null)}> +
e.stopPropagation()}> +
+
+ +
+
+

确认删除

+

删除后无法恢复

+
+
+

+ 确定要删除 {deleteTarget.employee.name}{TYPE_LABELS[deleteTarget.type]} 记录吗? +

+
+ + +
+
+
+ )} +
+ ) +} + +/** 信息行组件 */ +function Row({ label, value, valueClass }: { label: string; value: string; valueClass?: string }) { + return ( +
+ {label} + {value} +
+ ) +}