feat: 员工特殊状态台账(三期/工伤/医疗期)完整实现

This commit is contained in:
selfrelease
2026-07-30 15:04:34 +08:00
parent 5a5ce7186b
commit 826c8fda65
11 changed files with 1275 additions and 6 deletions
@@ -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;
+48
View File
@@ -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])
}
+2
View File
@@ -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)
+201
View File
@@ -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
+77 -2
View File
@@ -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(
+3 -3
View File
@@ -14,9 +14,9 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"ignoreDeprecations": "6.0"
},
"include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"],
"ignoreDeprecations": "6.0"
"exclude": ["node_modules", "dist"]
}
+2
View File
@@ -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 },
],
},
{
+610
View File
@@ -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">730</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>
)
}