feat: 员工特殊状态台账(三期/工伤/医疗期)完整实现
This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}`)),
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 员工特殊状态台账服务
|
||||
* 管理三期(孕期/产期/哺乳期)、工伤、医疗期等特殊状态的跟踪和提醒
|
||||
*/
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
/** 特殊状态类型 */
|
||||
export const STATUS_TYPES: Record<string, { label: string; color: string }> = {
|
||||
PREGNANCY: { label: '三期', color: 'pink' },
|
||||
WORK_INJURY: { label: '工伤', color: 'red' },
|
||||
MEDICAL_PERIOD: { label: '医疗期', color: 'orange' },
|
||||
OTHER: { label: '其他', color: 'gray' },
|
||||
}
|
||||
|
||||
/** 状态文本映射 */
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
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' },
|
||||
})
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"ignoreDeprecations": "6.0"
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
{/* 平台管理端 */}
|
||||
<Route path="/platform/login" element={<Suspense fallback={<SkeletonPage />}><PlatformLogin /></Suspense>} />
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<string, string> = {
|
||||
PREGNANCY: '三期',
|
||||
WORK_INJURY: '工伤',
|
||||
MEDICAL_PERIOD: '医疗期',
|
||||
OTHER: '其他',
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, typeof Baby> = {
|
||||
PREGNANCY: Baby,
|
||||
WORK_INJURY: Activity,
|
||||
MEDICAL_PERIOD: HeartPulse,
|
||||
OTHER: AlertTriangle,
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
ACTIVE: '进行中',
|
||||
PENDING: '待处理',
|
||||
RESOLVED: '已结束',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
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<string, { border: string; badge: string; text: string }> = {
|
||||
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<SpecialStatus[]>([])
|
||||
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<Stats | null>(null)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<SpecialStatus | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<SpecialStatus | null>(null)
|
||||
const [employees, setEmployees] = useState<any[]>([])
|
||||
const [form, setForm] = useState<any>(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 (
|
||||
<div className="space-y-6">
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">特殊状态台账</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">三期/工伤/医疗期等特殊员工状态跟踪与提醒</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">总记录</div>
|
||||
<div className="text-xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">进行中</div>
|
||||
<div className="text-xl font-bold text-emerald-600">{stats.active}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">待处理</div>
|
||||
<div className="text-xl font-bold text-amber-600">{stats.pending}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-red-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">紧急提醒</div>
|
||||
<div className="text-xl font-bold text-red-600">{stats.redAlert}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-amber-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">即将到期</div>
|
||||
<div className="text-xl font-bold text-amber-600">{stats.yellowAlert}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">类型分布</div>
|
||||
<div className="text-xs text-gray-700">
|
||||
{stats.byType.map((t) => (
|
||||
<span key={t.type} className="mr-2">{t.label}: <b>{t.count}</b></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索员工姓名、手机号..."
|
||||
className="pl-10"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchList()}
|
||||
/>
|
||||
</div>
|
||||
<Select className="w-32" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
|
||||
<option value="">全部类型</option>
|
||||
<option value="PREGNANCY">三期</option>
|
||||
<option value="WORK_INJURY">工伤</option>
|
||||
<option value="MEDICAL_PERIOD">医疗期</option>
|
||||
<option value="OTHER">其他</option>
|
||||
</Select>
|
||||
<Select className="w-32" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">进行中</option>
|
||||
<option value="PENDING">待处理</option>
|
||||
<option value="RESOLVED">已结束</option>
|
||||
</Select>
|
||||
<Button variant="secondary" onClick={fetchList}>搜索</Button>
|
||||
<Button onClick={handleOpenCreate}><Plus className="w-4 h-4 mr-1" />新增</Button>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<AlertTriangle className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
暂无特殊状态记录
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{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 (
|
||||
<div key={item.id} className={`bg-white border ${alert.border} border-gray-200 rounded-lg p-4`}>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${TYPE_COLORS[item.type] || TYPE_COLORS.OTHER}`}>
|
||||
<Icon className="w-3 h-3 inline mr-1" />
|
||||
{TYPE_LABELS[item.type] || item.type}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[item.status] || STATUS_COLORS.RESOLVED}`}>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleOpenEdit(item)} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="编辑">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(item)} className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-gray-100" title="删除">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 员工信息 */}
|
||||
<div className="mb-3">
|
||||
<span className="font-medium text-gray-900">{item.employee.name}</span>
|
||||
<span className="text-sm text-gray-500 ml-2">{item.employee.department}</span>
|
||||
{item.employee.status === 'RESIGNED' && (
|
||||
<span className="ml-2 text-xs text-gray-400">已离职</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关键日期 */}
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{item.type === 'PREGNANCY' && (
|
||||
<>
|
||||
{item.expectedDueDate && <Row label="预产期" value={formatDate(item.expectedDueDate)} />}
|
||||
{item.maternityLeaveStart && <Row label="产假开始" value={formatDate(item.maternityLeaveStart)} />}
|
||||
{item.maternityLeaveEnd && <Row label="产假结束" value={formatDate(item.maternityLeaveEnd)} />}
|
||||
{item.nursingEndDate && <Row label="哺乳期截止" value={formatDate(item.nursingEndDate)} />}
|
||||
</>
|
||||
)}
|
||||
{item.type === 'WORK_INJURY' && (
|
||||
<>
|
||||
{item.injuryDate && <Row label="受伤日期" value={formatDate(item.injuryDate)} />}
|
||||
{item.injuryDescription && <Row label="伤情描述" value={item.injuryDescription} />}
|
||||
{item.certificationDate ? (
|
||||
<Row label="认定日期" value={formatDate(item.certificationDate)} />
|
||||
) : (
|
||||
<Row label="认定日期" value="待认定" valueClass="text-amber-600" />
|
||||
)}
|
||||
{item.assessmentDate && <Row label="鉴定日期" value={formatDate(item.assessmentDate)} />}
|
||||
{item.disabilityLevel && <Row label="伤残等级" value={`${item.disabilityLevel}级`} />}
|
||||
</>
|
||||
)}
|
||||
{item.type === 'MEDICAL_PERIOD' && (
|
||||
<>
|
||||
{item.startDate && <Row label="开始日期" value={formatDate(item.startDate)} />}
|
||||
{item.medicalMonths && <Row label="医疗期" value={`${item.medicalMonths}个月`} />}
|
||||
{item.medicalPeriodEnd && <Row label="截止日期" value={formatDate(item.medicalPeriodEnd)} />}
|
||||
</>
|
||||
)}
|
||||
{item.type === 'OTHER' && (
|
||||
<>
|
||||
{item.startDate && <Row label="开始日期" value={formatDate(item.startDate)} />}
|
||||
{item.endDate && <Row label="预计结束" value={formatDate(item.endDate)} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提醒 */}
|
||||
{item.status === 'ACTIVE' && reminderDays !== null && (
|
||||
<div className={`mt-3 pt-3 border-t border-gray-100 flex items-center gap-1.5 text-xs ${alert.text}`}>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{reminderDays < 0 ? `已过期 ${Math.abs(reminderDays)} 天` : `距提醒日还有 ${reminderDays} 天`}
|
||||
</div>
|
||||
)}
|
||||
{item.description && (
|
||||
<div className="mt-2 text-xs text-gray-400 line-clamp-2">{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>上一页</Button>
|
||||
<span className="text-sm text-gray-500">{page} / {totalPages}</span>
|
||||
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑弹窗 */}
|
||||
{editOpen && (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={() => setEditOpen(false)}>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{editing ? '编辑特殊状态' : '新增特殊状态'}</h2>
|
||||
<button onClick={() => setEditOpen(false)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 员工选择 */}
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })} disabled={!!editing}>
|
||||
<option value="">请选择员工</option>
|
||||
{employees.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 类型 + 状态 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||
<option value="PREGNANCY">三期</option>
|
||||
<option value="WORK_INJURY">工伤</option>
|
||||
<option value="MEDICAL_PERIOD">医疗期</option>
|
||||
<option value="OTHER">其他</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>状态</Label>
|
||||
<Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
||||
<option value="ACTIVE">进行中</option>
|
||||
<option value="PENDING">待处理</option>
|
||||
<option value="RESOLVED">已结束</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 三期专用字段 */}
|
||||
{form.type === 'PREGNANCY' && (
|
||||
<div className="border-t border-gray-100 pt-3 space-y-3">
|
||||
<h3 className="text-xs text-primary font-medium">三期信息</h3>
|
||||
<div>
|
||||
<Label>预产期</Label>
|
||||
<Input type="date" value={form.expectedDueDate} onChange={(e) => setForm({ ...form, expectedDueDate: e.target.value })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填写预产期后,系统将自动计算产假起止和哺乳期截止日期</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工伤专用字段 */}
|
||||
{form.type === 'WORK_INJURY' && (
|
||||
<div className="border-t border-gray-100 pt-3 space-y-3">
|
||||
<h3 className="text-xs text-primary font-medium">工伤信息</h3>
|
||||
<div>
|
||||
<Label>受伤日期</Label>
|
||||
<Input type="date" value={form.injuryDate} onChange={(e) => setForm({ ...form, injuryDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>伤情描述</Label>
|
||||
<Input value={form.injuryDescription} onChange={(e) => setForm({ ...form, injuryDescription: e.target.value })} placeholder="简要描述伤情" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>认定日期</Label>
|
||||
<Input type="date" value={form.certificationDate} onChange={(e) => setForm({ ...form, certificationDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>认定书编号</Label>
|
||||
<Input value={form.certificationNo} onChange={(e) => setForm({ ...form, certificationNo: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>伤残等级(1-10)</Label>
|
||||
<Input type="number" min={1} max={10} value={form.disabilityLevel} onChange={(e) => setForm({ ...form, disabilityLevel: e.target.value })} placeholder="未鉴定则留空" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>鉴定日期</Label>
|
||||
<Input type="date" value={form.assessmentDate} onChange={(e) => setForm({ ...form, assessmentDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 医疗期专用字段 */}
|
||||
{form.type === 'MEDICAL_PERIOD' && (
|
||||
<div className="border-t border-gray-100 pt-3 space-y-3">
|
||||
<h3 className="text-xs text-primary font-medium">医疗期信息</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>开始日期</Label>
|
||||
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>医疗期月数</Label>
|
||||
<Input type="number" value={form.medicalMonths} onChange={(e) => setForm({ ...form, medicalMonths: e.target.value })} placeholder="如3/6/9/12/24" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">填写开始日期和月数后,系统将自动计算截止日期</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 其他类型 */}
|
||||
{form.type === 'OTHER' && (
|
||||
<div className="border-t border-gray-100 pt-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>开始日期</Label>
|
||||
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>预计结束日期</Label>
|
||||
<Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通用字段 */}
|
||||
<div className="border-t border-gray-100 pt-3 space-y-3">
|
||||
{form.status === 'RESOLVED' && (
|
||||
<div>
|
||||
<Label>实际结束日期</Label>
|
||||
<Input type="date" value={form.actualEndDate} onChange={(e) => setForm({ ...form, actualEndDate: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>提醒日期</Label>
|
||||
<Input type="date" value={form.reminderDate} onChange={(e) => setForm({ ...form, reminderDate: e.target.value })} />
|
||||
<p className="text-xs text-gray-400 mt-1">系统将在此日期前7天和30天分别发出提醒</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注</Label>
|
||||
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="补充说明" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2 pt-3">
|
||||
<Button className="flex-1" onClick={handleSave}>保存</Button>
|
||||
<Button variant="secondary" onClick={() => setEditOpen(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={() => setDeleteTarget(null)}>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">确认删除</h3>
|
||||
<p className="text-sm text-gray-500">删除后无法恢复</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
确定要删除 <b>{deleteTarget.employee.name}</b> 的 <b>{TYPE_LABELS[deleteTarget.type]}</b> 记录吗?
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1 bg-red-500 hover:bg-red-600" onClick={handleDelete}>删除</Button>
|
||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 信息行组件 */
|
||||
function Row({ label, value, valueClass }: { label: string; value: string; valueClass?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500 text-xs">{label}</span>
|
||||
<span className={`text-gray-900 text-xs font-medium ${valueClass || ''}`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user