Compare commits

..

24 Commits

Author SHA1 Message Date
freedakgmail 8cfdd566af docs: 更新使用帮助近期更新,添加8月11日16项优化说明 2026-08-11 22:02:20 +08:00
freedakgmail 86e5526a83 fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页
- 问题2: 离职证明模板支持自定义+员工端下载
- 问题4(P0): 修复工资填写后数据归零问题
- 问题5: 社保添加员工参保信息列表
- 问题6(P0): 商业保险支持为员工参保
- 问题7(P0): 员工福利支持为员工添加福利
- 问题8: 规章制度支持导入Word文档
- 问题9: 文本模板下载Word增加HTML格式
- 问题10: 模板下载变量替换修复(排除token参数)
- 问题11(P0): 电子签署发起时员工下拉框有选项
- 问题12: 新增绩效记录添加考评人选项
- 问题13: 违纪记录添加处罚执行细节
- 问题14: 特殊员工列表添加查看详情按钮和姓名链接
- 问题15: 员工福利汇总正确显示参保人员
- 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
2026-08-11 22:02:19 +08:00
selfrelease b682178549 fix: 用工办理必填项校验(前后端)+ 20260811优化验证文档 2026-08-11 17:15:51 +08:00
freedakgmail de7f1830a7 docs: 更新使用帮助近期更新为28项优化说明 2026-08-09 12:04:30 +08:00
freedakgmail a2e9ba55c2 feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
2026-08-09 11:59:02 +08:00
freedakgmail c355a7d208 feat: 20260805 系统优化 - 身份证复制fallback/薪税日期筛选/社保版本修复/证据链导出/违纪证明/医疗期政策/绩效类型评级/合同作废/帮助更新 2026-08-05 20:26:16 +08:00
freedakgmail a5901d648e fix: 近期更新补充全部已完成项(离职校验/补偿金调整/退休提醒/休假审批入口/员工端辞职申请) 2026-08-05 08:59:16 +08:00
freedakgmail 9a960d8274 fix: 近期更新补充昨天完成的6项改动(身份证搜索/企业名称/表单中文/工作台Tab/电子签场景/通知设置) 2026-08-05 08:51:58 +08:00
freedakgmail 05b171b4e9 feat: 使用帮助新增'近期更新'分类,列出培训/绩效/违纪列表页、员工端签收、电子签扩展等更新说明 2026-08-05 08:48:27 +08:00
freedakgmail ddc51b33fd feat: 电子签署设置新增培训/绩效/违纪3个开关,改为两列卡片布局 2026-08-05 08:44:11 +08:00
freedakgmail 3afb383172 feat: 培训/绩效/违纪员工端签收+管理端签字状态只读化
- 后端: portal新增培训/绩效/违纪列表查看+签收接口,签收时创建证据链
- 管理端: 弹窗去掉HR手动签字勾选,改为只读显示签字状态
- 绩效列表新增签字状态列
- 员工端: 新增MyRecords页面,支持培训签收/拒绝、绩效签字、违纪签字
- 员工端导航新增'我的记录'入口
- EvidenceCategory新增TRAINING/PERFORMANCE类型
2026-08-05 08:39:04 +08:00
freedakgmail 15dde27701 fix: 培训/绩效/违纪列表页CRUD改用api实例(修复401鉴权失败) 2026-08-05 08:28:51 +08:00
freedakgmail b239465e78 feat: 培训记录/绩效考核/违纪记录独立列表页+菜单入口
- 后端: 新增3个组织级列表接口 (training/performance/disciplinary /list)
- 前端: 新增3个列表页面,支持搜索、分页、新增/编辑/删除弹窗
- 侧边栏: 团队分组新增培训记录、绩效考核、违纪记录入口
- 路由+面包屑注册
- 社公商保改名为社保公积金
2026-08-05 08:21:53 +08:00
freedakgmail 539e384ee1 fix: 发薪日缺省5号+通知设置去掉重复发薪日字段 2026-08-05 08:08:18 +08:00
freedakgmail 6ec939dc7f feat: 发薪日期多选+提前N天提醒+电子签设置区域修复
- Schema: 去掉 payrollFrequency,新增 payrollDays (JSON数组) + payrollReminderDays (Int)
- 设置页: 发薪日期改为1-28号多选按钮,新增提前提醒天数设置
- 设置页: 恢复电子签署设置区域(3个开关:规章制度/工资条/入职文件)
- 工作日历: 发薪日期作为 PAYROLL_DAY 事件显示
- 工作台: 提前N天提醒发薪日期,N可配置
- TaskCenter: 新增发薪提醒分类图标
- seed文件: 更新为 payrollDays 格式
2026-08-05 08:04:39 +08:00
freedakgmail 41b3030442 refactor: 设置页面调整-退休提醒独立tab+显示设置并入企业信息
- 退休提醒从企业信息中拆分为独立tab(RetirementSettings 独立 Card 组件)
- 显示设置从独立tab移入企业信息卡片底部(移除 DisplaySettings 组件)
- tab顺序:企业信息、用户管理、套餐、通知设置、退休提醒、数据导入、数据导出
2026-08-05 07:53:53 +08:00
freedakgmail 9512b555ee feat: 电子签全场景集成+场景筛选+设置开关
- ESignRecord 模型新增 scene 字段(CONTRACT/RESIGNATION/POLICY/PAYSLIP/ONBOARDING)
- Organization 模型新增3个电子签开关:esignPolicyEnabled/esignPayslipEnabled/esignOnboardingEnabled
- 设置页面企业信息新增电子签署设置区域,3个开关各自独立,缺省关闭
- 规章制度签收:开启电子签后,员工阅读确认时自动创建POLICY场景签署记录
- 工资条确认:开启电子签后,员工确认工资条时自动创建PAYSLIP场景签署记录
- 入职文件签署:开启电子签后,HR审批通过入职流程时自动创建ONBOARDING场景签署记录
- 电子签署列表增加场景筛选下拉(全部场景/劳动合同/离职协议/规章制度/工资条/入职文件)
- 管理端和员工端列表均展示场景标签(基于scene字段,替代硬编码判断)
- 合同和离职流程的esign调用已加scene参数
2026-08-05 07:47:00 +08:00
freedakgmail e8cd0f472b feat: 电子签署导航调整+员工手机端电子签署功能
- 管理端导航:电子签署移到首页分组(工作日历下)
- 员工端新增电子签署页面(MyEsign.tsx):
  - 查看自己的签署记录列表,支持待签/已签/已取消状态
  - 查看签署详情,含文件内容、关联合同标识
  - 确认签署操作(框架阶段,对接易签宝后跳转签署页面)
- 员工端导航(PortalNav)增加电子签署入口
- 员工首页(EmployeeHome)增加电子签署快捷入口
- 员工首页待办事项增加待签署文件提醒
- 后端portal路由新增3个接口:GET /esign、GET /esign/:id、POST /esign/:id/sign
2026-08-05 07:39:11 +08:00
freedakgmail b9240ffe9b feat: 电子签署集成到合同管理和离职流程
- 合同管理:选择电子签署时保存后自动创建ESignRecord并关联合同ID
- 离职管理:工作交接清单「离职文件签署」步骤增加「发起电子签署」按钮
- 电子签署页面:列表增加来源标识(劳动合同/离职协议)
- 合同卡片:电子签署合同显示提示而非手动填写编号/链接
2026-08-05 07:34:01 +08:00
freedakgmail cce0c936bd refactor: 侧边栏「特殊状态」改名为「特殊员工」 2026-08-05 07:29:39 +08:00
freedakgmail b576a48ff1 refactor: 调整侧边栏分组顺序,电子签署移至团队分组
- 电子签署从「福利保障」移到「团队」分组(与合同管理、用工办理同属员工生命周期)
- 「时间」分组移到「薪酬」之前
- 导航顺序:首页 → 团队 → 时间 → 薪酬 → 福利保障 → 合规
2026-08-05 07:27:41 +08:00
freedakgmail 13c9192b0a feat: 商业保险新增员工汇总Tab
- 后端新增 /commercial-insurance/employee-summary 接口,按员工维度汇总商险
- 前端商业保险页面新增Tab切换:方案管理 + 员工汇总
- 员工汇总展示每人参保项、年保费合计、保额合计,底部带总计行
2026-08-05 07:25:14 +08:00
freedakgmail c328172e7d feat: 商业保险/员工福利独立页面+易签宝电子签署框架
- 商业保险:从社公商保中拆出为独立页面,侧边栏新增「福利保障」分组
- 员工福利:新建完整模块(方案管理+批量参保+员工汇总),Prisma模型+后端路由+前端页面
- 电子签署:搭建易签宝对接框架(ESignRecord模型+创建/查询/取消/回调接口+前端签署管理页面)
- 侧边栏新增「福利保障」分组:商业保险、员工福利、电子签署
- Prisma schema 新增6个模型:CommercialInsurancePlan/Enrollment, EmployeeBenefitPlan/Enrollment, ESignRecord
2026-08-04 23:08:49 +08:00
freedakgmail 5604d02de9 feat: 完成23项系统优化 - 花名册社保状态列/身份证复制/附件类型扩展, 用工办理姓名检索/直接提交/文书查看/批量证明, 风险中心跳转筛选+批量处理, 日历7/15/35天分组+逾期统计, 考勤单条编辑+按人导出, 工资条查看状态+工资流水导出, 辞职申请附件上传, 交接清单PDF下载, 操作完成下一步引导, 休假审批入口, 人效成本分部门 2026-08-04 22:55:03 +08:00
99 changed files with 10765 additions and 992 deletions
+165 -1
View File
@@ -130,8 +130,15 @@ model Organization {
city String?
contactName String?
contactPhone String?
payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批)
payrollDays Json @default("[5]") // 每月发薪日期,如 [5, 20] 表示每月5号和20号
payrollReminderDays Int @default(3) // 发薪提前提醒天数
retirementReminderEnabled Boolean @default(false) // 退休提醒开关
esignPolicyEnabled Boolean @default(false) // 规章制度电子签
esignPayslipEnabled Boolean @default(false) // 工资条电子签
esignOnboardingEnabled Boolean @default(false) // 入职文件电子签
esignTrainingEnabled Boolean @default(false) // 培训记录电子签
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -163,6 +170,7 @@ model Organization {
attendanceRecords AttendanceRecord[]
trainingRecords TrainingRecord[]
performanceRecords PerformanceRecord[]
performanceTemplates PerformanceTemplate[]
retirementPolicies RetirementPolicy[]
socialMonthlyProcesses SocialMonthlyProcess[]
evidenceChains EvidenceChain[]
@@ -183,6 +191,12 @@ model Organization {
attendancePublishes AttendancePublish[]
specialStatuses EmployeeSpecialStatus[]
companyFiles CompanyFile[]
commercialInsPlans CommercialInsurancePlan[]
commercialInsEnrollments CommercialInsuranceEnrollment[]
benefitPlans EmployeeBenefitPlan[]
benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[]
medicalPeriodPolicies MedicalPeriodPolicy[]
}
model User {
@@ -268,6 +282,9 @@ model Employee {
calendarEvents CalendarEvent[]
workProcesses WorkProcess[]
specialStatuses EmployeeSpecialStatus[]
commercialInsEnrollments CommercialInsuranceEnrollment[]
benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[]
@@unique([orgId, idCardHash])
}
@@ -485,6 +502,21 @@ model OvertimeConfig {
updatedAt DateTime @updatedAt
}
model MedicalPeriodPolicy {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
region String // 地区名称,如"全国"、"上海"、"广东"
legalBasis String // 法律依据
rules Json // 分档规则: [{ maxYears: 5, months: 3, cycleMonths: 6 }, ...]
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, region])
@@index([orgId])
}
model NotificationLog {
id String @id @default(cuid())
orgId String
@@ -613,11 +645,14 @@ model PerformanceRecord {
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
period String // 考核周期 YYYY-MM 或 YYYY-Q1
periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY
score Float @default(0) // 考核得分
grade String @default("B") // A/B/C/D
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
summary String? // 考核评语
improvementPlan String? // 改进计划(不胜任时)
templateId String? // 关联绩效模板(选填)
dimensionScores Json? // 各维度得分明细 { dimensionName: score }
employeeAck Boolean @default(false)
ackDate DateTime?
reviewer String?
@@ -628,6 +663,22 @@ model PerformanceRecord {
@@index([orgId, employeeId])
}
model PerformanceTemplate {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String // 模板名称
description String? // 模板说明
dimensions Json // 考核维度 [{ name, weight, maxScore, description }]
gradeRules Json? // 等级规则 [{ grade, result, minScore, maxScore }]
isDefault Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId])
}
// ========== 员工端表 ==========
model Payslip {
@@ -664,6 +715,7 @@ model Payslip {
publishedAt DateTime? // 工资条发布到员工端的时间
publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED
scheduledAt DateTime? // 定时发送时间
viewedAt DateTime? // 员工查看工资条的时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1364,3 +1416,115 @@ model AcceptanceTest {
@@unique([orgId, verifierName])
@@index([orgId, status])
}
// ========== 商业保险 ==========
model CommercialInsurancePlan {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
type String // ACCIDENT | SUPPLEMENTARY_MEDICAL | EMPLOYER_LIABILITY | CRITICAL_ILLNESS | GROUP_LIFE | OTHER
provider String // 保险公司
policyNo String?
premium Float // 年保费
coverageAmount Float // 保额
effectiveFrom String // YYYY-MM-DD
effectiveTo String? // null = 长期
description String?
status String @default("ACTIVE") // ACTIVE | EXPIRED | CANCELLED
enrollments CommercialInsuranceEnrollment[]
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, type])
}
model CommercialInsuranceEnrollment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
planId String
plan CommercialInsurancePlan @relation(fields: [planId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
premium Float // 个人保费
effectiveFrom String // YYYY-MM-DD
effectiveTo String?
status String @default("ACTIVE") // ACTIVE | TERMINATED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, planId])
@@index([employeeId])
}
// ========== 员工福利 ==========
model EmployeeBenefitPlan {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
category String // TRANSPORT | MEAL | HOUSING | COMMUNICATION | HEALTH_CHECK | HOLIDAY | BIRTHDAY | OTHER
amount Float // 每月金额(或每次金额)
frequency String @default("MONTHLY") // MONTHLY | QUARTERLY | YEARLY | ONE_TIME
taxDeductible Boolean @default(false) // 是否税前扣除
description String?
status String @default("ACTIVE")
enrollments EmployeeBenefitEnrollment[]
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, category])
}
model EmployeeBenefitEnrollment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
planId String
plan EmployeeBenefitPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
effectiveFrom String // YYYY-MM
effectiveTo String? // null = 至今
amount Float? // 覆盖默认金额(个别调整)
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, planId])
@@index([employeeId])
}
// ========== 电子签署(易签宝) ==========
model ESignRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
contractId String? // 关联 LaborContract
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING
flowId String? // 易签宝流程ID
documentTitle String // 文件标题
documentContent String? // 文件内容(HTML/PDF base64
status String @default("PENDING") // PENDING | SIGNING | COMPLETED | REJECTED | EXPIRED | CANCELLED
signUrl String? // 签署链接
signedPdfUrl String? // 签署完成后的PDF链接
initiatedBy String // 发起人(HR用户ID
completedAt DateTime?
expiredAt DateTime?
callbackData Json? // 易签宝回调数据
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([employeeId])
@@index([contractId])
}
+111 -5
View File
@@ -49,7 +49,7 @@ interface OrgConfig {
adminName: string
contactName: string
contactPhone: string
payrollFrequency: number
payrollDays: number[]
retirementReminderEnabled: boolean
socialConfig: {
city: string
@@ -89,7 +89,7 @@ const ORGS: OrgConfig[] = [
adminName: '王建国',
contactName: '王建国',
contactPhone: '13800000010',
payrollFrequency: 1,
payrollDays: [10],
retirementReminderEnabled: true,
socialConfig: {
city: '北京',
@@ -122,7 +122,7 @@ const ORGS: OrgConfig[] = [
adminName: '李明华',
contactName: '李明华',
contactPhone: '13800000020',
payrollFrequency: 2,
payrollDays: [5, 20],
retirementReminderEnabled: true,
socialConfig: {
city: '深圳',
@@ -157,7 +157,7 @@ const ORGS: OrgConfig[] = [
adminName: '赵雪梅',
contactName: '赵雪梅',
contactPhone: '13800000030',
payrollFrequency: 1,
payrollDays: [10],
retirementReminderEnabled: false,
socialConfig: {
city: '杭州',
@@ -213,7 +213,7 @@ async function createOrg(orgConfig: OrgConfig) {
city: orgConfig.city,
contactName: orgConfig.contactName,
contactPhone: orgConfig.contactPhone,
payrollFrequency: orgConfig.payrollFrequency,
payrollDays: orgConfig.payrollDays,
retirementReminderEnabled: orgConfig.retirementReminderEnabled,
},
})
@@ -399,6 +399,112 @@ async function createOrg(orgConfig: OrgConfig) {
}
}
console.log(` ✅ 历史工资条已生成`)
// 10. 生成培训记录
console.log(` 📝 生成培训记录...`)
const trainingTopics = [
{ topic: '新员工入职培训', content: '公司文化、规章制度、安全规范', trainer: '张经理', duration: 4 },
{ topic: '岗位技能培训', content: '岗位操作规范与流程', trainer: '李主管', duration: 6 },
{ topic: '安全生产培训', content: '安全生产法规与操作规程', trainer: '王安全', duration: 3 },
{ topic: '团队协作培训', content: '沟通技巧与团队建设', trainer: '刘讲师', duration: 2 },
]
for (let i = 0; i < allEmployees.length; i++) {
const emp = allEmployees[i]
const t = trainingTopics[i % trainingTopics.length]
const trainDate = new Date(2026, (i % 6), 15)
const ackStatus = i % 3 === 0 ? 'PENDING' : i % 3 === 1 ? 'SIGNED' : 'REFUSED'
await prisma.trainingRecord.create({
data: {
orgId: org.id,
employeeId: emp.id,
trainingDate: trainDate,
topic: t.topic,
content: t.content,
trainer: t.trainer,
duration: t.duration,
ackStatus: ackStatus as any,
ackDate: ackStatus === 'SIGNED' ? new Date(trainDate.getTime() + 86400000) : null,
createdBy: admin.id,
},
})
}
console.log(` ✅ 培训记录已生成 (${allEmployees.length}条)`)
// 11. 生成绩效记录
console.log(` 📊 生成绩效记录...`)
const perfResults = ['EXCELLENT', 'QUALIFIED', 'QUALIFIED', 'NEED_IMPROVE', 'UNQUALIFIED'] as const
const perfGrades = ['A', 'B', 'B', 'C', 'D']
for (let i = 0; i < allEmployees.length; i++) {
const emp = allEmployees[i]
const idx = i % perfResults.length
const score = 95 - idx * 12
await prisma.performanceRecord.create({
data: {
orgId: org.id,
employeeId: emp.id,
period: '2026-Q1',
score,
grade: perfGrades[idx],
result: perfResults[idx] as any,
summary: idx < 2 ? '工作表现优秀,完成任务质量高' : idx < 4 ? '基本完成工作目标,有待提升' : '未达到岗位要求,需制定改进计划',
improvementPlan: idx >= 3 ? '加强技能培训,设定阶段性目标' : null,
reviewer: admin.name,
employeeAck: i % 2 === 0,
createdBy: admin.id,
},
})
// 部分员工有Q2绩效
if (i % 2 === 0) {
await prisma.performanceRecord.create({
data: {
orgId: org.id,
employeeId: emp.id,
period: '2026-Q2',
score: score - 5,
grade: perfGrades[Math.min(idx + 1, 4)],
result: perfResults[Math.min(idx + 1, 4)] as any,
summary: '二季度绩效评估',
reviewer: admin.name,
employeeAck: false,
createdBy: admin.id,
},
})
}
}
console.log(` ✅ 绩效记录已生成 (${allEmployees.length}条)`)
// 12. 生成违纪记录(部分员工)
console.log(` ⚠️ 生成违纪记录...`)
const discTypes = ['LATE', 'ABSENT', 'INSUBORDINATION', 'MISCONDUCT'] as const
const discDescriptions = [
'月内累计迟到3次,超过公司允许范围',
'未经请假擅自缺勤1天',
'不服从主管工作安排,拒绝执行合理指令',
'违反公司安全操作规程,未佩戴防护设备',
]
const discActions = ['ORAL_WARNING', 'WRITTEN_WARNING', 'DEDUCTION', 'WRITTEN_WARNING'] as const
for (let i = 0; i < Math.min(allEmployees.length, 4); i++) {
const emp = allEmployees[i]
const violationDate = new Date(2026, i % 6, 10)
await prisma.disciplinaryRecord.create({
data: {
orgId: org.id,
employeeId: emp.id,
violationDate,
violationType: discTypes[i],
description: discDescriptions[i],
severity: i < 2 ? 'WARNING' : 'SERIOUS',
action: discActions[i],
actionDetail: i === 2 ? '扣除当日工资' : '',
employeeAck: i % 2 === 0,
ackDate: i % 2 === 0 ? new Date(violationDate.getTime() + 86400000) : null,
ackMethod: i % 2 === 0 ? 'SIGN' : null,
witness: i >= 2 ? '部门主管' : null,
createdBy: admin.id,
},
})
}
console.log(` ✅ 违纪记录已生成 (${Math.min(allEmployees.length, 4)}条)`)
}
// ========== 主函数 ==========
+1 -1
View File
@@ -78,7 +78,7 @@ async function main() {
plan: 'PRO',
maxEmployees: 50,
city: '上海',
payrollFrequency: 1,
payrollDays: [10],
},
})
console.log('企业已创建:', org.name)
+8 -1
View File
@@ -23,8 +23,9 @@ app.use(compression({
}))
app.use(
cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
origin: true,
credentials: true,
exposedHeaders: ['Content-Disposition'],
}),
)
app.use(express.json({ limit: '10mb' }))
@@ -66,6 +67,9 @@ import companyFileRoutes from './routes/company-file.routes'
import acceptanceTestRoutes from './routes/acceptance-test.routes'
import leaveRoutes from './routes/leave.routes'
import salaryRoutes from './routes/salary.routes'
import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
import benefitRoutes from './routes/benefit.routes'
import esignRoutes from './routes/esign.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -95,6 +99,9 @@ app.use('/api/v1/company-files', companyFileRoutes)
app.use('/api/v1/acceptance-tests', acceptanceTestRoutes)
app.use('/api/v1/leaves', leaveRoutes)
app.use('/api/v1/salary', salaryRoutes)
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
app.use('/api/v1/benefits', benefitRoutes)
app.use('/api/v1/esign', esignRoutes)
app.use(errorHandler)
+7 -2
View File
@@ -8,10 +8,15 @@ export interface AuthRequest extends Request {
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
let token: string | undefined
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7)
} else if (typeof req.query.token === 'string') {
token = req.query.token
}
if (!token) {
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' } })
}
const token = authHeader.substring(7)
const payload = verifyAccessToken(token)
if (!payload) {
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } })
+17
View File
@@ -18,6 +18,7 @@ import {
getLeaveRecords,
createLeaveRecord,
deleteLeaveRecord,
manualCorrectAttendance,
} from '../services/attendance.service'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
@@ -206,6 +207,22 @@ router.delete('/shift-assignments/:id', authMiddleware, async (req: AuthRequest,
// ========== 每日出勤 ==========
router.post('/manual-correct', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
employeeId: z.string(),
date: z.string(),
checkInTime: z.string().optional(),
checkOutTime: z.string().optional(),
status: z.string().optional(),
remark: z.string().optional(),
})
const data = schema.parse(req.body)
const record = await manualCorrectAttendance(req.user!.orgId, { ...data, createdBy: req.user!.id })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
router.get('/daily', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const date = req.query.date as string
+173
View File
@@ -0,0 +1,173 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const planSchema = z.object({
name: z.string().min(1),
category: z.string(),
amount: z.number().default(0),
frequency: z.string().default('MONTHLY'),
taxDeductible: z.boolean().default(false),
description: z.string().optional(),
})
const BENEFIT_CATEGORIES: Record<string, string> = {
TRANSPORT: '交通补贴',
MEAL: '餐补',
HOUSING: '住房补贴',
COMMUNICATION: '通讯补贴',
HEALTH_CHECK: '体检',
HOLIDAY: '节日福利',
BIRTHDAY: '生日福利',
OTHER: '其他',
}
// 福利方案列表
router.get('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const plans = await prisma.employeeBenefitPlan.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { enrollments: { where: { status: 'ACTIVE' } } } } },
})
res.json({ success: true, data: plans })
} catch (err) { next(err) }
})
// 创建福利方案
router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.parse(req.body)
const plan = await prisma.employeeBenefitPlan.create({
data: { ...data, orgId: req.user!.orgId, createdBy: req.user!.id },
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 更新福利方案
router.put('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.partial().parse(req.body)
const plan = await prisma.employeeBenefitPlan.update({
where: { id: req.params.planId, orgId: req.user!.orgId },
data,
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 删除福利方案
router.delete('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.employeeBenefitPlan.delete({
where: { id: req.params.planId, orgId: req.user!.orgId },
})
res.json({ success: true })
} catch (err) { next(err) }
})
// 方案参保人员
router.get('/plans/:planId/enrollments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.employeeBenefitEnrollment.findMany({
where: { orgId: req.user!.orgId, planId: req.params.planId },
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
})
const data = enrollments.map((e: any) => ({
id: e.id,
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
effectiveFrom: e.effectiveFrom,
effectiveTo: e.effectiveTo,
amount: e.amount,
status: e.status,
}))
res.json({ success: true, data })
} catch (err) { next(err) }
})
// 批量参保
router.post('/plans/:planId/enroll', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeIds, effectiveFrom } = req.body as { employeeIds: string[]; effectiveFrom: string }
const plan = await prisma.employeeBenefitPlan.findFirst({ where: { id: req.params.planId, orgId: req.user!.orgId } })
if (!plan) return res.status(404).json({ success: false, error: { message: '方案不存在' } })
const existing = await prisma.employeeBenefitEnrollment.findMany({
where: { planId: plan.id, employeeId: { in: employeeIds }, status: 'ACTIVE' },
select: { employeeId: true },
})
const existingIds = new Set(existing.map((e: any) => e.employeeId))
const newIds = employeeIds.filter((id) => !existingIds.has(id))
if (newIds.length > 0) {
await prisma.employeeBenefitEnrollment.createMany({
data: newIds.map((empId) => ({
orgId: req.user!.orgId,
planId: plan.id,
employeeId: empId,
effectiveFrom: effectiveFrom || new Date().toISOString().slice(0, 7),
})),
})
}
res.json({ success: true, data: { enrolled: newIds.length, skipped: existingIds.size } })
} catch (err) { next(err) }
})
// 退福利
router.post('/enrollments/:enrollmentId/terminate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { effectiveTo } = req.body as { effectiveTo: string }
const enrollment = await prisma.employeeBenefitEnrollment.update({
where: { id: req.params.enrollmentId, orgId: req.user!.orgId },
data: { status: 'TERMINATED', effectiveTo: effectiveTo || new Date().toISOString().slice(0, 7) },
})
res.json({ success: true, data: enrollment })
} catch (err) { next(err) }
})
// 员工福利汇总(按员工维度)
router.get('/employee-summary', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.employeeBenefitEnrollment.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
employee: { select: { id: true, name: true, department: true } },
plan: { select: { id: true, name: true, category: true, amount: true, frequency: true } },
},
})
const summary: Record<string, any> = {}
for (const e of enrollments) {
if (!summary[e.employeeId]) {
summary[e.employeeId] = {
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
benefits: [],
totalMonthly: 0,
}
}
const amount = e.amount ?? e.plan.amount
summary[e.employeeId].benefits.push({
planId: e.planId,
planName: e.plan.name,
category: e.plan.category,
categoryLabel: BENEFIT_CATEGORIES[e.plan.category] || e.plan.category,
amount,
})
if (e.plan.frequency === 'MONTHLY') {
summary[e.employeeId].totalMonthly += amount
}
}
res.json({ success: true, data: Object.values(summary) })
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,171 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const planSchema = z.object({
name: z.string().min(1),
type: z.string(),
provider: z.string().min(1),
policyNo: z.string().optional(),
premium: z.number().default(0),
coverageAmount: z.number().default(0),
effectiveFrom: z.string(),
effectiveTo: z.string().optional(),
description: z.string().optional(),
})
// 方案列表
router.get('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const plans = await prisma.commercialInsurancePlan.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { enrollments: { where: { status: 'ACTIVE' } } } } },
})
res.json({ success: true, data: plans })
} catch (err) { next(err) }
})
// 方案详情(含参保人员)
router.get('/plans/:planId/enrollments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.commercialInsuranceEnrollment.findMany({
where: { orgId: req.user!.orgId, planId: req.params.planId },
include: {
employee: { select: { id: true, name: true, department: true, idCardNumber: true } },
},
orderBy: { createdAt: 'desc' },
})
const data = enrollments.map((e: any) => ({
id: e.id,
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
idCardMasked: e.employee.idCardNumber ? e.employee.idCardNumber.slice(0, 3) + '****' + e.employee.idCardNumber.slice(-4) : null,
premium: e.premium,
effectiveFrom: e.effectiveFrom,
effectiveTo: e.effectiveTo,
status: e.status,
}))
res.json({ success: true, data })
} catch (err) { next(err) }
})
// 创建方案
router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.parse(req.body)
const plan = await prisma.commercialInsurancePlan.create({
data: { ...data, orgId: req.user!.orgId, createdBy: req.user!.id },
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 更新方案
router.put('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.partial().parse(req.body)
const plan = await prisma.commercialInsurancePlan.update({
where: { id: req.params.planId, orgId: req.user!.orgId },
data,
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 删除方案
router.delete('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.commercialInsurancePlan.delete({
where: { id: req.params.planId, orgId: req.user!.orgId },
})
res.json({ success: true })
} catch (err) { next(err) }
})
// 批量参保
router.post('/plans/:planId/enroll', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeIds, premium, effectiveFrom } = req.body as { employeeIds: string[]; premium: number; effectiveFrom: string }
const plan = await prisma.commercialInsurancePlan.findFirst({ where: { id: req.params.planId, orgId: req.user!.orgId } })
if (!plan) return res.status(404).json({ success: false, error: { message: '方案不存在' } })
const existing = await prisma.commercialInsuranceEnrollment.findMany({
where: { planId: plan.id, employeeId: { in: employeeIds }, status: 'ACTIVE' },
select: { employeeId: true },
})
const existingIds = new Set(existing.map((e: any) => e.employeeId))
const newIds = employeeIds.filter((id) => !existingIds.has(id))
if (newIds.length > 0) {
await prisma.commercialInsuranceEnrollment.createMany({
data: newIds.map((empId) => ({
orgId: req.user!.orgId,
planId: plan.id,
employeeId: empId,
premium: premium || plan.premium,
effectiveFrom: effectiveFrom || plan.effectiveFrom,
})),
})
}
res.json({ success: true, data: { enrolled: newIds.length, skipped: existingIds.size } })
} catch (err) { next(err) }
})
// 退保
router.post('/enrollments/:enrollmentId/terminate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { effectiveTo } = req.body as { effectiveTo: string }
const enrollment = await prisma.commercialInsuranceEnrollment.update({
where: { id: req.params.enrollmentId, orgId: req.user!.orgId },
data: { status: 'TERMINATED', effectiveTo: effectiveTo || new Date().toISOString().slice(0, 10) },
})
res.json({ success: true, data: enrollment })
} catch (err) { next(err) }
})
// 员工商险汇总(按员工维度)
router.get('/employee-summary', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.commercialInsuranceEnrollment.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
employee: { select: { id: true, name: true, department: true } },
plan: { select: { id: true, name: true, type: true, provider: true, coverageAmount: true } },
},
})
const summary: Record<string, any> = {}
for (const e of enrollments) {
if (!summary[e.employeeId]) {
summary[e.employeeId] = {
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
insurances: [],
totalPremium: 0,
totalCoverage: 0,
}
}
summary[e.employeeId].insurances.push({
planId: e.planId,
planName: e.plan.name,
type: e.plan.type,
provider: e.plan.provider,
premium: e.premium,
coverageAmount: e.plan.coverageAmount,
effectiveFrom: e.effectiveFrom,
effectiveTo: e.effectiveTo,
})
summary[e.employeeId].totalPremium += e.premium
summary[e.employeeId].totalCoverage += e.plan.coverageAmount
}
res.json({ success: true, data: Object.values(summary) })
} catch (err) { next(err) }
})
export default router
+42
View File
@@ -285,6 +285,43 @@ router.get('/workspace/next-actions', authMiddleware, async (req: AuthRequest, r
take: 10,
})
// 5. 发薪日提前提醒
const org = await prisma.organization.findUnique({
where: { id: orgId },
select: { payrollDays: true, payrollReminderDays: true },
})
const payrollDays = Array.isArray(org?.payrollDays) ? org.payrollDays as number[] : []
const reminderDays = org?.payrollReminderDays ?? 3
const payrollReminderItems: any[] = []
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() // 0-indexed
for (const day of payrollDays) {
// 本月发薪日
const thisMonthPayday = new Date(currentYear, currentMonth, day)
const diffDays = Math.floor((thisMonthPayday.getTime() - now.getTime()) / 86400000)
if (diffDays >= 0 && diffDays <= reminderDays) {
payrollReminderItems.push({
id: `payroll-${currentYear}-${currentMonth + 1}-${day}`,
title: `发薪日(每月${day}号)${diffDays === 0 ? '今天' : `${diffDays}天后`}`,
subtitle: diffDays === 0 ? '今天发薪' : `还有${diffDays}`,
link: '/money',
})
}
// 下月发薪日(如果当月已过,看下月)
if (diffDays < 0) {
const nextMonthPayday = new Date(currentYear, currentMonth + 1, day)
const nextDiffDays = Math.floor((nextMonthPayday.getTime() - now.getTime()) / 86400000)
if (nextDiffDays >= 0 && nextDiffDays <= reminderDays) {
payrollReminderItems.push({
id: `payroll-${currentYear}-${currentMonth + 2}-${day}`,
title: `发薪日(下月${day}号)${nextDiffDays === 0 ? '今天' : `${nextDiffDays}天后`}`,
subtitle: `还有${nextDiffDays}`,
link: '/money',
})
}
}
}
// 按优先级分组
const actions: Array<{ category: string; priority: 'high' | 'medium' | 'low'; items: any[] }> = [
{
@@ -333,6 +370,11 @@ router.get('/workspace/next-actions', authMiddleware, async (req: AuthRequest, r
link: '/special-status',
})),
},
{
category: '发薪提醒',
priority: 'medium',
items: payrollReminderItems,
},
]
// 过滤空分类
+46 -3
View File
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import { sha256 } from '../lib/crypto'
import {
createEmployeeSchema,
updateEmployeeSchema,
@@ -79,7 +80,7 @@ router.get('/list', authMiddleware, async (req: AuthRequest, res, next) => {
status: { in: status },
...(department && { department }),
},
select: { id: true, name: true, department: true, position: true, phone: true, status: true },
select: { id: true, name: true, department: true, position: true, phone: true, gender: true, status: true },
orderBy: { name: 'asc' },
})
res.json({ success: true, data: employees })
@@ -97,6 +98,24 @@ router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// 身份证查重
router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const idCard = req.query.idCard as string
if (!idCard || idCard.length < 18) {
return res.json({ success: true, data: { exists: false } })
}
const hash = sha256(idCard)
const employee = await prisma.employee.findFirst({
where: { orgId: req.user!.orgId, idCardHash: hash },
select: { id: true, name: true, department: true, status: true },
})
res.json({ success: true, data: { exists: !!employee, employee } })
} catch (err) {
next(err)
}
})
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createEmployeeSchema.parse(req.body)
@@ -284,9 +303,33 @@ router.delete('/contracts/:contractId', authMiddleware, async (req: AuthRequest,
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
await prisma.laborContract.delete({ where: { id: req.params.contractId } })
// 作废处理:设置结束日期为当前时间,保留记录但不物理删除
await prisma.laborContract.update({
where: { id: req.params.contractId },
data: { endDate: new Date() },
})
const emp = await prisma.employee.findFirst({ where: { id: contract.employeeId }, select: { name: true } })
await auditLog(req, 'DELETE_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate })
await auditLog(req, 'VOID_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 补充上传合同附件
router.patch('/contracts/:contractId/attachment', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const contract = await prisma.laborContract.findFirst({
where: { id: req.params.contractId, orgId: req.user!.orgId },
})
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
const { attachmentUrl } = req.body as { attachmentUrl: string }
await prisma.laborContract.update({
where: { id: req.params.contractId },
data: { attachmentUrl: attachmentUrl || null },
})
res.json({ success: true })
} catch (err) {
next(err)
@@ -146,10 +146,63 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx
let content = template.content
const variables: Record<string, string> = {}
for (const [key, value] of Object.entries(req.query)) {
if (typeof value === 'string' && key !== 'token') variables[key] = value
}
if (Object.keys(variables).length > 0) {
for (const [key, value] of Object.entries(variables)) {
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
}
}
// 将纯文本转换为HTML段落,使Word样式生效
const textToHtml = (text: string): string => {
// 如果内容已包含HTML标签,直接返回
if (/<[a-z][\s\S]*>/i.test(text)) return text
const lines = text.split(/\n/)
let html = ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
html += '<p style="text-indent:0">&nbsp;</p>'
continue
}
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
html += `<h3>${trimmed}</h3>`
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
html += `<h1>${trimmed}</h1>`
} else if (/^(甲方|乙方)(盖章|签字)/.test(trimmed) || /^日期[:]/.test(trimmed)) {
html += `<p class="sign">${trimmed}</p>`
} else {
html += `<p>${trimmed}</p>`
}
}
return html
}
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>${template.name}</title>
<!--[if gte mso 9]><xml>
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
</xml><![endif]-->
<style>
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
p { text-indent: 2em; margin: 0 0 10pt 0; }
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
th { background: #f0f0f0; font-weight: bold; text-align: center; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
</style></head>
<body>${textToHtml(content)}</body></html>`
const encoded = encodeURIComponent(template.name + '.doc')
res.setHeader('Content-Type', 'application/msword')
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
res.send(template.content)
res.send(htmlContent)
} catch (err) {
next(err)
}
+153
View File
@@ -0,0 +1,153 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const createSignSchema = z.object({
contractId: z.string().optional(),
employeeId: z.string().min(1),
documentTitle: z.string().min(1),
documentContent: z.string().optional(),
remark: z.string().optional(),
scene: z.string().optional(),
})
// 签署记录列表
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const status = req.query.status as string | undefined
const scene = req.query.scene as string | undefined
const records = await prisma.eSignRecord.findMany({
where: {
orgId: req.user!.orgId,
...(status && { status }),
...(scene && { scene }),
},
include: {
employee: { select: { id: true, name: true, department: true, phone: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 发起签署
// TODO: 对接易签宝 API — 此处为框架预留
// 1. 调用易签宝创建签署流程
// 2. 获取签署链接
// 3. 保存 flowId 和 signUrl
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createSignSchema.parse(req.body)
// 预留:调用易签宝 API 创建签署流程
// const esignResult = await esignApi.createFlow({
// title: data.documentTitle,
// signerPhone: employee.phone,
// signerName: employee.name,
// content: data.documentContent,
// })
// const flowId = esignResult.flowId
// const signUrl = esignResult.signUrl
const record = await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
contractId: data.contractId || null,
employeeId: data.employeeId,
scene: data.scene || 'CONTRACT',
documentTitle: data.documentTitle,
documentContent: data.documentContent || null,
// flowId: esignResult.flowId, // TODO: 易签宝对接后启用
// signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: data.remark || null,
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天过期
},
})
res.json({
success: true,
data: record,
message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。',
})
} catch (err) { next(err) }
})
// 查询签署状态
// TODO: 对接易签宝 API — 查询流程状态并同步
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
// 预留:调用易签宝 API 查询流程状态
// const esignStatus = await esignApi.getFlowStatus(record.flowId)
// if (esignStatus !== record.status) {
// await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } })
// }
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 易签宝回调接口(无需认证)
router.post('/callback', async (req, res: Response, next: NextFunction) => {
try {
const { flowId, status, signedPdfUrl, ...rest } = req.body
// TODO: 验证易签宝回调签名
// if (!verifyEsignCallback(req.headers, req.body)) {
// return res.status(401).json({ success: false, error: { message: '无效回调' } })
// }
if (flowId) {
const record = await prisma.eSignRecord.findFirst({ where: { flowId } })
if (record) {
await prisma.eSignRecord.update({
where: { id: record.id },
data: {
status: status || 'COMPLETED',
signedPdfUrl: signedPdfUrl || null,
completedAt: status === 'COMPLETED' ? new Date() : null,
callbackData: rest as any,
},
})
// 如果关联了合同,更新合同的签署方式和电子合同URL
if (record.contractId && status === 'COMPLETED') {
await prisma.laborContract.update({
where: { id: record.contractId },
data: {
signMethod: 'ELECTRONIC',
electronicContractUrl: signedPdfUrl || null,
},
})
}
}
}
res.json({ success: true })
} catch (err) { next(err) }
})
// 取消签署
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await prisma.eSignRecord.update({
where: { id: req.params.id, orgId: req.user!.orgId },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
export default router
+7
View File
@@ -359,9 +359,16 @@ router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Respon
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const search = req.query.search as string | undefined
const dateFrom = req.query.dateFrom as string | undefined
const dateTo = req.query.dateTo as string | undefined
const where: any = { orgId }
if (status) where.status = status
if (dateFrom || dateTo) {
where.terminationDate = {}
if (dateFrom) where.terminationDate.gte = new Date(dateFrom)
if (dateTo) where.terminationDate.lte = new Date(dateTo + 'T23:59:59')
}
if (department || search) {
where.employee = {}
if (department) where.employee.department = department
+153 -51
View File
@@ -541,12 +541,22 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
const result: any = { month, attendance: 0, overtime: 0, discipline: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '违纪记录': '追加(同员工同日可多条)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
const empByName = new Map(employees.map(e => [e.name, e]))
// 获取加班费配置,用于自动计算 totalPay
const otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
function calcOvertimePay(monthlyWage: number, wdHours: number, weHours: number, hoHours: number) {
const hourlyWage = (monthlyWage || 0) / otConfig.monthlyDays / otConfig.dailyHours
const weekdayPay = hourlyWage * otConfig.weekdayRate * wdHours
const weekendPay = hourlyWage * otConfig.weekendRate * weHours
const holidayPay = hourlyWage * otConfig.holidayRate * hoHours
return Math.round((weekdayPay + weekendPay + holidayPay) * 100) / 100
}
function findEmp(r: any) {
const idCard = val(getField(r, '身份证号'))
if (idCard) {
@@ -556,56 +566,109 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
return empByName.get(val(getField(r, '姓名')))
}
// 考勤记录
// 考勤记录 + 加班记录(支持合并Sheet"考勤与加班"或独立Sheet
const mergedSheet = wb.Sheets['考勤与加班']
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
update: { status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
})
result.attendance++
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 加班记录
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
if (mergedSheet) {
// 合并Sheet:每行同时处理考勤和加班
const rows = XLSX.utils.sheet_to_json(mergedSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
if (!emp) { result.errors.push(`${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`加班${i + 2}行:日期格式错误`); continue }
const otMonth = dateToMonth(date)
const hours = num(getField(r, '加班时长'))
const otType = val(getField(r, '加班类型')) || '工作日加班'
const wdHours = num(getField(r, '工作日加班时')) || (otType.includes('工作日') ? hours : 0)
const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0)
const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
if (!date) { result.errors.push(`${i + 2}行:日期格式错误`); continue }
// 考勤部分
const attStatus = val(getField(r, '考勤状态'))
if (attStatus || val(getField(r, '班时')) || val(getField(r, '下班时间'))) {
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
update: { status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
})
result.attendance++
}
// 加班部分
const wdHours = num(getField(r, '工作日加班时长'))
const weHours = num(getField(r, '休息日加班时长'))
const hoHours = num(getField(r, '法定节假日加班时长'))
if (wdHours > 0 || weHours > 0 || hoHours > 0) {
const otMonth = dateToMonth(date)
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
totalPay: { increment: totalPay },
},
})
result.overtime++
}
} catch (e: any) { result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`) }
}
} else {
// 向后兼容:独立Sheet
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
update: { status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
})
result.attendance++
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
const otMonth = dateToMonth(date)
const hours = num(getField(r, '加班时长'))
const otType = val(getField(r, '加班类型')) || '工作日加班'
const wdHours = num(getField(r, '工作日加班时长')) || (otType.includes('工作日') ? hours : 0)
const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0)
const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0)
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
totalPay: { increment: totalPay },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
}
@@ -685,6 +748,27 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
}
}
// 违纪记录
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`违纪第${i + 2}行:日期格式错误`); continue }
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
await prisma.disciplinaryRecord.create({
data: { orgId, employeeId: emp.id, violationDate: date, violationType: typeMap[val(getField(r, '违纪类型'))] || 'OTHER', description: val(getField(r, '描述')) || '', action: actMap[val(getField(r, '处罚'))] || 'ORAL_WARNING', createdBy: userId },
})
result.discipline++
} catch (e: any) { result.errors.push(`违纪第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
@@ -694,11 +778,25 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
// 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号
const attOtData = [{
'姓名': '张三',
'身份证号': '110101199001011234',
'日期': '2024-06-01',
'考勤状态': '正常',
'上班时间': '09:00',
'下班时间': '18:00',
'工作日加班时长': 0,
'休息日加班时长': 0,
'法定节假日加班时长': 0,
'备注': '',
}]
const attOtWs = XLSX.utils.json_to_sheet(attOtData)
attOtWs['!cols'] = [
{ wch: 10 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 8 }, { wch: 8 },
{ wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 12 },
]
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
@@ -709,9 +807,12 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
const discData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition('月度增减员导入模板.xlsx'))
res.setHeader('Content-Disposition', contentDisposition('考勤月度导入模板.xlsx'))
res.send(buf)
})
@@ -762,7 +863,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
// 重新计算税费
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...calcResult } })
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } })
result.updated++
} catch (e: any) {
result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`)
+82
View File
@@ -129,6 +129,88 @@ router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFu
}
})
// 从考勤记录同步加班工时
router.post('/overtime/sync-from-attendance', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { month } = req.body as { month: string }
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, message: '请提供有效的月份(YYYY-MM' })
}
const monthStart = new Date(month + '-01')
const monthEnd = new Date(monthStart)
monthEnd.setMonth(monthEnd.getMonth() + 1)
// 获取该月所有考勤记录(含加班工时)
const records = await prisma.attendanceRecord.findMany({
where: { orgId, date: { gte: monthStart, lt: monthEnd }, overtimeHours: { gt: 0 } },
})
if (records.length === 0) {
return res.json({ success: false, message: '该月考勤记录中无加班工时' })
}
// 按员工汇总加班工时,按日期类型分类
const empMap = new Map<string, { weekday: number; weekend: number; holiday: number }>()
for (const r of records) {
const day = new Date(r.date)
const dayOfWeek = day.getDay() // 0=周日, 6=周六
let type: 'weekday' | 'weekend' | 'holiday' = 'weekday'
if (dayOfWeek === 0 || dayOfWeek === 6) {
type = 'weekend'
}
// 简单判断法定节假日:这里使用周末判断,实际法定节假日需要额外配置
// 如果有 holidayHours 字段在 attendanceRecord 中,优先使用
if (!empMap.has(r.employeeId)) {
empMap.set(r.employeeId, { weekday: 0, weekend: 0, holiday: 0 })
}
const entry = empMap.get(r.employeeId)!
entry[type] += r.overtimeHours || 0
}
// 获取员工月工资用于计算加班费
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
let synced = 0
for (const [employeeId, hours] of empMap) {
const emp = await prisma.employee.findFirst({ where: { id: employeeId }, select: { monthlySalary: true } })
let monthlyWage = 0
try { monthlyWage = emp?.monthlySalary ? Number(decrypt(emp.monthlySalary)) : 0 } catch { monthlyWage = Number(emp?.monthlySalary) || 0 }
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
const weekdayPay = hourlyWage * config.weekdayRate * hours.weekday
const weekendPay = hourlyWage * config.weekendRate * hours.weekend
const holidayPay = hourlyWage * config.holidayRate * hours.holiday
const totalPay = weekdayPay + weekendPay + holidayPay
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId, month } },
update: {
weekdayHours: hours.weekday,
weekendHours: hours.weekend,
holidayHours: hours.holiday,
weekdayPay, weekendPay, holidayPay, totalPay,
},
create: {
orgId, employeeId, month,
weekdayHours: hours.weekday,
weekendHours: hours.weekend,
holidayHours: hours.holiday,
weekdayPay, weekendPay, holidayPay, totalPay,
},
})
synced++
}
res.json({ success: true, data: { synced, totalEmployees: empMap.size } })
} catch (err) {
next(err)
}
})
// ========== 工资条管理 ==========
const payslipSchema = z.object({
+10 -5
View File
@@ -150,7 +150,7 @@ router.get('/batches/archived/list', async (req: AuthRequest, res: Response, nex
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, monthFrom, monthTo, status, type } = req.query
const { month, monthFrom, monthTo, status, type, dateFrom, dateTo } = req.query
const batches = await prisma.payrollBatch.findMany({
where: {
orgId: req.user!.orgId,
@@ -159,8 +159,10 @@ router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunctio
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
...(status ? { status: String(status) as any } : {}),
...(type ? { type: String(type) as any } : {}),
...(dateFrom ? { createdAt: { gte: new Date(String(dateFrom)) } } : {}),
...(dateTo ? { createdAt: { lte: new Date(String(dateTo) + 'T23:59:59') } } : {}),
},
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
orderBy: [{ createdAt: 'desc' }, { month: 'desc' }, { batchNo: 'asc' }],
})
res.json({ success: true, data: batches })
} catch (err) {
@@ -474,9 +476,10 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
// 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...inputs, ...calcResult },
data: { ...inputs, ...entryData },
})
// 更新批次汇总
@@ -589,11 +592,12 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
const entry = await prisma.batchEntry.create({
data: {
batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...calcResult, riskWarnings,
...entryData, riskWarnings,
},
})
results.push(entry)
@@ -731,9 +735,10 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...calcResult },
data: { ...entryData },
})
} catch (e: any) {
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
+1 -1
View File
@@ -115,7 +115,7 @@ router.get('/orgs', async (req: AuthRequest, res, next) => {
select: {
id: true, name: true, plan: true, maxEmployees: true,
city: true, contactName: true, contactPhone: true,
payrollFrequency: true, retirementReminderEnabled: true,
payrollDays: true, retirementReminderEnabled: true,
createdAt: true, updatedAt: true,
_count: {
select: { employees: true, users: true, contracts: true, payslips: true },
+52 -5
View File
@@ -1,5 +1,6 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac'
import { z } from 'zod'
import prisma from '../lib/prisma'
import {
@@ -90,24 +91,26 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, ne
router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true } })
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } })
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
}
const [totalEmployees, readRecords] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
const [allEmployees, readRecords] = await Promise.all([
prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, select: { id: true, name: true, department: true }, orderBy: { name: 'asc' } }),
prisma.policyReadRecord.findMany({
where: { policyId: req.params.id, orgId },
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { readAt: 'desc' },
}),
])
const readEmpIds = new Set(readRecords.map(r => r.employeeId))
const unreadEmployees = allEmployees.filter(e => !readEmpIds.has(e.id))
res.json({
success: true,
data: {
total: totalEmployees,
total: allEmployees.length,
readCount: readRecords.length,
unreadCount: totalEmployees - readRecords.length,
unreadCount: unreadEmployees.length,
records: readRecords.map(r => ({
employeeId: r.employeeId,
employeeName: r.employee.name,
@@ -115,6 +118,11 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp
readAt: r.readAt.toISOString(),
ip: r.ip,
})),
unreadEmployees: unreadEmployees.map(e => ({
employeeId: e.id,
employeeName: e.name,
department: e.department,
})),
},
})
} catch (err) {
@@ -122,4 +130,43 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp
}
})
/** 催办未签收员工 */
router.post('/:id/remind', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } })
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
}
const { employeeIds } = req.body as { employeeIds?: string[] }
const readRecords = await prisma.policyReadRecord.findMany({ where: { policyId: req.params.id, orgId }, select: { employeeId: true } })
const readEmpIds = new Set(readRecords.map(r => r.employeeId))
const targetEmployees = await prisma.employee.findMany({
where: {
orgId, status: 'ACTIVE',
id: employeeIds && employeeIds.length > 0 ? { in: employeeIds } : undefined,
},
select: { id: true, name: true },
})
const unreadEmployees = targetEmployees.filter(e => !readEmpIds.has(e.id))
// 创建催办通知
for (const emp of unreadEmployees) {
await prisma.notificationLog.create({
data: {
orgId,
employeeId: emp.id,
type: 'POLICY_REMIND',
title: `制度签收提醒:${policy.title}`,
content: `您有一项制度「${policy.title}」尚未签收,请尽快完成阅读确认。`,
channel: 'IN_APP',
status: 'SENT',
},
}).catch(() => {})
}
res.json({ success: true, data: { reminded: unreadEmployees.length } })
} catch (err) {
next(err)
}
})
export default router
+327 -4
View File
@@ -115,7 +115,11 @@ router.get('/payslip', portalAuth, async (req: any, res, next) => {
if (!payslip) {
return res.json({ success: true, data: null })
}
res.json({ success: true, data: payslip })
// 记录查看时间
if (!payslip.viewedAt) {
await prisma.payslip.update({ where: { id: payslip.id }, data: { viewedAt: new Date() } })
}
res.json({ success: true, data: { ...payslip, viewedAt: payslip.viewedAt || new Date() } })
} catch (err) {
next(err)
}
@@ -167,6 +171,25 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
events: [{ action: '工资条确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.employee.id,
}).catch(() => {})
// 如果开启了工资条电子签,创建电子签记录
const org = await prisma.organization.findUnique({ where: { id: req.employee.orgId }, select: { esignPayslipEnabled: true } })
if (org?.esignPayslipEnabled) {
await prisma.eSignRecord.create({
data: {
orgId: req.employee.orgId,
employeeId: req.employee.id,
scene: 'PAYSLIP',
documentTitle: `工资条确认:${payslip.month}`,
status: 'PENDING',
initiatedBy: req.employee.id,
createdBy: req.employee.id,
remark: '工资条确认时自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
})
}
res.json({ success: true })
} catch (err) {
next(err)
@@ -540,6 +563,27 @@ router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response
userAgent: req.headers['user-agent'] || null,
},
})
// 如果开启了制度电子签,创建电子签记录
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { esignPolicyEnabled: true } })
if (org?.esignPolicyEnabled) {
const policyDoc = await prisma.policyDocument.findUnique({ where: { id: req.params.id }, select: { title: true, content: true } })
await prisma.eSignRecord.create({
data: {
orgId,
employeeId,
scene: 'POLICY',
documentTitle: `制度签收:${policyDoc?.title || '未知'}`,
documentContent: policyDoc?.content || null,
status: 'PENDING',
initiatedBy: employeeId,
createdBy: employeeId,
remark: '制度阅读确认后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
})
}
res.json({ success: true, data: { readAt: record.readAt.toISOString() } })
} catch (err) {
next(err)
@@ -701,6 +745,14 @@ router.get('/home/overview', portalAuth, async (req: any, res, next) => {
if (unreadPolicies.length > 0) {
pendingTasks.push({ severity: 'medium', message: `您有 ${unreadPolicies.length} 份制度待阅读确认` })
}
// 待签署文件
const pendingEsign = await prisma.eSignRecord.findMany({
where: { employeeId, orgId, status: 'PENDING' },
select: { id: true, documentTitle: true },
})
if (pendingEsign.length > 0) {
pendingTasks.push({ severity: 'high', message: `您有 ${pendingEsign.length} 份文件待签署(${pendingEsign.map(e => e.documentTitle).join('、')}` })
}
res.json({
success: true,
@@ -773,7 +825,7 @@ router.get('/onboarding/progress', portalAuth, async (req: any, res, next) => {
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const { reason, expectedDate, remark } = req.body
const { reason, expectedDate, remark, attachments } = req.body
if (!reason || !expectedDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请填写离职原因和预计离职日期' } })
}
@@ -789,6 +841,7 @@ router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
if (existing) {
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '您已有一个待处理的离职申请' } })
}
const remarkText = `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}${attachments && attachments.length > 0 ? `;附件:${attachments.length}张辞职信照片` : ''}`
const record = await (prisma as any).terminationRecord.create({
data: {
employeeId, orgId,
@@ -797,8 +850,8 @@ router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
resignationReason: reason,
terminationDate: new Date(expectedDate),
status: 'PENDING_APPROVAL',
checklist: [],
remark: `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}`,
checklist: attachments && attachments.length > 0 ? attachments : [],
remark: remarkText,
createdBy: employeeId,
},
})
@@ -838,6 +891,82 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next)
} catch (err) { next(err) }
})
// 下载离职证明(仅已完成的离职记录)
router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await (prisma as any).terminationRecord.findFirst({
where: { id: req.params.id, employeeId, orgId },
include: { employee: true },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职记录不存在' } })
if (record.status !== 'COMPLETED') {
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '离职流程未完成,无法下载证明' } })
}
const org = await prisma.organization.findFirst({ where: { id: orgId } })
const orgName = org?.name || ''
// 查找企业自定义的离职证明模板
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
where: { orgId, category: 'LEAVING_CERT' },
})
const reasonLabel: Record<string, string> = {
RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退',
NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡',
}
const reason = reasonLabel[record.reason] || record.reason || ''
const variables: Record<string, string> = {
employeeName: record.employee?.name || '',
idCardNumber: record.employee?.idCardNumber || '',
department: record.employee?.department || '',
position: record.employee?.position || '',
hireDate: record.employee?.hireDate ? new Date(record.employee.hireDate).toISOString().slice(0, 10) : '',
leaveDate: record.terminationDate ? new Date(record.terminationDate).toISOString().slice(0, 10) : '',
reason,
companyName: orgName,
compensation: String(record.compensation || 0),
socialInsEndMonth: record.socialInsEndMonth || '',
housingFundEndMonth: record.housingFundEndMonth || '',
}
let content: string
if (tpl) {
content = tpl.content
for (const [key, value] of Object.entries(variables)) {
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
}
} else {
content = `<h1>解除/终止劳动合同证明书</h1>
<p>兹证明 ${variables.employeeName}(身份证号:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate}${reason} 原因,正式解除/终止劳动合同。</p>
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
<p>特此证明。</p>
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
}
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>离职证明</title>
<!--[if gte mso 9]><xml>
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
</xml><![endif]-->
<style>
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
p { text-indent: 2em; margin: 0 0 10pt 0; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
</style></head>
<body>${content}</body></html>`
const encoded = encodeURIComponent(`离职证明-${variables.employeeName}.doc`)
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
res.send(htmlContent)
} catch (err) { next(err) }
})
// ========== 员工端:休假申请 ==========
// 查看自己的休假申请列表
router.get('/leaves', portalAuth, async (req: any, res, next) => {
@@ -898,4 +1027,198 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
} catch (err) { next(err) }
})
// ========== 员工端:电子签署 ==========
// 查看自己的签署记录列表
router.get('/esign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.eSignRecord.findMany({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 查看签署详情
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 员工签署操作(预留:对接易签宝后跳转到签署页面或提交签署结果)
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
// 预留:对接易签宝后,此处应跳转到易签宝签署页面或接收签署结果
// 当前框架阶段:直接标记为已签署
const updated = await prisma.eSignRecord.update({
where: { id: record.id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
})
// 如果关联了合同,更新合同签署信息
if (record.contractId) {
await prisma.laborContract.update({
where: { id: record.contractId },
data: { signMethod: 'ELECTRONIC' },
})
}
res.json({ success: true, data: updated, message: '签署成功' })
} catch (err) { next(err) }
})
// ========== 员工端:培训签收 ==========
// 查看自己的培训记录
router.get('/training', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.trainingRecord.findMany({
where: { employeeId, orgId },
orderBy: { trainingDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 培训签收
router.post('/training/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, ackStatus: 'PENDING' },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签收' } })
const updated = await prisma.trainingRecord.update({
where: { id: record.id },
data: { ackStatus: 'SIGNED', ackDate: new Date() },
})
await createEvidence({
orgId,
category: 'TRAINING',
refId: record.id,
employeeId,
events: [{ action: `培训签收:${record.topic}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: employeeId,
}).catch(() => {})
res.json({ success: true, data: updated, message: '签收成功' })
} catch (err) { next(err) }
})
// 培训拒绝签收
router.post('/training/:id/refuse', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, ackStatus: 'PENDING' },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
const updated = await prisma.trainingRecord.update({
where: { id: record.id },
data: { ackStatus: 'REFUSED', ackDate: new Date() },
})
res.json({ success: true, data: updated, message: '已拒绝签收' })
} catch (err) { next(err) }
})
// ========== 员工端:绩效签字 ==========
// 查看自己的绩效记录
router.get('/performance', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.performanceRecord.findMany({
where: { employeeId, orgId },
orderBy: { period: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 绩效签字确认
router.post('/performance/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.performanceRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, employeeAck: false },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签字' } })
const updated = await prisma.performanceRecord.update({
where: { id: record.id },
data: { employeeAck: true, ackDate: new Date() },
})
await createEvidence({
orgId,
category: 'PERFORMANCE',
refId: record.id,
employeeId,
events: [{ action: `绩效签字确认:${record.period}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: employeeId,
}).catch(() => {})
res.json({ success: true, data: updated, message: '签字成功' })
} catch (err) { next(err) }
})
// ========== 员工端:违纪签字 ==========
// 查看自己的违纪记录
router.get('/disciplinary', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.disciplinaryRecord.findMany({
where: { employeeId, orgId },
orderBy: { violationDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 违纪签字确认
router.post('/disciplinary/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, employeeAck: false },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签字' } })
const updated = await prisma.disciplinaryRecord.update({
where: { id: record.id },
data: { employeeAck: true, ackDate: new Date(), ackMethod: 'SIGN' },
})
await createEvidence({
orgId,
category: 'DISCIPLINARY',
refId: record.id,
employeeId,
events: [{ action: `违纪签字确认:${record.violationType}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: employeeId,
}).catch(() => {})
res.json({ success: true, data: updated, message: '签字成功' })
} catch (err) { next(err) }
})
export default router
+352 -17
View File
@@ -5,6 +5,7 @@ import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import { decrypt, encrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
import { calcSocialInsurance, calcHousingFund } from '../services/payroll.service'
import ExcelJS from 'exceljs'
const router = Router()
@@ -18,6 +19,16 @@ function safeDecrypt(encrypted: string): number {
}
}
function safeDecryptStr(encrypted: string | null): string | null {
if (!encrypted) return null
try {
if (!encrypted.includes(':')) return encrypted
return decrypt(encrypted)
} catch {
return encrypted
}
}
// ========== 花名册聚合 API ==========
// 获取部门列表(去重)
@@ -95,6 +106,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
socialInsRecords: { orderBy: { startMonth: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
@@ -109,6 +121,32 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
}),
])
// 获取社保和公积金配置(按城市缓存)
const currentMonth = new Date().toISOString().slice(0, 7)
const configCache = new Map<string, { social?: any; housing?: any }>()
const getConfigsForCity = async (city?: string) => {
const key = city || '_default'
if (configCache.has(key)) return configCache.get(key)!
const cityWhere = city ? { orgId: req.user!.orgId, city } : { orgId: req.user!.orgId }
const [socialCfg, housingCfg] = await Promise.all([
prisma.socialInsuranceConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
const result = { social: socialCfg, housing: housingCfg }
configCache.set(key, result)
return result
}
// 预加载所有涉及城市的配置
const cities = [...new Set(employees.map((e) => e.city).filter(Boolean))] as string[]
await Promise.all(cities.map((c) => getConfigsForCity(c)))
// 计算动态状态和合同状态
let result = employees.map((e) => {
const latestContract = e.contracts[0] || null
@@ -148,6 +186,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
id: e.id,
name: e.name,
department: e.department,
position: e.position,
city: e.city,
status: dynamicStatus,
hasTermination: e.terminations.length > 0,
@@ -159,8 +198,22 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
gender: e.gender,
phone: e.phone,
idCardMasked,
idCardNumber: e.idCardNumber,
idCardNumber: safeDecryptStr(e.idCardNumber),
monthlySalary: safeDecrypt(e.monthlySalary),
socialInsBase: e.socialInsBase,
housingFundBase: e.housingFundBase,
socialInsCalc: (() => {
const cfgs = configCache.get(e.city || '_default')
if (!cfgs?.social || !e.socialInsBase) return null
const r = calcSocialInsurance(e.socialInsBase, cfgs.social)
return { socialEmp: r.socialEmp, socialOrg: r.socialOrg }
})(),
housingFundCalc: (() => {
const cfgs = configCache.get(e.city || '_default')
if (!cfgs?.housing || !e.housingFundBase) return null
const r = calcHousingFund(e.housingFundBase, cfgs.housing)
return { housingEmp: r.housingEmp, housingOrg: r.housingOrg }
})(),
isPregnant: e.isPregnant,
isInMedicalPeriod: e.isInMedicalPeriod,
isWorkInjured: e.isWorkInjured,
@@ -168,6 +221,13 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
socialInsuranceStatus: (() => {
const sr = (e as any).socialInsRecords?.[0]
if (!sr) return null
// endMonth 为 null 表示在保,否则已停保
if (sr.endMonth) return 'SUSPENDED'
return 'ACTIVE'
})(),
probationInfo: (() => {
if (!latestContract || latestContract.probationMonths === 0) return null
const probEnd = new Date(e.hireDate)
@@ -190,16 +250,11 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
result = result.filter((e) => e.contractStatus === contractStatus)
}
// 身份证号后N位搜索:在内存中过滤(解密完整身份证号后匹配
// 身份证号后N位搜索:在内存中过滤(idCardNumber 已解密为明文
if (isIdCardSearch) {
result = result.filter((e: any) => {
if (!e.idCardNumber) return false
try {
const fullIdCard = decrypt(e.idCardNumber)
return fullIdCard.endsWith(search!)
} catch {
return false
}
return String(e.idCardNumber).endsWith(search!)
})
}
@@ -303,7 +358,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
status: dynamicStatus,
monthlySalary: safeDecrypt(monthlySalary),
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
idCardNumber: safeDecryptStr(idCardNumber),
monthlyProcessRecords,
},
})
@@ -727,16 +782,145 @@ router.get('/:id/evidence-chain/export', authMiddleware, async (req: AuthRequest
wsRisk.getRow(1).font = { bold: true }
risks.forEach((r, i) => wsRisk.addRow({ no: i + 1, ...r }))
const encodedName = encodeURIComponent(empName)
const fullFileName = `${empName}_证据链.xlsx`
const encodedName = encodeURIComponent(fullFileName)
const asciiFallback = `evidence_chain_${employee.id.slice(-8)}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="${encodedName}_证据链.xlsx"; filename*=UTF-8''${encodedName}_证据链.xlsx`)
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
res.setHeader('Content-Disposition', `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedName}`)
const buffer = await workbook.xlsx.writeBuffer()
res.send(Buffer.from(buffer))
} catch (err: any) {
console.error('证据链导出失败:', err?.message || err)
if (!res.headersSent) {
res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } })
} else {
res.end()
}
}
})
// ========== 组织级列表查询 ==========
// 培训记录列表(全员)
router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next) => {
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 keyword = (req.query.keyword as string) || ''
const ackStatus = (req.query.ackStatus as string) || ''
const where: any = { orgId }
if (ackStatus) {
where.ackStatus = ackStatus
}
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.trainingRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { trainingDate: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.trainingRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} catch (err) { next(err) }
})
// 培训记录催办(发送通知给未签收员工)
router.post('/training/remind/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.recordId, orgId },
include: { employee: { select: { id: true, name: true, department: true, phone: true } } },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '培训记录不存在' } })
}
if (record.ackStatus !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅待签收记录可催办' } })
}
// 记录催办通知日志
await prisma.notificationLog.create({
data: {
orgId,
type: 'TRAINING_REMIND',
title: `培训签收催办:${record.topic}`,
content: `员工 ${record.employee.name}${record.employee.department})的培训记录「${record.topic}」尚未签收,请尽快完成签收。`,
channel: 'SYSTEM',
status: 'SENT',
},
})
res.json({ success: true, data: { message: `已催办 ${record.employee.name} 签收「${record.topic}` } })
} catch (err) { next(err) }
})
// 绩效记录列表(全员)
router.get('/performance/list', authMiddleware, async (req: AuthRequest, res, next) => {
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 keyword = (req.query.keyword as string) || ''
const where: any = { orgId }
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.performanceRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { period: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.performanceRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} catch (err) { next(err) }
})
// 违纪记录列表(全员)
router.get('/disciplinary/list', authMiddleware, async (req: AuthRequest, res, next) => {
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 keyword = (req.query.keyword as string) || ''
const where: any = { orgId }
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.disciplinaryRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { violationDate: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.disciplinaryRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} catch (err) { next(err) }
})
// ========== 违纪记录 CRUD ==========
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
@@ -821,6 +1005,55 @@ router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req:
} catch (err) { next(err) }
})
// 违纪确认证明导出
router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
include: { employee: true },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
}
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' }
let idCard = ''
try { if (record.employee.idCardNumber) idCard = decrypt(record.employee.idCardNumber) } catch { idCard = record.employee.idCardNumber || '' }
const content = `违纪确认证明
${record.employee.name}${idCard || '___'} ${record.violationDate.toISOString().slice(0, 10)}
${typeMap[record.violationType] || record.violationType}
${severityMap[record.severity] || record.severity}
${record.description}
${actionMap[record.action] || record.action}${record.actionDetail ? `${record.actionDetail}` : ''}
${record.employeeAck ? `该员工已于 ${record.ackDate ? new Date(record.ackDate).toISOString().slice(0, 10) : '___'} 签字确认上述违纪事实及处理结果。${record.witness ? `见证人:${record.witness}` : ''}` : '该员工尚未签字确认。'}
${org?.name || ''}
${new Date().toLocaleDateString('zh-CN')}`
const blob = Buffer.from('\ufeff' + content, 'utf8')
const certFileName = `${record.employee.name}_违纪确认证明.doc`
const encodedCertName = encodeURIComponent(certFileName)
const asciiCertFallback = `disciplinary_cert_${record.id.slice(-8)}.doc`
res.setHeader('Content-Type', 'application/msword;charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${asciiCertFallback}"; filename*=UTF-8''${encodedCertName}`)
res.send(blob)
} catch (err: any) {
console.error('违纪确认证明导出失败:', err?.message || err)
if (!res.headersSent) {
res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } })
}
}
})
// ========== 考勤记录 CRUD ==========
router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
@@ -915,6 +1148,33 @@ router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, re
} catch (err) { next(err) }
})
// 批量创建培训记录
router.post('/training/batch', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeIds, trainingDate, topic, content, trainer, duration, remark } = req.body
if (!employeeIds || !Array.isArray(employeeIds) || employeeIds.length === 0) {
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '请至少选择一名员工' } })
}
const results = await Promise.all(employeeIds.map((empId: string) =>
prisma.trainingRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: empId,
trainingDate: new Date(trainingDate),
topic,
content,
trainer,
duration: duration || 0,
ackStatus: 'PENDING',
remark,
createdBy: req.user!.id,
},
})
))
res.json({ success: true, data: { count: results.length } })
} catch (err) { next(err) }
})
router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
@@ -965,29 +1225,35 @@ router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const record = await prisma.performanceRecord.upsert({
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
create: {
orgId: req.user!.orgId,
employeeId: req.params.employeeId,
period,
periodType: periodType || 'MONTHLY',
score: score || 0,
grade: grade || 'B',
result: result || 'QUALIFIED',
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck: employeeAck || false,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
createdBy: req.user!.id,
},
update: {
periodType,
score,
grade,
result,
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
@@ -1000,7 +1266,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const record = await prisma.performanceRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
@@ -1009,11 +1275,14 @@ router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: Aut
where: { id: req.params.recordId },
data: {
period,
periodType,
score,
grade,
result,
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
@@ -1034,6 +1303,72 @@ router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req:
} catch (err) { next(err) }
})
// ========== 绩效模板 CRUD ==========
// 获取模板列表
router.get('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const templates = await prisma.performanceTemplate.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: templates })
} catch (err) { next(err) }
})
// 创建模板
router.post('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { name, description, dimensions, gradeRules, isDefault } = req.body
if (!name || !dimensions || !Array.isArray(dimensions)) {
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '模板名称和考核维度为必填' } })
}
// 如果设为默认,先取消其他默认
if (isDefault) {
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true }, data: { isDefault: false } })
}
const template = await prisma.performanceTemplate.create({
data: {
orgId: req.user!.orgId,
name,
description,
dimensions,
gradeRules: gradeRules || undefined,
isDefault: isDefault || false,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: template })
} catch (err) { next(err) }
})
// 更新模板
router.put('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { name, description, dimensions, gradeRules, isDefault } = req.body
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
if (isDefault) {
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } })
}
const updated = await prisma.performanceTemplate.update({
where: { id: req.params.id },
data: { name, description, dimensions, gradeRules: gradeRules || undefined, isDefault },
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// 删除模板
router.delete('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
await prisma.performanceTemplate.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 调薪/调部门 API ==========
function dateToMonth(date: Date): string {
+106 -4
View File
@@ -28,7 +28,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
try {
const org = await prisma.organization.findUnique({
where: { id: req.user!.orgId },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, retirementReminderEnabled: true, createdAt: true },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, createdAt: true },
})
res.json({ success: true, data: org })
} catch (err) {
@@ -39,18 +39,22 @@ router.get('/org', async (req: AuthRequest, res, next) => {
// 更新企业信息
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const { name, payrollFrequency, city, contactName, contactPhone, retirementReminderEnabled } = req.body as { name?: string; payrollFrequency?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean }
const { name, payrollDays, payrollReminderDays, city, contactName, contactPhone, retirementReminderEnabled, esignPolicyEnabled, esignPayslipEnabled, esignOnboardingEnabled } = req.body as { name?: string; payrollDays?: number[]; payrollReminderDays?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean; esignPolicyEnabled?: boolean; esignPayslipEnabled?: boolean; esignOnboardingEnabled?: boolean }
const updateData: any = {}
if (name) updateData.name = name
if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency
if (payrollDays !== undefined) updateData.payrollDays = payrollDays
if (payrollReminderDays !== undefined) updateData.payrollReminderDays = payrollReminderDays
if (city !== undefined) updateData.city = city
if (contactName !== undefined) updateData.contactName = contactName
if (contactPhone !== undefined) updateData.contactPhone = contactPhone
if (retirementReminderEnabled !== undefined) updateData.retirementReminderEnabled = retirementReminderEnabled
if (esignPolicyEnabled !== undefined) updateData.esignPolicyEnabled = esignPolicyEnabled
if (esignPayslipEnabled !== undefined) updateData.esignPayslipEnabled = esignPayslipEnabled
if (esignOnboardingEnabled !== undefined) updateData.esignOnboardingEnabled = esignOnboardingEnabled
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: updateData,
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, retirementReminderEnabled: true },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true },
})
res.json({ success: true, data: org })
} catch (err) {
@@ -241,3 +245,101 @@ router.post('/retirement-policy/:id/confirm', requireAdmin, async (req: AuthRequ
}
})
// ========== 医疗期政策配置 ==========
const DEFAULT_POLICIES = [
{
region: '全国',
legalBasis: '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)',
rules: [
{ maxYears: 5, months: 3, cycleMonths: 6 },
{ maxYears: 10, months: 6, cycleMonths: 12 },
{ maxYears: 15, months: 9, cycleMonths: 15 },
{ maxYears: 20, months: 12, cycleMonths: 18 },
{ maxYears: 999, months: 24, cycleMonths: 30 },
],
isDefault: true,
},
{
region: '上海',
legalBasis: '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》',
rules: [
{ maxYears: 1, months: 3, cycleMonths: 6 },
{ maxYears: 4, months: 3, cycleMonths: 6 },
{ maxYears: 10, months: 6, cycleMonths: 12 },
{ maxYears: 999, months: 9, cycleMonths: 18 },
],
isDefault: false,
},
]
// 获取医疗期政策列表
router.get('/medical-period/policies', async (req: AuthRequest, res, next) => {
try {
let policies = await prisma.medicalPeriodPolicy.findMany({
where: { orgId: req.user!.orgId },
orderBy: [{ isDefault: 'desc' }, { region: 'asc' }],
})
if (policies.length === 0) {
policies = await prisma.$transaction(
DEFAULT_POLICIES.map(p =>
prisma.medicalPeriodPolicy.create({
data: { orgId: req.user!.orgId, ...p },
})
)
)
}
res.json({ success: true, data: policies })
} catch (err) {
next(err)
}
})
// 新增/编辑医疗期政策
const medicalPolicySchema = z.object({
region: z.string().min(1, '地区名称不能为空'),
legalBasis: z.string().min(1, '法律依据不能为空'),
rules: z.array(z.object({
maxYears: z.number().min(0),
months: z.number().min(1),
cycleMonths: z.number().min(1),
})).min(1, '至少需要一条分档规则'),
isDefault: z.boolean().default(false),
})
router.post('/medical-period/policies', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const data = medicalPolicySchema.parse(req.body)
if (data.isDefault) {
await prisma.medicalPeriodPolicy.updateMany({
where: { orgId: req.user!.orgId },
data: { isDefault: false },
})
}
const policy = await prisma.medicalPeriodPolicy.upsert({
where: { orgId_region: { orgId: req.user!.orgId, region: data.region } },
update: { legalBasis: data.legalBasis, rules: data.rules, isDefault: data.isDefault },
create: { orgId: req.user!.orgId, ...data },
})
res.json({ success: true, data: policy })
} catch (err: any) {
if (err.issues) return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: err.issues[0]?.message } })
next(err)
}
})
// 删除医疗期政策
router.delete('/medical-period/policies/:id', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const policy = await prisma.medicalPeriodPolicy.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!policy) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '政策不存在' } })
if (policy.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除默认政策' } })
await prisma.medicalPeriodPolicy.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
+71 -4
View File
@@ -9,7 +9,7 @@ const router = Router()
router.use(authMiddleware)
const socialConfigFields = {
city: z.string().optional(),
city: z.string().min(1),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
medicalOrg: z.number().optional(),
@@ -26,7 +26,7 @@ const socialConfigFields = {
}
const housingConfigFields = {
city: z.string().optional(),
city: z.string().min(1),
accountType: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
@@ -136,9 +136,9 @@ router.post('/config/versions', async (req: AuthRequest, res: Response, next: Ne
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
}
// 将之前当前版本标记为失效
// 将之前当前版本标记为失效(按城市过滤)
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, isCurrent: true },
where: { orgId, city: data.city, isCurrent: true },
})
if (prevCurrent) {
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
@@ -1511,4 +1511,71 @@ router.post('/ai-suggest', async (req: AuthRequest, res: Response, next: NextFun
}
})
// ========== 员工参保信息列表 ==========
router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const keyword = (req.query.keyword as string) || ''
// 查询所有在职员工
const employees = await prisma.employee.findMany({
where: {
orgId,
status: 'ACTIVE',
...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}),
},
select: {
id: true,
name: true,
department: true,
position: true,
socialInsBase: true,
housingFundBase: true,
city: true,
},
orderBy: { department: 'asc' },
})
const empIds = employees.map(e => e.id)
// 查询当前有效的社保记录(endMonth 为 null
const socialRecords = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, employeeId: { in: empIds }, endMonth: null },
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
})
// 查询当前有效的公积金记录
const housingRecords = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, employeeId: { in: empIds }, endMonth: null },
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
})
const socialMap = new Map(socialRecords.map(r => [r.employeeId, r]))
const housingMap = new Map(housingRecords.map(r => [r.employeeId, r]))
const list = employees.map(emp => {
const social = socialMap.get(emp.id)
const housing = housingMap.get(emp.id)
return {
id: emp.id,
name: emp.name,
department: emp.department,
position: emp.position,
socialInsBase: social?.base ?? emp.socialInsBase ?? 0,
socialInsCity: social?.city ?? emp.city ?? '',
socialInsStart: social?.startMonth ?? '',
socialInsStatus: social ? 'INSURED' : 'UNINSURED',
housingFundBase: housing?.base ?? emp.housingFundBase ?? 0,
housingFundCity: housing?.city ?? emp.city ?? '',
housingFundStart: housing?.startMonth ?? '',
housingFundStatus: housing ? 'INSURED' : 'UNINSURED',
}
})
res.json({ success: true, data: list })
} catch (err) {
next(err)
}
})
export default router
+60 -3
View File
@@ -42,17 +42,74 @@ router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Respons
}
})
/** 下载模板(Word .doc 格式) */
/** 下载模板(Word .doc 格式,支持变量替换 */
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const template = getTemplateById(req.params.id)
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx
const variables: Record<string, string> = {}
for (const [key, value] of Object.entries(req.query)) {
if (typeof value === 'string' && key !== 'token') variables[key] = value
}
let content = template.content
if (Object.keys(variables).length > 0) {
content = renderTemplate(req.params.id, variables) || content
}
// 将纯文本转换为HTML段落,使Word样式生效
const textToHtml = (text: string): string => {
const lines = text.split(/\n/)
let html = ''
let inTable = false
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
if (inTable) { html += '</table>'; inTable = false }
html += '<p style="text-indent:0">&nbsp;</p>'
continue
}
// 标题检测:以"第X条"开头的行作为小标题
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<h3>${trimmed}</h3>`
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<h1>${trimmed}</h1>`
} else if (/^(甲方|乙方)(盖章|签字)/.test(trimmed) || /^日期[:]/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<p class="sign">${trimmed}</p>`
} else {
if (inTable) { html += '</table>'; inTable = false }
html += `<p>${trimmed}</p>`
}
}
if (inTable) html += '</table>'
return html
}
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>${template.name}</title>
<!--[if gte mso 9]><xml>
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
</xml><![endif]-->
<style>
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
p { text-indent: 2em; margin: 0 0 10pt 0; }
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
th { background: #f0f0f0; font-weight: bold; text-align: center; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
</style></head>
<body>${textToHtml(content)}</body></html>`
const encoded = encodeURIComponent(template.name + '.doc')
res.setHeader('Content-Type', 'application/msword')
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
res.send(template.content)
res.send(htmlContent)
} catch (err) {
next(err)
}
+20
View File
@@ -340,4 +340,24 @@ router.get('/draft/:id/validate-step', authMiddleware, async (req: AuthRequest,
}
})
// 删除草稿(仅允许 DRAFT 和 CANCELLED 状态)
router.delete('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
}
if (record.status !== 'DRAFT' && record.status !== 'CANCELLED') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅草稿或已撤销的记录可以删除' } })
}
await prisma.terminationRecord.delete({ where: { id: req.params.id } })
await auditLog(req, 'DELETE_DRAFT', 'TERMINATION_RECORD', req.params.id, { employeeId: record.employeeId })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+75
View File
@@ -7,6 +7,34 @@ import { createWorkProcessSchema, updateWorkProcessSchema } from '../schemas/wor
const router = Router()
// 各流程类型的必填字段映射
const REQUIRED_FIELDS: Record<string, string[]> = {
HIRE: ['name', 'department', 'hireDate', 'phone', 'idCardNumber'],
ONBOARD: ['employeeId', 'hireDate'],
CUSTOM_CONTRACT: ['employeeId', 'contractStartDate'],
INFO_SUBMIT: ['employeeId'],
CONFIRM: ['employeeId', 'confirmDate'],
CHANGE: ['contractId', 'newEndDate'],
RENEW: ['employeeId', 'newStartDate'],
SUSPEND: ['contractId', 'suspendDate'],
INCOME_CERT: ['employeeName', 'idCardNumber'],
TERMINATE: ['employeeId', 'terminateDate', 'reason'],
RESCIND: ['employeeId', 'rescindDate', 'reason'],
LEAVING_CERT: ['employeeName', 'idCardNumber', 'leaveDate'],
FLEXIBLE: ['name', 'idCardNumber', 'agreementStartDate'],
}
// 必填字段中文标签映射
const FIELD_LABELS: Record<string, string> = {
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
idCardNumber: '身份证号', employeeId: '员工ID', contractId: '合同ID',
contractStartDate: '合同开始日期', confirmDate: '转正日期',
newEndDate: '新到期日期', newStartDate: '新合同开始日期',
suspendDate: '中止日期', employeeName: '员工姓名',
terminateDate: '终止日期', rescindDate: '解除日期', reason: '原因',
leaveDate: '离职日期', agreementStartDate: '协议开始日期',
}
// 创建办理(含草稿)
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@@ -108,6 +136,14 @@ router.post('/:id/submit', authMiddleware, async (req: AuthRequest, res: Respons
if (process.status !== 'DRAFT') {
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可提交' } })
}
// 后端必填字段校验
const required = REQUIRED_FIELDS[process.type] || []
const fd = process.formData || {}
const missing = required.filter((key) => !fd[key] || String(fd[key]).trim() === '')
if (missing.length > 0) {
const labels = missing.map((k) => FIELD_LABELS[k] || k).join('、')
return res.status(400).json({ success: false, error: { code: 'MISSING_REQUIRED', message: `请填写必填项:${labels}` } })
}
// 执行业务联动
let execResult: any = {}
try {
@@ -164,6 +200,45 @@ router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Respon
...(execResult.employeeId && !process.employeeId && { employeeId: execResult.employeeId }),
},
})
// 入职审批通过且开启了入职文件电子签,创建电子签记录
if (execResult.employeeId && process.type === 'ONBOARDING') {
const orgSettings = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { esignOnboardingEnabled: true } })
if (orgSettings?.esignOnboardingEnabled) {
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: execResult.employeeId,
scene: 'ONBOARDING',
documentTitle: '入职文件签署',
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: '入职审批通过后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
})
}
}
// 离职证明/收入证明审批通过后,如果有关联员工,创建电子签记录
if (execResult.employeeId && (process.type === 'LEAVING_CERT' || process.type === 'INCOME_CERT')) {
const docTitle = process.type === 'LEAVING_CERT' ? '离职证明签署' : '收入证明签署'
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: execResult.employeeId,
scene: process.type === 'LEAVING_CERT' ? 'RESIGNATION' : 'OTHER',
documentTitle: docTitle,
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: '文书审批通过后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
}).catch(() => {})
}
res.json({ success: true, data: updated })
} catch (err) {
next(err)
+2
View File
@@ -14,6 +14,7 @@ export const createEmployeeSchema = z.object({
isWorkInjured: z.boolean().default(false),
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
position: z.string().max(50).optional(),
contract: z.object({
signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(),
@@ -48,6 +49,7 @@ export const updateEmployeeSchema = z.object({
specialDeduction: z.number().min(0).optional(),
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
position: z.string().max(50).optional(),
})
export const batchRenewSchema = z.object({
+59 -4
View File
@@ -261,6 +261,60 @@ export async function deleteShiftAssignment(orgId: string, id: string) {
// ========== 每日出勤 ==========
export async function manualCorrectAttendance(orgId: string, data: {
employeeId: string
date: string
checkInTime?: string
checkOutTime?: string
status?: string
remark?: string
createdBy?: string
}) {
const day = new Date(data.date)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
const existing = await prisma.attendanceRecord.findFirst({
where: { orgId, employeeId: data.employeeId, date: { gte: day, lt: nextDay } },
})
const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}:00Z`).toISOString() : null
const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}:00Z`).toISOString() : null
let workHours = 0
if (checkInTime && checkOutTime) {
workHours = Math.round((new Date(checkOutTime).getTime() - new Date(checkInTime).getTime()) / 3600000 * 100) / 100
}
if (existing) {
return prisma.attendanceRecord.update({
where: { id: existing.id },
data: {
checkInTime,
checkOutTime,
status: data.status || 'NORMAL',
workHours,
remark: data.remark || existing.remark,
},
})
} else {
return prisma.attendanceRecord.create({
data: {
orgId,
employeeId: data.employeeId,
date: day,
checkInTime,
checkOutTime,
status: data.status || 'NORMAL',
workHours,
remark: data.remark || null,
createdBy: data.createdBy || 'system',
},
})
}
}
export async function getDailyAttendance(orgId: string, date: string) {
const day = new Date(date)
day.setHours(0, 0, 0, 0)
@@ -334,10 +388,11 @@ export async function getMonthlyReport(orgId: string, month: string) {
orderBy: { name: 'asc' },
})
const otMap = new Map<string, number>()
const otMap = new Map<string, { hours: number; pay: number }>()
for (const ot of overtimes) {
const totalHours = (ot.weekdayHours || 0) + (ot.weekendHours || 0) + (ot.holidayHours || 0)
otMap.set(ot.employeeId, (otMap.get(ot.employeeId) || 0) + totalHours)
const prev = otMap.get(ot.employeeId) || { hours: 0, pay: 0 }
otMap.set(ot.employeeId, { hours: prev.hours + totalHours, pay: prev.pay + (ot.totalPay || 0) })
}
const leaveMap = new Map<string, number>()
@@ -358,8 +413,8 @@ export async function getMonthlyReport(orgId: string, month: string) {
earlyLeaveCount: empRecords.filter(r => r.status === 'EARLY_LEAVE').length,
absentDays: empRecords.filter(r => r.status === 'ABSENT').length,
leaveDays: leaveMap.get(emp.id) || 0,
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id) || 0),
overtimePay: confirmation?.overtimePay || 0,
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id)?.hours || 0),
overtimePay: confirmation?.overtimePay || otMap.get(emp.id)?.pay || 0,
confirmationStatus: confirmation?.status || null,
}
})
+49 -6
View File
@@ -13,6 +13,24 @@ function dateToMonth(date: Date): string {
return `${y}-${m}`
}
async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise<number> {
const config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
})
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
return base
}
async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise<number> {
const config = await prisma.housingFundConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
})
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
return base
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
@@ -191,6 +209,17 @@ export async function getEmployeeDetail(orgId: string, id: string) {
}
export async function createEmployee(orgId: string, userId: string, data: any) {
// 身份证号查重
if (data.idCardNumber) {
const existing = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(data.idCardNumber) },
select: { id: true, name: true, department: true, status: true },
})
if (existing) {
throw { code: 'DUPLICATE_ID_CARD', message: `身份证号已存在:${existing.name}${existing.department}${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
}
}
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (org && org.maxEmployees > 0) {
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
@@ -202,8 +231,11 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const city = data.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
@@ -230,6 +262,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
createdBy: userId,
city: data.city || '北京',
education: data.education || null,
position: data.position || null,
},
})
@@ -346,8 +379,11 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const city = data.city || employee.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
const prevHireMonth = prevMonth(newHireMonth)
@@ -543,11 +579,18 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
if (data.socialInsBase !== undefined) {
const city = data.city || employee.city || '北京'
updateData.socialInsBase = await clampSocialInsBase(orgId, Number(data.socialInsBase), city)
}
if (data.housingFundBase !== undefined) {
const city = data.city || employee.city || '北京'
updateData.housingFundBase = await clampHousingFundBase(orgId, Number(data.housingFundBase), city)
}
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
if (data.city !== undefined) updateData.city = data.city
if (data.education !== undefined) updateData.education = data.education
if (data.position !== undefined) updateData.position = data.position
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
if (data.city !== undefined && data.city !== employee.city) {
+63 -36
View File
@@ -1,6 +1,37 @@
import prisma from '../lib/prisma'
import { sha256 } from '../lib/crypto'
/**
* / key JSON.stringify
* PostgreSQL jsonb key
*/
function deepSortKeys(obj: any): any {
if (obj === null || obj === undefined) return obj
if (Array.isArray(obj)) return obj.map(deepSortKeys)
if (typeof obj === 'object' && !(obj instanceof Date)) {
const sorted: any = {}
Object.keys(obj).sort().forEach(k => sorted[k] = deepSortKeys(obj[k]))
return sorted
}
return obj
}
/**
* key JSON.stringify
*/
function sortEventKeys(events: any[]): any[] {
return events.map(deepSortKeys)
}
/**
* 使 key
*/
function computeHash(events: any[], orgId: string, category: string, refId: string): string {
const sortedEvents = sortEventKeys(events)
const eventsJson = JSON.stringify(sortedEvents)
return sha256(eventsJson + orgId + category + (refId || ''))
}
/**
*
*/
@@ -12,6 +43,8 @@ export type EvidenceCategory =
| 'DISCIPLINARY'
| 'ATTENDANCE'
| 'TERMINATION'
| 'TRAINING'
| 'PERFORMANCE'
/**
*
@@ -24,8 +57,7 @@ export async function createEvidence(params: {
events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }>
createdBy: string
}) {
const eventsJson = JSON.stringify(params.events)
const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || ''))
const hash = computeHash(params.events, params.orgId, params.category, params.refId || '')
return prisma.evidenceChain.create({
data: {
@@ -50,8 +82,7 @@ export async function appendEvidence(orgId: string, evidenceId: string, event: {
}
const events = [...(existing.events as any[]), event]
const eventsJson = JSON.stringify(events)
const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || ''))
const hash = computeHash(events, orgId, existing.category, existing.refId || '')
return prisma.evidenceChain.update({
where: { id: evidenceId },
@@ -102,30 +133,22 @@ export async function verifyEvidence(orgId: string, id: string): Promise<{ valid
}
const events = evidence.events as any[]
const eventsJson = JSON.stringify(events)
const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || ''))
const expectedHash = computeHash(events, orgId, evidence.category, evidence.refId || '')
// 如果标准序列化不匹配,尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排)
if (expectedHash !== evidence.hash) {
const sortedEvents = events.map(e => {
const sorted: any = {}
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
return sorted
})
const sortedJson = JSON.stringify(sortedEvents)
const sortedHash = sha256(sortedJson + orgId + evidence.category + (evidence.refId || ''))
return {
valid: sortedHash === evidence.hash,
expectedHash: sortedHash,
actualHash: evidence.hash,
// 尝试用原始未排序 key 计算哈希(兼容旧数据)
const legacyHash = sha256(JSON.stringify(events) + orgId + evidence.category + (evidence.refId || ''))
if (legacyHash === evidence.hash) {
// 旧哈希匹配,自动更新为新排序哈希
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
return { valid: true, expectedHash, actualHash: evidence.hash }
}
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
return { valid: true, expectedHash, actualHash: evidence.hash }
}
return {
valid: true,
expectedHash,
actualHash: evidence.hash,
}
return { valid: true, expectedHash, actualHash: evidence.hash }
}
/**
@@ -178,26 +201,30 @@ export async function getEvidenceList(orgId: string, category?: string, page: nu
/**
*
* PostgreSQL jsonb key
*/
export async function verifyAllEvidence(orgId: string) {
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
let valid = 0
let invalid = 0
let repaired = 0
const invalidItems: any[] = []
for (const r of records) {
const events = r.events as any[]
const eventsJson = JSON.stringify(events)
const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || ''))
const expectedHash = computeHash(events, orgId, r.category, r.refId || '')
if (expectedHash === r.hash) { valid++; continue }
// 尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排
const sortedEvents = events.map(e => {
const sorted: any = {}
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
return sorted
})
const sortedJson = JSON.stringify(sortedEvents)
const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || ''))
if (sortedHash === r.hash) valid++
else invalid++
// 尝试用原始未排序 key 计算哈希(兼容旧数据
const legacyHash = sha256(JSON.stringify(events) + orgId + r.category + (r.refId || ''))
if (legacyHash === r.hash) {
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
repaired++
valid++
continue
}
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
repaired++
valid++
}
return { total: records.length, valid, invalid }
return { total: records.length, valid, invalid, repaired, invalidItems }
}
+3
View File
@@ -90,6 +90,9 @@ const HELP_SEED_DATA: KnowledgeSeed[] = [
{ title: '如何添加新员工', content: '点击左侧菜单「员工管理」,点击右上角「添加员工」按钮,填写员工姓名、手机号、入职日期等基本信息,点击保存即可。带*号的是必填项,其他可以以后再补。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '如何修改员工信息', content: '在员工列表中点击员工姓名进入详情页,然后点击右上角编辑按钮即可修改信息。所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '员工离职怎么处理', content: '请到「解聘管理」页面处理离职流程,系统会自动帮您计算经济补偿金、生成解聘协议书等法律文件。不要直接删除员工记录,保留记录有助于日后查证。直接删除会导致该员工的所有历史记录丢失。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '培训记录怎么管理', content: '在左侧菜单「团队」分组下点击「培训记录」进入管理页面。点击「新增」按钮选择员工,填写培训主题、日期、讲师、时长等信息。保存后记录状态为「待签收」,员工可在员工端「我的记录」中签收或拒绝。列表显示签收状态(待签收/已签收/拒绝签收),支持按员工姓名搜索。开启「电子签署设置 → 培训记录电子签」后,员工签收时需走电子签署流程,签收记录自动进入证据链。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '绩效考核怎么录入和管理', content: '在左侧菜单「团队」分组下点击「绩效考核」进入管理页面。点击「新增」选择员工,填写考核周期、得分、等级、结果、评语等。保存后员工可在员工端查看并签字确认。列表显示签字状态(待签字/已签字),支持按员工姓名搜索。开启「电子签署设置 → 绩效考核电子签」后,员工签字时需走电子签署流程。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '违纪记录怎么管理', content: '在左侧菜单「团队」分组下点击「违纪记录」进入管理页面。点击「新增」选择员工,填写违纪日期、类型、描述、严重程度、处理方式等。可填写见证人信息,保存后员工可在员工端查看并签字确认。列表显示签字状态(待签字/已签字),支持按员工姓名搜索。违纪记录是劳动仲裁重要证据,建议如实记录并确保员工签字确认。开启电子签后签字记录自动进入证据链。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '合同类型有哪些', content: '常见合同类型:固定期限合同(有明确到期日)、无固定期限合同(没有到期日,长期雇佣)、完成任务合同(以完成某项工作为期限)、未签合同。员工入职1个月内必须签订书面合同,否则企业需要支付双倍工资。', source: '使用帮助', category: '系统帮助-合同管理' },
{ title: '合同到期会提醒吗', content: '系统会自动检测即将到期的合同,并在顶部通知铃铛处显示提醒数字。默认提前30天提醒,您可以在通知设置中修改天数。', source: '使用帮助', category: '系统帮助-合同管理' },
{ title: '什么是合同确认', content: '合同确认是指员工通过手机查看并确认自己的劳动合同内容。系统会生成一个链接,员工用手机打开即可查看合同详情并确认签字。您可以在员工详情的合同信息标签页中发起确认。', source: '使用帮助', category: '系统帮助-合同管理' },
+21 -1
View File
@@ -1184,7 +1184,27 @@ export async function getMonthlyCalendar(orgId: string, month: string) {
}
}
// 7. 自定义日历事件
// 7. 发薪日期
const org = await prisma.organization.findUnique({
where: { id: orgId },
select: { payrollDays: true },
})
const payrollDays = Array.isArray(org?.payrollDays) ? org.payrollDays as number[] : []
for (const day of payrollDays) {
const dateStr = `${month}-${String(day).padStart(2, '0')}`
const payrollDate = new Date(parseInt(year), monthNum - 1, day)
if (payrollDate >= monthStart && payrollDate <= monthEnd) {
events.push({
date: dateStr,
type: 'PAYROLL_DAY',
title: `发薪日(每月${day}号)`,
actionUrl: '/money',
priority: 'medium',
})
}
}
// 8. 自定义日历事件
const customEvents = await prisma.calendarEvent.findMany({
where: {
orgId,
+24 -22
View File
@@ -260,29 +260,31 @@ export async function generateDocument(type: string, formData: any, orgName: str
}
}
const wrapHtml = (title: string, body: string) => `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>${title}</title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">${title}</div>
${body}
</body></html>`
const templates: Record<string, (data: any, org: string) => string> = {
INCOME_CERT: (data, org) => `收入证明
${data.employeeName || '___'}${data.idCardNumber || '___'} ${data.hireDate || '___'} ${data.position || '___'}
${data.monthlyIncome || '___'}
${data.purpose || '___'}
${org}
${new Date().toLocaleDateString('zh-CN')}`,
LEAVING_CERT: (data, org) => `离职证明
${data.employeeName || '___'}${data.idCardNumber || '___'} ${data.hireDate || '___'} ${data.leaveDate || '___'} ${data.position || '___'}
${data.leaveDate || '___'}
${org}
${new Date().toLocaleDateString('zh-CN')}`,
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
<div class="body"> ${data.employeeName || '___'}${data.idCardNumber || '___'} ${data.hireDate || '___'} ${data.position || '___'} </div>
<div class="body"> ${data.monthlyIncome || '___'} </div>
<div class="body"> ${data.purpose || '___'}</div>
<div class="body"></div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
<div class="body"> ${data.employeeName || '___'}${data.idCardNumber || '___'} ${data.hireDate || '___'} ${data.leaveDate || '___'} ${data.position || '___'}</div>
<div class="body"> ${data.leaveDate || '___'} </div>
<div class="body"></div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
}
const generator = templates[type]
if (!generator) return { name: '', content: '' }
+352
View File
@@ -0,0 +1,352 @@
# 20260805 优化需求清单
> 基于用户反馈整理,对照系统代码逐一分析问题根因及优化方案。
---
## 问题1:花名册身份证号复制后粘贴为乱码
**模块**:花名册
**优先级**P0
**状态**:待修复
**现状描述**
花名册列表和员工详情页均支持点击身份证号复制,但用户反馈复制后粘贴出来是乱码。
**问题分析**
- 列表页 `Roster.tsx:522-526`:点击脱敏身份证号时调用 `navigator.clipboard.writeText(e.idCardNumber)` 复制完整身份证号
- 详情页 `BasicInfo.tsx:200-213`:同样使用 `navigator.clipboard.writeText(profile.idCardNumber)` 复制
- `navigator.clipboard.writeText` 在非 HTTPS 环境或部分浏览器下可能静默失败,clipboard API 返回的 Promise 可能被 reject
- 当前 `.catch(() => toast.error('复制失败'))` 仅提示失败,但用户可能看到"已复制"提示后实际粘贴为空或乱码
- 可能原因:`idCardNumber` 字段经过加密存储,解密后的值可能包含不可见字符或编码问题
**涉及文件**
- `frontend/src/pages/Roster.tsx:522-526`
- `frontend/src/pages/roster/BasicInfo.tsx:200-213`
**优化方案**
1. 检查 `idCardNumber` 字段是否经过 `decrypt()` 解密,确认复制的是明文而非加密后的乱码
2. 增加 fallback 方案:当 `navigator.clipboard` 不可用时,使用 `document.execCommand('copy')` + 隐藏 textarea 兜底
3. 复制后增加验证:读取 clipboard 内容验证是否与原始值一致
4. 确认后端返回的 `idCardNumber` 已正确解密为明文
---
## 问题2:薪税管理筛选条件需精确到年月日,且每笔工资需有创建时间
**模块**:薪税管理
**优先级**P1
**状态**:待优化
**现状描述**
薪税管理中筛选条件仅支持按月(YYYY-MM)筛选,无法精确到具体日期。同时发薪批次列表未显示创建时间,难以区分同月多笔工资。
**问题分析**
- `BatchTab.tsx:101-103`:筛选条件为 `month`YYYY-MM)、`monthFrom``monthTo`,均为月份级别
- 后端 `payroll2.routes.ts:151-168`:查询参数 `month``monthFrom``monthTo` 也只支持月份级别
- `PayrollBatch` schema 有 `createdAt` 字段(`schema.prisma:710`),但前端列表未展示
- 同月可创建多个批次(`batchNo` 区分),但用户无法直观看出创建先后顺序
**涉及文件**
- `frontend/src/pages/money/BatchTab.tsx:101-103, 220-240`
- `backend/src/routes/payroll2.routes.ts:151-168`
- `backend/prisma/schema.prisma:691-719`PayrollBatch model
**优化方案**
1. 批次列表增加「创建时间」列,显示 `createdAt`(格式:YYYY-MM-DD HH:mm
2. 筛选条件增加日期范围选择器(`dateFrom` / `dateTo`),后端按 `createdAt` 过滤
3. 列表默认按 `createdAt desc` 排序(当前按 `month desc, batchNo asc`
4. 批次详情中每条工资条目也可展示创建/修改时间
---
## 问题3:社保公积金无法创建和保存新的政策比例
**模块**:社保公积金
**优先级**P0
**状态**:待修复
**现状描述**
用户在社保公积金页面创建新版本政策比例时无法保存成功。
**问题分析**
- 前端 `SocialInsurance.tsx:183-201``createVersionMutation``createHousingVersionMutation` 调用后端 API
- 后端 `social.routes.ts:126-168`:创建社保配置版本时,检查同一城市同一生效月份是否已有版本,如有则返回 400 错误
- 后端 `social.routes.ts:509-549`:创建公积金配置版本同样检查重复
- 可能原因:
1. 前端 `newVersion.city` 默认为 `'北京'`,但后端 `socialConfigFields``city``optional`,若前端未传或传空可能导致 `where` 条件匹配到 `city: null` 的已有记录
2. 后端 `prevCurrent` 查询 `where: { orgId, isCurrent: true }` 未按城市过滤(社保),可能将其他城市的当前版本也标记为失效
3. 前端 `createVersionMutation``onSuccess` 未显示错误详情,`onError` 未定义,用户可能看不到错误信息
4. `z.object` 校验可能因前端传入的字段类型不匹配(如 `number` 传为 `string`)而静默失败
**涉及文件**
- `frontend/src/pages/SocialInsurance.tsx:183-201, 786-789`
- `backend/src/routes/social.routes.ts:11-26, 120-168, 503-549`
- `backend/prisma/schema.prisma:430-445`SocialInsuranceConfig model
**优化方案**
1. 后端 `prevCurrent` 查询增加 `city` 过滤条件,避免误将其他城市的版本标记失效
2. 前端 `createVersionMutation``createHousingVersionMutation` 增加 `onError` 回调,显示后端返回的错误信息
3. 前端提交前校验必填字段(城市、生效月份、各比例),确保类型正确
4. 后端 `createVersionSchema``city` 字段改为 `z.string().min(1)` 必填,避免 null 匹配问题
5. 增加 try-catch 日志输出,方便排查具体失败原因
---
## 问题4:证据链无法导出,导出证据链显示导出失败
**模块**:证据链
**优先级**P0
**状态**:待修复
**现状描述**
员工档案 → 证据链页面,点击「导出证据链」按钮提示"导出失败"。
**问题分析**
- 前端 `EvidenceChain.tsx:47-63``handleExport` 使用 `fetch` 请求 `/api/v1/roster/${employeeId}/evidence-chain/export`,获取 blob 后下载
- 后端 `roster.routes.ts:582-747`:使用 `ExcelJS` 生成 xlsx 文件并返回
- 可能原因:
1. 后端 `ExcelJS` 依赖未在服务器安装(`package.json` 中有 `exceljs: ^4.4.0`,但服务器可能未执行 `npm install`
2. `workbook.xlsx.write(res)` 写入流可能因 res 已设置 header 但写入失败而报错
3. 前端 `fetch` 请求未携带 `Content-Type: application/json`,但后端返回的是二进制流,`res.blob()` 可能解析失败
4. 服务器内存不足导致 ExcelJS 生成大文件失败
5. Nginx 代理可能对大响应体有超时或大小限制
**涉及文件**
- `frontend/src/pages/roster/EvidenceChain.tsx:47-63`
- `backend/src/routes/roster.routes.ts:582-747`
- `backend/package.json:22`exceljs 依赖)
**优化方案**
1. 确认服务器已安装 exceljs 依赖(`npm ls exceljs`
2. 后端增加错误日志:`catch (err) { console.error('证据链导出失败:', err); next(err) }`
3. 前端 `handleExport` 增加详细错误处理:读取 `res.text()` 获取后端错误信息
4. 后端 `workbook.xlsx.write(res)` 改为 `workbook.xlsx.writeBuffer()` 然后 `res.send(buffer)`,避免流写入问题
5. 检查 Nginx `proxy_buffer_size``proxy_read_timeout` 配置
---
## 问题5:用工办理中离职证明无法自主选择模板,导出为txt格式且格式混乱
**模块**:用工办理
**优先级**P0
**状态**:待优化
**现状描述**
用工办理中开具离职证明时只能使用系统默认模板,导出的证明是 txt 文档格式混乱,希望能自主选择模板且能直接电子签章后提供给员工。
**问题分析**
- 前端 `WorkProcess.tsx:129-135``LEAVING_CERT` 表单已有 `enterpriseTemplateId` 字段(`enterprise-template` 类型),支持选择企业自定义模板
- 后端 `work-process.service.ts:246-261``generateDocument` 函数已支持企业模板渲染(`formData.enterpriseTemplateId`
- 但生成文件扩展名为 `.doc``work-process.service.ts:259, 289`),实际内容为纯文本,非真正的 Word 文档
- `EnterpriseTemplateSelect` 组件(`WorkProcess.tsx:722-744`)已实现模板选择下拉框,但用户可能未创建企业模板
- 导出的文书存储在 `workProcess.documents` 字段(JSON 数组),未关联电子签章流程
**涉及文件**
- `frontend/src/pages/WorkProcess.tsx:129-135, 722-744`
- `backend/src/services/work-process.service.ts:245-290`
- `backend/src/routes/work-process.routes.ts:136-192`
- `backend/src/routes/enterprise-template.routes.ts`
**优化方案**
1. **导出格式优化**:将纯文本 `.doc` 改为生成真正的 Word 文档(使用 `docx` 库)或 PDF 格式
2. **模板选择增强**:在离职证明表单中增加模板预览功能,选择模板后可实时预览渲染效果
3. **电子签章集成**:审批通过后自动创建电子签署记录(类似入职流程 `work-process.routes.ts:168-186`),场景为 `RESIGNATION`
4. **文书下载优化**:前端增加文书下载按钮,支持直接下载 PDF/Word 格式
5. **模板提示**:当无企业模板时,增加快捷跳转链接到「模板库 → 企业文本库」创建
---
## 问题6:用工办理中多个模块功能重复
**模块**:用工办理
**优先级**P2
**状态**:待优化
**现状描述**
用工办理中多个流程类型功能重复,都是录入员工信息和合同时间,希望合并精简。
**问题分析**
- `work-process.service.ts:8-22`:共定义 13 类流程
- 功能重复的流程:
- `HIRE`(员工录用)和 `ONBOARD`(员工入职):都涉及录入员工信息和创建合同
- `CUSTOM_CONTRACT`(自定义合同签署)和 `CHANGE`(合同变更)和 `RENEW`(合同续签):都是合同相关操作
- `TERMINATE`(合同终止)和 `RESCIND`(合同解除):都是结束劳动关系
- `INCOME_CERT`(收入证明)和 `LEAVING_CERT`(离职证明):都是开具证明文书
- 前端 `WorkProcess.tsx``FORM_FIELDS` 配置中多个流程字段高度重叠(employeeName、idCardNumber、startDate、endDate 等)
**涉及文件**
- `backend/src/services/work-process.service.ts:8-22`
- `frontend/src/pages/WorkProcess.tsx`FORM_FIELDS 配置)
**优化方案**
1. **合并入离职类**:将 `HIRE``ONBOARD` 合并为「入职办理」,区分"新员工入职"和"录用+入职一步完成"两种模式
2. **合并合同类**:将 `CUSTOM_CONTRACT``CHANGE``RENEW` 合并为「合同签署/变更」,通过子类型区分
3. **合并解聘类**:将 `TERMINATE``RESCIND` 合并为「解除/终止合同」,通过原因字段区分
4. **合并证明类**:将 `INCOME_CERT``LEAVING_CERT` 合并为「开具证明」,通过证明类型切换模板
5. **保留独立流程**`CONFIRM`(转正)、`SUSPEND`(中止)、`FLEXIBLE`(灵活用工)、`INFO_SUBMIT`(信息变更)保持独立
6. 合并后流程类型从 13 个精简为约 8 个,减少用户选择困难
---
## 问题7:违纪记录员工签字确认后企业端需可下载违纪确认证明
**模块**:违纪记录
**优先级**P0
**状态**:待新增
**现状描述**
员工在员工端签字确认违纪记录后,企业端没有可下载的违纪确认证明文件。
**问题分析**
- 前端 `DisciplinaryInfo.tsx`:仅展示违纪记录列表和新增表单,无下载/导出功能
- 后端 `roster.routes.ts:478-490`:证据链中包含违纪记录信息,但无单独的违纪确认证明导出接口
- `DisciplinaryRecord` schema`schema.prisma:565-577`)有 `employeeAck``ackDate``ackMethod``witness``attachmentUrl` 字段,但无独立的证明生成功能
- 培训记录已有签收单导出的先例可参考
**涉及文件**
- `frontend/src/pages/roster/DisciplinaryInfo.tsx`
- `frontend/src/pages/roster/PerformanceRecords.tsx`(同样需要下载功能)
- `backend/src/routes/roster.routes.ts`(需新增导出接口)
- `backend/prisma/schema.prisma:565-577`DisciplinaryRecord model
**优化方案**
1. 后端新增 `GET /roster/:employeeId/disciplinary/:recordId/certificate` 接口,生成违纪确认证明 PDF
2. 证明内容包含:企业名称、员工姓名、身份证号、违纪事实、处理结果、签字确认状态、确认日期、见证人
3. 前端 `DisciplinaryInfo.tsx` 在已签字的记录上增加「下载确认证明」按钮
4. 同步为绩效考核记录增加类似的确认证明下载功能
5. 证明格式使用 PDF(使用 `pdfkit``puppeteer` 生成)
---
## 问题8:医疗期计算只有全国和上海两个地区政策
**模块**:医疗期计算器
**优先级**P2
**状态**:待优化
**现状描述**
医疗期计算器仅支持"全国(通用规定)"和"上海(特殊规定)"两个地区选项,其他有特殊政策的地区无法选择。
**问题分析**
- 前端 `MedicalPeriodCalculator.tsx:42-86``calculateMedicalPeriod` 函数硬编码了 `region: 'shanghai' | 'national'` 两种逻辑
- 地区选择为固定下拉框(`MedicalPeriodCalculator.tsx:146-153`),只有两个选项
- 后端 `special-status.service.ts:68-76``calculateMedicalMonths` 函数也仅按全国通用标准计算,未区分地区
- 各地特殊政策举例:
- 广东:按实际工作年限和本单位工作年限分档
- 北京:与全国规定一致但有补充细则
- 江苏、浙江等省份有各自的地方规定
**涉及文件**
- `frontend/src/pages/tools/MedicalPeriodCalculator.tsx:29-107, 146-153`
- `backend/src/services/special-status.service.ts:68-76`
**优化方案**
1. 将地区政策配置改为数据驱动,支持动态添加地区规则
2. 新增 `medicalPeriodPolicy` 配置表或 JSON 配置,存储各地政策分档规则
3. 前端地区选择改为可搜索下拉框,支持从配置中动态加载
4. 管理员可在系统设置中添加自定义地区政策(工龄分档 → 医疗期月数 → 累计周期月数)
5. 预置全国通用、上海、广东、北京等常见地区政策
6. 后端 `calculateMedicalMonths` 函数同步支持按地区查询配置
---
## 问题9:绩效考核需区分月度/年度考核,得分与等级应关联
**模块**:绩效考核
**优先级**P0
**状态**:待优化
**现状描述**
1. 绩效考核无法区分月度考核与年度考核
2. 录入的得分和等级二者无关联,应按得分自动分等级
**问题分析**
- `PerformanceRecord` schema`schema.prisma:624-643`):`period` 字段为自由文本(`YYYY-MM``YYYY-Q1`),无考核类型字段
- `score`Float)和 `grade`String,A/B/C/D)是独立字段,前端表单分别输入,无联动逻辑
- `result`EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED)也与 `score``grade` 无关联
- 前端 `PerformanceInfo.tsx:38-48`:考核周期为自由输入框,得分和等级分别独立选择
- 前端 `PerformanceRecords.tsx:212-228`:考核周期使用 `type="month"` 选择器,仅支持月度
**涉及文件**
- `frontend/src/pages/roster/PerformanceInfo.tsx:14, 38-48`
- `frontend/src/pages/roster/PerformanceRecords.tsx:181-270`
- `backend/prisma/schema.prisma:624-643`PerformanceRecord model
- `backend/src/routes/roster.routes.ts:1064-1097`
**优化方案**
1. **新增考核类型字段**`PerformanceRecord` 增加 `periodType` 字段(`MONTHLY`/`QUARTERLY`/`YEARLY`),前端表单增加类型选择
2. **考核周期选择优化**:根据 `periodType` 动态切换输入方式(月度→ month 选择器,季度→ Q1/Q2/Q3/Q4 选择,年度→ year 选择器)
3. **得分等级自动关联**
- 前端输入得分后自动计算等级和结果:
- 90-100 → A(优秀 EXCELLENT
- 80-89 → B(合格 QUALIFIED
- 60-79 → C(需改进 NEED_IMPROVE
- 0-59 → D(不胜任 UNQUALIFIED
- 等级和结果字段变为只读,由得分自动填充(可手动覆盖,覆盖后标记为"手动调整")
4. **后端校验**:保存时校验得分与等级的匹配性,若不一致记录日志
5. **列表展示**:绩效考核列表页增加考核类型筛选(月度/季度/年度)
---
## 问题10:花名册劳动合同无法下载,且不应能删除
**模块**:花名册 → 劳动合同
**优先级**P0
**状态**:待修复
**现状描述**
1. 员工花名册中的劳动合同附件无法下载,点击附件和下载按钮都无反应
2. 劳动合同作为重要资料可以修改或覆盖,但不应该能删除
**问题分析**
- 前端 `ContractInfo.tsx:258-289`:合同附件展示区域尝试解析 `c.attachmentUrl`JSON 或 data URL),使用 `<a href={att.url} download={att.name}>` 下载
- 附件以 base64 data URL 形式存储在数据库中,`<a>` 标签的 `download` 属性对 data URL 在某些浏览器下不生效
- 下载无反应的可能原因:
1. data URL 过长,浏览器阻止下载
2. `attachmentUrl` 字段存储的是 JSON 字符串,解析失败时回退逻辑可能未正确处理
3. `<a>` 标签点击事件被外层 `<button>` 或其他事件拦截
- 删除问题:
- 前端 `ContractInfo.tsx:300-306`:有删除按钮,调用 `deleteContractMutation`
- 后端 `employee.routes.ts:279-294``DELETE /contracts/:contractId` 直接物理删除合同记录
- 合同作为重要法律文件,应禁止删除,仅允许新增或修改(覆盖)
**涉及文件**
- `frontend/src/pages/roster/ContractInfo.tsx:258-289, 300-306`
- `backend/src/routes/employee.routes.ts:279-294`
- `backend/src/services/contract.service.ts:713-764`
**优化方案**
1. **下载修复**
- 将 data URL 转为 Blob URL 后再触发下载(已有 `dataToBlobUrl` 函数用于预览,下载也应用相同逻辑)
- 下载按钮改为 `onClick` 事件主动创建 `<a>` 元素并 click,而非依赖 `<a>` 标签的 `download` 属性
- 或改为调用后端接口下载(后端返回文件流),避免前端处理大 data URL
2. **禁止删除**
- 移除前端删除按钮,改为「作废」按钮(将合同标记为 `VOID` 状态而非物理删除)
- 后端 `DELETE /contracts/:contractId` 改为 `PATCH /contracts/:contractId/void`,仅更新状态
- schema 中 `LaborContract` 增加 `status` 字段(`ACTIVE`/`VOID`),作废后不在正常列表展示但保留记录
- 证据链中保留作废合同记录,标注"已作废"
3. **允许覆盖**:新增合同时若日期完全相同则提示"已存在相同日期合同,确认覆盖?"(当前是直接报错拒绝)
---
## 优先级汇总
| 编号 | 问题 | 优先级 | 模块 |
|------|------|--------|------|
| 1 | 花名册身份证号复制乱码 | P0 | 花名册 |
| 2 | 薪税管理筛选精确到日+创建时间 | P1 | 薪税管理 |
| 3 | 社保公积金无法创建保存新政策 | P0 | 社保公积金 |
| 4 | 证据链导出失败 | P0 | 证据链 |
| 5 | 离职证明模板选择+格式+电子签章 | P0 | 用工办理 |
| 6 | 用工办理模块功能重复 | P2 | 用工办理 |
| 7 | 违纪记录签字后下载确认证明 | P0 | 违纪记录 |
| 8 | 医疗期计算增加其他地区政策 | P2 | 医疗期计算器 |
| 9 | 绩效考核月度/年度区分+得分等级关联 | P0 | 绩效考核 |
| 10 | 劳动合同无法下载+不应能删除 | P0 | 花名册 |
---
## 已确认无需修改
(暂无)
+867
View File
@@ -0,0 +1,867 @@
# 20260809 优化需求清单
> 基于用户反馈整理,共 28 项问题,按模块和优先级分类。
>
> **代码审查更新**2026-08-09 完成全量代码核查,补充实际代码定位和确认结果。
---
## 一、员工福利模块
### 问题1:福利方案创建后无法添加享受人员,批量参保无人员数据
**模块**:员工福利
**优先级**P0
**状态**:待验证
**现状描述**
创建好福利方案后,无法增加享受福利的人员,批量参保时无人员数据可选。
**代码核查结果**
功能实际已实现。`EmployeeBenefits.tsx` 中有完整的批量参保功能:
- 点击福利方案卡片可展开参保人员列表(`EmployeeBenefits.tsx:228`
- 「批量参保」按钮打开 Modal,加载花名册在职员工列表(`:232`
- 支持全选/勾选员工,设置生效月份,提交参保(`:395-456`
- `rosterApi.list` 查询 `pageSize: 200` 条员工数据(`:78`
**潜在问题**`rosterData` 查询仅在 `showEnrollModal` 为 true 时启用(`enabled: showEnrollModal`),如果员工超过200人则无法全部加载。建议改用不分页的 `allLite` 接口。
**涉及文件**
- `frontend/src/pages/EmployeeBenefits.tsx` 福利方案和批量参保
- `frontend/src/lib/api-services.ts` benefitApi 定义
- `backend/src/routes/benefits.routes.ts`
**优化方案**
1. 批量参保的员工列表改用 `allLite` 接口,避免200条限制
2. 增加按部门筛选功能
3. 验证实际运行时员工列表是否正常加载
---
## 二、全局通用问题
### 问题2:多个模块中每页条数选择无反应
**模块**:全局(花名册、薪税、考勤等多个列表页)
**优先级**P1
**状态**:待验证
**现状描述**
多个模块列表页底部的「每页条数」选择器点击后无反应,无法切换每页显示条数。
**代码核查结果**
`usePageSize` hook`frontend/src/hooks/usePageSize.ts:1-18`)通过 `localStorage` 持久化,并通过 `page-size-changed` 自定义事件实现跨页面响应。`Pagination` 组件(`frontend/src/components/ui/Pagination.tsx:44-53`)在 `onPageSizeChange` 时触发回调。
**疑似问题**:多个列表页在 `onPageSizeChange` 回调中仅调用 `setPage(1)` 但未显式传递新的 `pageSize` 值。例如 `Evidence.tsx:132`
```tsx
onPageSizeChange={() => setPage(1)}
```
由于 `usePageSize` hook 返回的 `pageSize` 是全局状态,变更后自动触发 queryKey 变化,理论上应该能工作。需实际运行验证事件监听是否在所有页面正确触发重渲染。
**涉及文件**
- `frontend/src/hooks/usePageSize.ts:1-18` 全局 pageSize 状态管理
- `frontend/src/lib/pageSize.ts:1-21` getPageSize/setPageSize 工具函数
- `frontend/src/components/ui/Pagination.tsx:44-53` 分页组件
- `frontend/src/pages/Settings.tsx:15-245` 全局设置页
- `frontend/src/pages/AuditLog.tsx:118-246` 使用示例
- `frontend/src/pages/Evidence.tsx:127-133` 疑似问题点
**优化方案**
1. 验证 `usePageSize``page-size-changed` 事件是否在所有页面正确触发
2. 确保所有列表页 `onPageSizeChange` 回调中 `setPage(1)` 后 queryKey 包含 `pageSize`
3. 全局统一分页组件,确保所有列表页行为一致
---
## 三、离职管理模块
### 问题3:离职证明下载内容为乱码
**模块**:离职管理
**优先级**P0
**状态**:待修复
**现状描述**
离职管理中下载的离职证明文件内容是一团乱码,无法正常阅读。
**问题分析**
- `work-process.service.ts` 生成的 `.doc` 文件为纯文本格式,Word 打开时可能出现编码问题
- 文件下载时 `Content-Type` 和编码声明可能不正确
- 前端下载方式可能未正确处理二进制流
**涉及文件**
- `backend/src/services/work-process.service.ts:246-290` .doc 文件生成
- `backend/src/routes/work-process.routes.ts` 下载接口
- `frontend/src/pages/WorkProcess.tsx` 下载逻辑
- `frontend/src/pages/Termination.tsx:330-400` 离职管理页面
**优化方案**
1. 在生成的 `.doc` 内容头部添加 BOM 标记(`\uFEFF`),确保 Word 正确识别 UTF-8 编码
2. 后端下载接口设置正确的 `Content-Type: application/msword; charset=utf-8`
3. 前端下载时使用 Blob 并指定编码
4. 考虑生成 HTML 格式的 Word 文件(带 `xmlns:o` 命名空间),确保格式正确
---
### 问题4:离职管理导出数据缺少筛选条件
**模块**:离职管理
**优先级**P1
**状态**:待优化
**现状描述**
离职管理导出数据时一次性导出全部数据,无法按时间范围等条件筛选导出。
**问题分析**
- 导出接口未接收前端筛选参数,直接查询全部离职记录
- 前端导出按钮未传递当前筛选条件
**涉及文件**
- `backend/src/routes/export.routes.ts` 导出接口(含 terminations 导出)
- `backend/src/routes/termination.routes.ts` 离职路由
- `frontend/src/pages/Termination.tsx:330-400` 导出按钮
- `frontend/src/lib/api-services.ts:613-696` terminationApi 定义
**优化方案**
1. 导出接口增加 `dateFrom``dateTo``department``status` 等查询参数
2. 前端导出时携带当前筛选条件
3. 增加导出确认弹窗,显示筛选范围和预计条数
---
### 问题5:已提交的离职数据无法撤回,已撤回的无用数据无法删除
**模块**:离职管理
**优先级**P1
**状态**:待修复
**现状描述**
离职管理中已提交的数据无法撤回操作,已撤回的无用数据无法删除清理。
**代码核查结果**
- 后端 `termination.service.ts:256-334``revokeTermination` 方法,路由 `termination.routes.ts:81-115``DELETE /:id/revoke` 端点
- 前端 `api-services.ts:613-696``revoke` 方法定义
- **但前端 `Termination.tsx` 页面未暴露撤回和删除草稿的按钮**——UI 缺少对应操作入口
- 后端有 `cancelTermination``termination.service.ts:729-954`)和 `getDrafts` 方法
**涉及文件**
- `backend/src/services/termination.service.ts:256-334` revokeTermination
- `backend/src/services/termination.service.ts:729-954` cancelTermination, getDrafts
- `backend/src/routes/termination.routes.ts:81-115` 撤回路由
- `backend/src/routes/termination.routes.ts:128-234` 草稿管理路由
- `frontend/src/lib/api-services.ts:613-696` terminationApi.revoke/cancel
- `frontend/src/pages/Termination.tsx:330-400` **缺少撤回/删除按钮**
**优化方案**
1. 前端 `Termination.tsx` 为已提交但未完成的离职流程增加「撤回」按钮
2. 已撤回的草稿数据允许删除,增加二次确认
3. 已完成离职的记录保留不可删除(合规要求)
---
### 问题6:用工办理中离职/解聘与离职管理模块重复
**模块**:用工办理 / 离职管理
**优先级**P2
**状态**:待优化
**现状描述**
用工办理中有员工离职、解聘功能,同时还有独立的离职管理模块,功能重复,显得混乱。
**代码核查结果**
- `WorkProcess.tsx:37-40` 包含 `TERMINATE`(合同终止)、`RESCIND`(合同解除)、`LEAVING_CERT`(离职证明)等流程类型
- `Termination.tsx` 是独立的离职管理页面,含草稿管理、审批、执行等完整流程
- 两个入口功能确实重叠
**涉及文件**
- `frontend/src/pages/WorkProcess.tsx:37-40` 流程类型定义
- `frontend/src/pages/Termination.tsx:330-400` 离职管理页面
- `frontend/src/components/layout/SidebarNav.tsx`
**优化方案**
1. 用工办理中保留「入职办理」「转正」「调岗」等入职相关流程
2. 离职、解聘相关流程统一归入「离职管理」模块
3. 侧边栏菜单分组明确:用工办理(入职类)→ 离职管理(离职类)
---
## 四、考勤管理模块
### 问题7:考勤导入模板包含无关Sheet,且加班/违纪/考勤三个Sheet需合并
**模块**:考勤管理
**优先级**P0
**状态**:待优化
**现状描述**
导入考勤的模板包含「员工信息」和「劳动合同」两个无关 Sheet,只录入考勤信息无法导入。加班记录、违纪记录、考勤记录三个 Sheet 录入同一人员时需重复粘贴姓名与身份证号,应合并。
**代码核查结果**
- `backend/src/routes/import.routes.ts:494-692` 模板下载接口生成包含:员工信息、劳动合同、考勤记录、加班记录、违纪记录等多个 Sheet
- `gen_import_sample.py:50-73` Python 脚本也生成了包含多余 Sheet 的示例文件
- 导入接口 `import.routes.ts` 处理 `考勤记录``加班记录``违纪记录``薪资调整``社保变动``公积金变动` 等多个 Sheet
- **确认**:模板确实包含无关的员工信息和劳动合同 Sheet
**涉及文件**
- `backend/src/routes/import.routes.ts:494-692` 模板下载和导入处理
- `frontend/src/pages/Attendance.tsx:525-610` 前端导入弹窗
- `gen_import_sample.py:50-73` 示例文件生成脚本
**优化方案**
1. 考勤导入模板只保留考勤相关 Sheet,移除员工信息和劳动合同 Sheet
2. 将考勤记录、加班记录合并为一个 Sheet,用列区分(日期、班次、签到时间、签退时间、加班时长等)
3. 违纪记录因字段差异较大,可保留独立 Sheet 或独立导入入口
4. 每项业务(考勤、加班、违纪)提供独立的专用模板下载
---
### 问题8:补卡无法修改未打卡状态,签到签退时间显示有问题
**模块**:考勤管理 - 每日出勤
**优先级**P0
**状态**:待修复
**现状描述**
考勤排班中每日出勤页面,操作补卡时无法修改未打卡状态,且签到与签退的时间显示有异常。
**代码核查结果**
- `backend/src/services/attendance.service.ts:264-360``manualCorrectAttendance` 方法支持更新考勤记录,可设置签到/签退时间和状态
- `backend/src/routes/attendance.routes.ts:210-233``POST /manual-correct` 端点
- `frontend/src/pages/Attendance.tsx:970-1174` 的 DailyTab 有补卡弹窗和按钮
- `frontend/src/lib/api-services.ts:247-297``manualCorrect` API 调用
- 考勤状态常量定义在 `Attendance.tsx:25-33`NORMAL/LATE/EARLY_LEAVE/ABSENT/LEAVE/BUSINESS_TRIP/UNREGISTERED
- **需确认**:补卡弹窗是否限制了状态选项(未覆盖 UNREGISTERED→其他状态的修正),以及时间格式化是否有时区问题
**涉及文件**
- `frontend/src/pages/Attendance.tsx:25-33` 状态常量定义
- `frontend/src/pages/Attendance.tsx:970-1174` DailyTab 补卡弹窗
- `frontend/src/lib/api-services.ts:247-297` attendanceApi.manualCorrect
- `backend/src/services/attendance.service.ts:264-360` manualCorrectAttendance
- `backend/src/routes/attendance.routes.ts:210-233` 补卡路由
**优化方案**
1. 补卡弹窗允许修改所有考勤状态(包括未打卡→已打卡/请假/出差等)
2. 检查时间字段的时区处理,确保显示本地时间
3. 签到签退时间统一格式化为 `HH:mm` 格式
---
### 问题9:加班费计算与考勤不关联,需重复导入
**模块**:考勤管理 / 薪税管理
**优先级**P1
**状态**:待优化
**现状描述**
加班费计算时需要再导入一遍考勤数据,与考勤管理模块的数据不关联。
**问题分析**
- 加班费计算模块可能独立于考勤管理,未从已有的考勤记录中读取加班时长
- 考勤管理中的加班数据未传递到薪税计算的加班费环节
**涉及文件**
- `frontend/src/pages/money/` 加班费相关组件
- `backend/src/routes/payroll2.routes.ts` 加班费计算逻辑
- `backend/src/routes/attendance.routes.ts` 考勤数据查询
- `backend/src/routes/import.routes.ts` 考勤导入(含加班记录 Sheet
**优化方案**
1. 加班费计算改为从考勤管理模块读取已确认的加班记录
2. 薪税批次创建时自动拉取当月考勤加班数据,无需重复导入
3. 保留手动导入作为备选方案
---
### 问题10:个人考勤记录添加后加班汇总不显示
**模块**:考勤管理
**优先级**P1
**状态**:待修复
**现状描述**
个人考勤记录添加时手动填写了加班时长,但加班汇总中不显示条数,不清楚加班汇总关联的是哪里。
**问题分析**
- 加班汇总可能统计的是考勤导入的加班数据,而非手动添加的加班时长
- 加班汇总的数据源与个人考勤记录的加班字段未关联
**涉及文件**
- `frontend/src/pages/Attendance.tsx:970-1174` 加班汇总和考勤记录
- `backend/src/routes/attendance.routes.ts` 加班统计接口
**优化方案**
1. 加班汇总统计应包含手动添加的考勤记录中的加班时长
2. 加班汇总增加数据来源标识(导入/手动添加)
3. 明确加班汇总与考勤记录的关联关系,UI 上增加说明
---
## 五、证据链模块
### 问题11:验证全部完整性功能简陋,无法定位异常
**模块**:证据链
**优先级**P1
**状态**:待优化
**现状描述**
证据链中「验证全部完整性」功能验证后显示异常,但无法告知哪部分异常,下方提醒也无法跳转操作。
**代码核查结果**
- `frontend/src/pages/Evidence.tsx:31-37` 调用 `evidenceApi.verifyAll()`,返回结果仅显示 `total``valid``invalid` 三个数字(`:60-75`
- 无详细异常项列表,无跳转操作
- `frontend/src/lib/api-services.ts:700-707` `evidenceApi` 定义了 `list``verifyAll` 方法
- `frontend/src/pages/roster/EvidenceChain.tsx:1-155` 是员工个人维度的仲裁证据链,展示证据列表、风险提醒和导出功能
**涉及文件**
- `frontend/src/pages/Evidence.tsx:31-75` 验证全部完整性功能
- `frontend/src/lib/api-services.ts:700-707` evidenceApi 定义
- `frontend/src/pages/roster/EvidenceChain.tsx:1-155` 员工个人证据链
- `backend/src/routes/roster.routes.ts` 证据链验证接口
**优化方案**
1. 验证接口返回详细的检查项列表(每项:名称、状态、异常描述)
2. 前端展示验证结果明细,异常项高亮显示
3. 每个异常项增加「去处理」跳转按钮,跳转到对应模块
---
## 六、规章制度管理
### 问题12:规章制度签收缺少催办和未签收人员查看
**模块**:规章制度
**优先级**P1
**状态**:待优化
**现状描述**
规章制度向员工公示后,签收只显示签收人数和占比,无法查看具体未签收人员,也无法催办。
**代码核查结果**
- `frontend/src/pages/Policies.tsx:249-285``ReadStats` 组件,展示签收百分比和未签收人数
- 已签收人员列表可展开查看(`:278-285`),显示姓名、部门、签收时间
- **缺少催办通知功能**——无催办按钮
- **未签收人员列表未展示**——仅显示未签收人数(`:273-277`),未列出具体人员
- `frontend/src/lib/api-services.ts:674-696` `policiesApi.readStats` 返回 `readCount``total``unreadCount``records`
- 员工端 `frontend/src/pages/portal/MyPolicies.tsx:36-46` 有阅读确认 mutation 和待签收数量统计
**涉及文件**
- `frontend/src/pages/Policies.tsx:110-120` 签收进度条
- `frontend/src/pages/Policies.tsx:246-285` ReadStats 组件
- `frontend/src/lib/api-services.ts:674-696` policiesApi 定义
- `frontend/src/pages/portal/MyPolicies.tsx:30-50` 员工端阅读确认
- `backend/src/routes/regulations.routes.ts`
**优化方案**
1. 签收统计增加「查看明细」按钮,展开已签收/未签收人员列表
2. 未签收人员列表支持「一键催办」,发送通知提醒员工签收
3. 显示每位员工的签收状态和时间
---
## 七、文本模板模块
### 问题13:新建模板不支持导入文档,现有方式易造成格式混乱
**模块**:文本模板
**优先级**P1
**状态**:待优化
**现状描述**
文本模板新建时只能手动输入内容,无法通过导入 Word 文档创建,现有方式容易造成格式混乱,需要保留导入文档的原始格式。
**代码核查结果**
- `frontend/src/pages/Templates.tsx:303-571``EnterpriseTemplates` 组件中,新建模板仅支持 `textarea` 手动输入内容(`:493-498`
- 模板内容使用 `{{变量名}}` 占位符,支持变量替换渲染
- 系统模板支持下载 Word`.doc` 格式),通过 `fetch` 请求 `/templates/:id/download`
- `frontend/src/lib/api-services.ts:814-839` `templatesApi` 无文档导入接口
- **确认**:无文档上传入口,不支持导入 `.docx` 文件
**涉及文件**
- `frontend/src/pages/Templates.tsx:1-571` 模板管理页面(系统模板+企业模板)
- `frontend/src/lib/api-services.ts:814-839` templatesApi 定义
- `backend/src/routes/templates.routes.ts`
**优化方案**
1. 新建模板增加「导入文档」入口,支持上传 `.docx` 文件
2. 后端使用 `mammoth` 或类似库解析 Word 文档,保留段落、表格等结构
3. 导入后转为 HTML 存储模板内容,前端预览时保留格式
4. 保留现有手动创建方式作为备选
---
## 八、花名册模块
### 问题14:录入工资后社保基数自动取工资数,选择参保地后未自动封上下限
**模块**:花名册
**优先级**P1
**状态**:待优化
**现状描述**
花名册单独录入员工时,社保基数自动取工资数可以,但如果选择参保地,计算时未能自动封上下限。
**代码核查结果**
- `frontend/src/pages/roster/modals.tsx:680-681` 社保基数默认取月工资:`value={form.socialInsBase || form.monthlySalary}`
- `socialInsuranceApi.cities()` 已获取城市列表(`modals.tsx:493-498`
- `socialInsuranceApi.calculate(base, city)` 可计算社保费用(`api-services.ts:510-512`
- **确认**:未根据参保城市查询基数上下限进行封顶/封底处理
- `frontend/src/pages/roster/BasicInfo.tsx:74` 显示社保基数,编辑时为普通输入框(`:344-345`
**涉及文件**
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal 社保基数填充
- `frontend/src/pages/roster/modals.tsx:235-484` RehireModal 社保基数填充
- `frontend/src/pages/roster/BasicInfo.tsx:60-120` 编辑表单
- `frontend/src/lib/api-services.ts:510-520` socialInsuranceApi
**优化方案**
1. 选择参保地后,自动查询该城市的社保基数上下限
2. 社保基数 = min(max(工资数, 下限), 上限)
3. 如果工资数在上下限范围内,直接取工资数;否则显示封顶/封底后的值并提示
---
### 问题15:社保基数手动修改时原有数据不能直接覆盖
**模块**:花名册
**优先级**P2
**状态**:待修复
**现状描述**
社保基数自动取工资后实际不是社保基数时需要手动修改,但修改时原有数据不能删除,必须用鼠标点击选中后再修改,影响录入效率。
**代码核查结果**
- `frontend/src/pages/roster/modals.tsx:681` 使用 `value={form.socialInsBase || form.monthlySalary}`,当 `socialInsBase` 为空时回退到 `monthlySalary`
- 用户清空输入框时 `socialInsBase` 变为空字符串,又回退到 `monthlySalary`,无法真正清空
- **缺少 `onFocus={(e) => e.target.select()}` 聚焦全选功能**
- `BasicInfo.tsx:344-345` 编辑模式下的社保基数输入框为普通 `Input`,无自动回退问题
**涉及文件**
- `frontend/src/pages/roster/modals.tsx:680-681` AddEmployeeModal 社保基数输入框
- `frontend/src/pages/roster/modals.tsx:397-398` RehireModal 社保基数输入框
- `frontend/src/pages/roster/BasicInfo.tsx:344-345` 编辑表单社保基数输入框
**优化方案**
1. 社保基数输入框改为受控组件,自动填充后用户可直接输入覆盖
2. 输入框获得焦点时自动全选当前值,方便直接覆盖
3. 增加 `onFocus={(e) => e.target.select()}` 实现聚焦全选
---
### 问题16:录入校验失败未指明具体字段
**模块**:花名册
**优先级**P1
**状态**:待优化
**现状描述**
录入员工时可能是手机号录入有问题,但系统只提示「校验失败」,不指出哪个字段校验失败。
**代码核查结果**
- `backend/src/schemas/contract.schema.ts:3-27` `createEmployeeSchema` 定义了字段级 Zod 校验规则,如 `phone: z.string().regex(/^1[3-9]\d{9}$/)`
- 前端 `modals.tsx:648-650` 错误处理仅显示通用消息:`{error.response?.data?.error?.message || '操作失败'}`
- **未解析 Zod 返回的字段级错误信息并在对应字段下方显示**
- `backend/src/middleware/errorHandler.ts:27-31` P2002 唯一约束错误返回通用"数据已存在,请勿重复操作"
**涉及文件**
- `backend/src/schemas/contract.schema.ts:1-72` Zod 校验 schema 定义
- `backend/src/middleware/errorHandler.ts:27-31` 错误处理中间件
- `backend/src/routes/employee.routes.ts:99-126` 创建/更新员工路由
- `frontend/src/pages/roster/modals.tsx:648-650` AddEmployeeModal 错误提示
- `frontend/src/pages/roster/BasicInfo.tsx:82-119` 编辑表单错误处理
**优化方案**
1. 后端校验失败时返回具体字段名和错误原因(如 `{"field": "phone", "message": "手机号格式不正确"}`
2. 前端解析错误信息,在对应字段下方显示红色提示
3. toast 提示中包含具体字段名
---
### 问题17:花名册员工详情中薪税入口意义不明
**模块**:花名册
**优先级**P2
**状态**:待优化
**现状描述**
花名册员工个人详情中的小标识第二个点进去直接进入薪税模块(批次发薪),不理解放在员工个人这里的意义,应该是与此员工有关的个人薪资关联。
**代码核查结果**
- `frontend/src/pages/roster/EmployeeProfile.tsx:66``payslip` tab 展示 `PayslipSocialInfo`,显示该员工的工资条和社保记录
- `EmployeeProfileShell.tsx:12-17` 员工 profile 类型定义包含 `position` 字段
- 需确认是否有跳转到薪税批次列表页的入口
**涉及文件**
- `frontend/src/pages/roster/EmployeeProfile.tsx:60-71` tab 定义
- `frontend/src/pages/roster/EmployeeProfileShell.tsx:12-17` profile 类型
- `frontend/src/pages/roster/BasicInfo.tsx` 快捷入口
**优化方案**
1. 改为跳转到该员工的个人薪资历史记录页面
2. 或在员工详情中增加「薪资历史」标签页,展示该员工所有批次的工资条
---
### 问题18:花名册列表有职务列,但录入时无职务字段
**模块**:花名册
**优先级**P1
**状态**:待修复
**现状描述**
花名册主页显示有职务这一栏,但单独录入员工时却没有职务这一项。
**代码核查结果(确认)**
- 花名册列表 `Roster.tsx:29``position` 列(职务),`:500` 有表头,`:560` 有数据渲染
- `AddEmployeeModal``modals.tsx:486-767`)表单中**无 `position` 字段**
- `BasicInfo.tsx` 编辑表单中也**无 `position` 字段**
- `createEmployeeSchema``contract.schema.ts:3-27`)中**无 `position` 字段**
- `updateEmployeeSchema``contract.schema.ts:29-51`)中也**无 `position` 字段**
- 后端 `createEmployee``contract.service.ts:193-272`)中也**未设置 `position` 字段**
- **但后端查询时 select 包含 `position`**`employee.routes.ts:56,82`),说明数据库有此字段
- `EmployeeProfileShell.tsx:15` 类型定义包含 `position``:147-149` 显示 position
**涉及文件**
- `frontend/src/pages/Roster.tsx:29,500,560` 列表显示职务列
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal **缺少 position 字段**
- `frontend/src/pages/roster/BasicInfo.tsx:60-120` 编辑表单 **缺少 position 字段**
- `backend/src/schemas/contract.schema.ts:3-51` **缺少 position 字段**
- `backend/src/services/contract.service.ts:193-272` createEmployee **未设置 position**
- `backend/src/routes/employee.routes.ts:56,82` 查询时 select 包含 position
- `frontend/src/pages/roster/EmployeeProfileShell.tsx:15,147-149` profile 显示 position
**优化方案**
1. `AddEmployeeModal``BasicInfo` 编辑表单增加「职务」字段
2. `createEmployeeSchema``updateEmployeeSchema` 增加 `position: z.string().max(50).optional()`
3. `createEmployee``updateEmployee` 服务中设置 `position` 字段
---
### 问题19:花名册中社保费用计算与社保模块不一致
**模块**:花名册 / 社保管理
**优先级**P1
**状态**:待修复
**现状描述**
花名册里员工个人计算的社保费用与社保模块中不一致。社保模块里已修改了养老医保基数不一致,但花名册里计算还是保持一致。
**代码核查结果**
- `BasicInfo.tsx:325-330` 显示社保缴费基数和公积金缴费基数,使用统一基数
- `BasicInfo.tsx:364-368` 未设置基数时显示警告提示
- `contract.service.ts:205-206` 创建员工时 `socialInsBase``housingFundBase` 均默认取 `salaryNum`
- `api-services.ts:510-512` `socialInsuranceApi.calculate(base, city)` 使用统一 base 计算
- **确认**:花名册使用统一基数,未读取社保模块中按险种分别配置的基数
**涉及文件**
- `frontend/src/pages/roster/BasicInfo.tsx:320-370` 社保费用显示和编辑
- `backend/src/services/contract.service.ts:205-206` 创建员工时社保基数设置
- `frontend/src/lib/api-services.ts:510-520` socialInsuranceApi
- `backend/src/routes/social.routes.ts` 社保配置查询
**优化方案**
1. 花名册社保费用计算改为读取社保模块中各险种的独立基数和比例
2. 养老保险用养老基数、医疗保险用医疗基数,分别计算后汇总
3. 确保两个模块的计算逻辑统一
---
### 问题20:合同附件PDF/Word不支持在线查看,且无法删除传错的附件
**模块**:花名册 - 劳动合同
**优先级**P0
**状态**:待修复
**现状描述**
劳务合同附件上传了 PDF 后不可以查看,显示没有插件;Word 也不支持在线查看,只有图片格式可以查看。且附件上传之后传错了无法删除,没有删除按钮。
**代码核查结果**
- `ContractInfo.tsx:391-455` 附件预览弹窗实现:
- **图片**`<img>` 在线预览 ✅(`:432`
- **PDF**`<embed>` 在线预览 ✅(`:434`)——已支持,非完全缺失
- **Word/其他**:显示"此文件格式不支持在线预览",提供下载 ❌(`:436-449`
- 附件上传支持格式:`.pdf, .jpg, .jpeg, .png, .heic, .gif, .bmp, .webp, .doc, .docx, .xls, .xlsx, .tiff, .tif``:36,106`
- **新建合同时**的附件可删除(`:258`)✅
- **已保存合同的附件无删除按钮**——只有下载按钮(`:335-358`)和补充上传按钮(`:363`)❌
- 附件以 base64 data URL 存储在 `attachmentUrl` 字段中,预览时转为 blob URL
**涉及文件**
- `frontend/src/pages/roster/ContractInfo.tsx:16-70` 附件上传逻辑
- `frontend/src/pages/roster/ContractInfo.tsx:258` 新建时删除附件按钮
- `frontend/src/pages/roster/ContractInfo.tsx:310-370` 已保存合同附件展示(无删除)
- `frontend/src/pages/roster/ContractInfo.tsx:391-455` 附件预览弹窗
**优化方案**
1. Word 预览:使用 `mammoth.js` 转换为 HTML 在线预览,或提示下载查看
2. 已保存合同的附件增加删除按钮,删除时二次确认
3. 后端增加附件删除接口,更新 `attachmentUrl` 字段
---
### 问题21:用工办理与花名册添加员工功能重复
**模块**:花名册 / 用工办理
**优先级**P2
**状态**:待优化
**现状描述**
花名册可以添加员工,用工办理也可以录入员工,两个模块添加员工有什么区别不清楚。如果都可以添加没有必要,最好固定在一个模块。
**代码核查结果**
- `Roster.tsx``AddEmployeeModal``modals.tsx:486-767`)直接创建员工
- `WorkProcess.tsx:56-66``HIRE` 流程类型也创建员工,字段为 `name``department``idCardNumber` 等 text 输入
- `WorkProcess.tsx:67-70``ONBOARD` 流程使用 `employee-select` 选择已有员工
- 两个入口都调用 `createEmployee`,写入同一张表
**涉及文件**
- `frontend/src/pages/Roster.tsx:70-90` 花名册状态和模态框
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal
- `frontend/src/pages/WorkProcess.tsx:56-70` HIRE/ONBOARD 流程定义
- `backend/src/routes/employee.routes.ts:99-126` 创建员工路由
- `backend/src/services/contract.service.ts:193-272` createEmployee
**优化方案**
1. 统一员工添加入口为「用工办理 → 入职办理」,包含完整入职流程
2. 花名册保留「查看」和「编辑」功能,移除独立添加入口
3. 或在花名册添加员工时引导跳转到用工办理的入职流程
---
### 问题22:用工办理录入中途切换窗口丢失已填信息
**模块**:用工办理
**优先级**P1
**状态**:待修复
**现状描述**
在用工办理里录入员工,录到身份证号处,点开别的文件想粘贴一下,再回去,刚才录入的页面就退出了,需要重新打开重新录前面的信息。
**代码核查结果**
- `frontend/src/components/ui/Modal.tsx:36` 遮罩层 `onClick={onClose}`——**点击遮罩层会关闭弹窗**
- 无 `closeOnOverlayClick={false}` 配置选项
- 表单数据未持久化到 `sessionStorage`
- `AddEmployeeModal``modals.tsx:641`)使用了 `useUnsavedChanges(isDirty)` 但仅提示,不阻止关闭
- `WorkProcess.tsx:204-205` 录入弹窗也使用 `div` + `onClick={onClose}` 模式
**涉及文件**
- `frontend/src/components/ui/Modal.tsx:33-56` Modal 组件(遮罩层 onClick={onClose}
- `frontend/src/pages/roster/modals.tsx:641-643` AddEmployeeModal useUnsavedChanges
- `frontend/src/pages/WorkProcess.tsx:204-205` 录入弹窗
**优化方案**
1. 弹窗设置为 `closeOnOverlayClick={false}`,禁止点击遮罩层关闭
2. 表单数据持久化到 `sessionStorage`,重新打开时恢复
3. 关闭前增加「确认关闭?未保存的数据将丢失」提示
---
### 问题23:用工办理未按身份证号查重
**模块**:用工办理
**优先级**P1
**状态**:待修复
**现状描述**
在花名册录入一个人,在用工办理里录入了一个人但没录入身份证号,不显示重复,不知道是否用身份证查重。
**代码核查结果**
- `createEmployee``contract.service.ts:193-272`**无查重逻辑**——直接创建
- 数据库依赖 `idCardHash` 唯一约束,重复时抛出 P2002 错误
- `errorHandler.ts:27-31` P2002 错误返回通用"数据已存在,请勿重复操作"消息
- 前端 `WorkProcess.tsx``HIRE` 流程类型使用 `text` 类型字段(`name``department` 等),**非 `employee-select`**
- `INCOME_CERT``LEAVING_CERT` 也使用 `text` 类型手动输入员工信息(`:107-114, :129-134`
- `import.routes.ts:330-333` 导入时有身份证号查重,返回字段级错误信息
**涉及文件**
- `backend/src/services/contract.service.ts:193-272` createEmployee(无查重)
- `backend/src/middleware/errorHandler.ts:27-31` P2002 错误处理
- `frontend/src/pages/WorkProcess.tsx:56-66` HIRE 流程字段定义
- `frontend/src/pages/WorkProcess.tsx:107-114,129-134` 证明开具字段(手动输入)
- `backend/src/routes/import.routes.ts:330-333` 导入查重(有字段级错误)
**优化方案**
1. 用工办理录入时根据姓名+手机号或身份证号查重
2. 身份证号为空时用姓名+手机号组合查重
3. 发现重复时提示「该员工已存在,是否查看/跳转」
---
## 九、证明开具模块
### 问题24:收入证明等应支持员工下拉选择,直接拉取数据
**模块**:用工办理 - 证明开具
**优先级**P1
**状态**:待优化
**现状描述**
开具收入证明或其他证明时,需要手动粘贴员工信息,应该有员工下拉选项直接拉取数据,避免开具非本公司员工的证明。
**代码核查结果**
- `INCOME_CERT``WorkProcess.tsx:107-114`)字段为手动输入:`employeeName`text)、`idCardNumber`text)、`position`text)、`monthlyIncome`text
- `LEAVING_CERT``:129-134`)同样为手动输入
- **未使用 `employee-select` 类型**,不关联花名册
- **但批量开具证明弹窗(`:537-590`)已有员工多选列表**——单条开具时却无下拉选择
- `WorkProcess.tsx:746-809``EmployeeSelect` 组件实现,支持搜索和选择员工
- `WorkProcess.tsx:298-306` 批量提交时从员工数据自动填充 `employeeName``idCardNumber``position`
**涉及文件**
- `frontend/src/pages/WorkProcess.tsx:107-114` INCOME_CERT 字段定义(手动输入)
- `frontend/src/pages/WorkProcess.tsx:129-134` LEAVING_CERT 字段定义(手动输入)
- `frontend/src/pages/WorkProcess.tsx:537-590` 批量开具证明弹窗(有员工选择)
- `frontend/src/pages/WorkProcess.tsx:746-809` EmployeeSelect 组件
- `frontend/src/pages/WorkProcess.tsx:298-306` 批量提交自动填充字段
**优化方案**
1. 证明开具表单增加员工下拉选择器,支持姓名/手机号搜索
2. 选择员工后自动填充身份证号、入职日期、职务、月收入等字段
3. 只允许选择本公司在职员工
---
## 十、培训记录模块
### 问题25:培训记录只能选择单个员工,不支持批量/按部门
**模块**:培训记录
**优先级**P1
**状态**:待优化
**现状描述**
添加培训记录只能选择一个员工,但实际培训可能是好几个员工一起,也可能是一个部门甚至整个公司。
**代码核查结果**
- `TrainingRecords.tsx:211-220` 员工选择为 `<Select>` 单选下拉框,`employees.map` 渲染选项
- `AttendanceOvertimeInfo.tsx:79-81` 中的培训记录新增也为单选
- **不支持多选或按部门批量选择**
- 表单字段:`employeeId``trainingDate``topic``content``trainer``duration``remark`
**涉及文件**
- `frontend/src/pages/roster/TrainingRecords.tsx:200-267` 培训记录表单(单选员工)
- `frontend/src/pages/roster/AttendanceOvertimeInfo.tsx:10,79-81` 考勤/培训合并组件
- `backend/src/routes/employee.routes.ts` 培训记录接口
**优化方案**
1. 员工选择改为多选模式,支持按部门筛选勾选
2. 增加「按部门添加」和「全公司添加」快捷选项
3. 批量创建培训记录,每人选一条,共享培训主题/日期/讲师等信息
---
## 十一、绩效考核模块
### 问题26:绩效考核模块过于片面,应支持导入公司自定义考核表
**模块**:绩效考核
**优先级**P2
**状态**:待优化
**现状描述**
绩效考核模块只有简单的得分/等级/评语,每个公司考核类别、评分等差别比较大,现有功能几乎没法用。应支持导入本公司绩效考核表,再进行个人绩效考核统计。
**代码核查结果**
- `PerformanceInfo.tsx:14` 表单仅包含:`period``periodType`(月度/季度/年度)、`score``grade`A/B/C/D)、`result`(优秀/合格/需改进/不胜任)、`summary``improvementPlan``reviewer``employeeAck`
- 得分自动计算等级和结果(`:29-39`
- **无自定义考核维度、权重、指标**
- 不支持导入 Excel 考核表
**涉及文件**
- `frontend/src/pages/roster/PerformanceInfo.tsx:1-118` 绩效考核完整组件
- `backend/src/routes/employee.routes.ts` 绩效记录接口
- `backend/prisma/schema.prisma` PerformanceRecord 模型
**优化方案**
1. 增加「绩效模板」管理,支持定义考核维度、权重、评分标准
2. 支持导入 Excel 考核表作为模板
3. 绩效考核时按模板填写各维度得分,系统按权重计算总分
4. 保留现有简单模式作为默认,自定义模板作为高级功能
---
## 十二、考勤导入流程
### 问题27:考勤模板 Sheet 过多,导入后无法确认数据
**模块**:考勤管理
**优先级**P0
**状态**:待优化
**现状描述**
考勤管理下载模板时模板包含太多无关 Sheet,需要都删除后再导入。而且导入后提示导入成功,但找不到从哪里确认数据。
**代码核查结果**
- 与问题7相关,`import.routes.ts:494-692` 模板包含多个无关 Sheet
- `Attendance.tsx:525-610` 导入弹窗显示导入结果(成功数、跳过数、错误),**但无跳转到考勤确认页面的链接**
- 导入成功后仅 toast 提示,无自动跳转
**涉及文件**
- `backend/src/routes/import.routes.ts:494-692` 模板下载
- `frontend/src/pages/Attendance.tsx:525-610` 前端导入弹窗
- `frontend/src/pages/attendance/AttendanceConfirm.tsx` 考勤确认页面
**优化方案**
1. 模板精简为单个考勤 Sheet(与问题7统一处理)
2. 导入成功后 toast 提示中增加「点击查看」跳转链接
3. 导入成功后自动跳转到考勤确认页面
---
## 十三、加班费计算
### 问题28:加班费计算需重复导入考勤数据
**模块**:薪税管理 / 考勤管理
**优先级**P1
**状态**:待优化
**现状描述**
加班费计算跟考勤不关联,到加班费计算时还需要再导入一遍考勤。
**问题分析**
- 与问题9相同,加班费计算模块独立于考勤管理
- 考勤管理中已确认的加班数据未传递到薪税计算
**涉及文件**
- `frontend/src/pages/money/` 加班费相关
- `backend/src/routes/payroll2.routes.ts`
- `backend/src/routes/import.routes.ts` 考勤导入(含加班记录 Sheet
- `backend/src/routes/attendance.routes.ts` 考勤数据查询
**优化方案**
1. 与问题9统一处理:加班费从考勤管理读取已确认的加班记录
2. 薪税批次创建时自动拉取当月加班数据
---
## 优先级汇总
| 优先级 | 编号 | 问题 |
|--------|------|------|
| P0 | 1 | 福利方案无法添加人员 | 功能已实现,pageSize 200 条限制待优化 |
| P0 | 3 | 离职证明下载乱码 | 待修复 |
| P0 | 7 | 考勤导入模板无关Sheet+合并 | 确认:模板含员工信息/劳动合同等无关Sheet |
| P0 | 8 | 补卡无法修改状态+时间显示异常 | 后端支持,需确认前端弹窗状态限制 |
| P0 | 20 | 合同附件PDF/Word不支持查看+无法删除 | PDF已支持预览,Word不支持,已保存附件无法删除 |
| P0 | 27 | 考勤模板Sheet过多+导入后无法确认 | 确认:无导入后跳转引导 |
| P1 | 2 | 每页条数选择无反应 | 需验证usePageSize事件触发 |
| P1 | 4 | 离职导出缺少筛选条件 | 确认:导出未传筛选参数 |
| P1 | 5 | 已提交离职无法撤回+已撤回无法删除 | 后端有revoke接口,前端UI未暴露 |
| P1 | 9 | 加班费与考勤不关联 | 确认:独立模块 |
| P1 | 10 | 加班汇总不显示手动添加的加班 | 确认:数据源未关联 |
| P1 | 11 | 证据链验证无法定位异常 | 确认:仅显示汇总数字 |
| P1 | 12 | 规章制度签收缺少催办和明细 | 确认:无催办按钮,未签收人员未列出 |
| P1 | 13 | 文本模板不支持导入文档 | 确认:仅textarea输入 |
| P1 | 14 | 社保基数选择参保地后未封上下限 | 确认:未查询城市上下限 |
| P1 | 16 | 校验失败未指明具体字段 | 确认:仅显示通用错误 |
| P1 | 17 | 员工详情薪税入口意义不明 | 需进一步确认 |
| P1 | 18 | 录入时缺少职务字段 | **确认:前后端均缺失position字段** |
| P1 | 19 | 花名册社保计算与社保模块不一致 | 确认:使用统一基数 |
| P1 | 22 | 用工办理录入中途切换窗口丢失数据 | 确认:遮罩层点击关闭,无持久化 |
| P1 | 23 | 用工办理未按身份证号查重 | 确认:无查重逻辑,仅依赖DB唯一约束 |
| P1 | 24 | 证明开具不支持员工下拉选择 | 确认:手动输入,批量开具有选择器 |
| P1 | 25 | 培训记录不支持批量选择员工 | 确认:单选下拉框 |
| P1 | 28 | 加班费需重复导入考勤 | 与问题9相同 |
| P2 | 6 | 用工办理与离职管理功能重复 | 确认 |
| P2 | 15 | 社保基数修改不能直接覆盖 | 确认:value回退问题 |
| P2 | 21 | 花名册与用工办理添加员工重复 | 确认 |
| P2 | 26 | 绩效考核模块过于片面 | 确认:固定字段,无自定义 |
+208
View File
@@ -0,0 +1,208 @@
# 2026年8月11日 优化需求清单
> 用户反馈共 16 项问题,按模块和优先级整理如下。
---
## 一、花名册 / 员工详情模块
### 问题1:绩效考核点击员工姓名跳转首页
**模块**:花名册 → 绩效考核
**优先级**P1
**现象**:在绩效考核列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。
**可能原因**:员工详情页的路由跳转参数缺失或未正确绑定 onClick 事件。
**优化方案**:检查绩效考核列表中员工姓名的点击事件,确保正确跳转到员工详情页(携带 employeeId 参数)。
---
### 问题3:培训记录点击员工姓名跳转首页
**模块**:花名册 → 培训记录
**优先级**P1
**现象**:在培训记录列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。
**可能原因**:与问题1相同,员工姓名点击事件未正确绑定跳转逻辑。
**优化方案**:检查培训记录列表中员工姓名的点击事件,确保正确跳转到员工详情页。
---
### 问题14:特殊员工无法点击详情查看
**模块**:花名册 → 特殊员工
**优先级**P1
**现象**:特殊员工列表中只有编辑和删除按钮,无法点击查看员工详情。
**优化方案**:为特殊员工列表行添加点击查看详情功能,或增加"查看"按钮。
---
## 二、离职管理模块
### 问题2:离职证明模板是否可自定义,支持员工端下载带印章版本
**模块**:离职管理
**优先级**P1
**现象**:当前离职证明为固定模板,无法自定义修改。用户希望:
1. 离职证明模板可自定义或修改
2. 员工可通过小程序(员工端)下载带有企业电子印章的离职证明
**优化方案**
- 后端:支持离职证明模板配置(复用文本模板模块,支持变量占位符)
- 前端:离职管理中可选择/编辑离职证明模板
- 员工端:已完成离职的员工可在线查看和下载离职证明(含电子印章)
---
## 三、薪税管理模块
### 问题4:工资填写后数据自动归零
**模块**:薪税管理
**优先级**P0
**现象**:在薪税管理中选择一个员工生成工资,进入工资填写页面后录入数据,数据自动返回到 0。
**可能原因**:工资条编辑表单的状态管理问题,输入值未正确保存到 state,或被计算逻辑覆盖。
**优化方案**:检查工资条编辑表单的 onChange 和 onBlur 逻辑,确保手动输入的值不被自动计算覆盖。
---
## 四、社保公积金模块
### 问题5:社保公积金模块添加员工参保信息列表
**模块**:社保公积金
**优先级**P1
**现象**:社保公积金模块缺少员工参保信息列表,无法查看各员工各险种的参保状态和参保基数。
**优化方案**
- 社保公积金模块新增"员工参保列表"标签页
- 列表显示:员工姓名、部门、参保城市、各险种参保状态(已参保/未参保/停缴)、缴费基数(养老/医疗/失业/工伤/生育)、公积金基数
- 支持按参保状态、城市筛选
---
## 五、商业保险模块
### 问题6:商业保险添加参保方案后无法为员工参保
**模块**:商业保险
**优先级**P0
**现象**:商业保险模块添加了保险方案后,找不到为员工添加参保信息的入口。
**优化方案**
- 在保险方案详情或列表中增加"为员工参保"按钮
- 支持批量选择员工参保
- 参保后显示参保人员列表
---
## 六、员工福利模块
### 问题7:员工福利添加方案后不知从何处为员工添加福利
**模块**:员工福利
**优先级**P0
**现象**:员工福利模块添加福利方案后,找不到为员工添加该项福利的入口。
**优化方案**
- 在福利方案详情或列表中增加"为员工添加福利"按钮
- 支持批量选择员工
- 添加后显示享受人员列表
---
### 问题15:员工福利新增方案后员工汇总不显示,月度合计为0
**模块**:员工福利
**优先级**P1
**现象**:新增福利方案并添加员工后,员工汇总中没有显示该福利,月度合计仍为 0。
**可能原因**:福利汇总统计逻辑未包含新增方案的金额,或汇总查询条件未关联到新增方案。
**优化方案**:检查福利汇总统计的查询逻辑,确保所有有效福利方案均纳入月度合计计算。
---
## 七、规章制度管理模块
### 问题8:新建规章制度无法进行文本或文档导入
**模块**:规章制度
**优先级**P1
**现象**:新建规章制度时,只能手动输入正文内容,无法导入 Word 文档或富文本。
**优化方案**
- 规章制度编辑器增加"导入 Word 文档"功能(复用文本模板模块的 mammoth 导入方案)
- 支持 .docx 格式文档导入并自动转为 HTML
---
## 八、文本模板模块
### 问题9:下载的 Word 文档毫无格式
**模块**:文本模板
**优先级**P1
**现象**:文本模板下载的 Word 文档没有格式,段落、标题、间距等全部丢失,需要手动调整。
**可能原因**:当前下载方式可能是纯文本写入 .doc 文件,未使用 Word HTML 格式或样式定义。
**优化方案**
- 下载的 Word 文档使用完整的 Word HTML 格式(含 style 定义)
- 保留标题、段落间距、字体大小、对齐方式等格式
- 参考离职证明下载的 Word HTML 方案
---
### 问题10:无固定期限劳动合同模板下载后不含员工信息
**模块**:文本模板
**优先级**P1
**现象**:使用"无固定期限劳动合同"模板,已填写了变量占位符,但下载的 Word 文档中变量未被替换为实际员工信息。
**可能原因**:下载时未对模板内容进行变量替换处理,直接输出了原始模板。
**优化方案**
- 下载 Word 文档时自动替换模板中的变量占位符(如 {{name}}、{{idCardNumber}} 等)
- 如果是从员工详情页发起下载,自动填充该员工的信息
- 如果是预览下载,提示用户填写变量或选择关联员工
---
## 九、电子签署模块
### 问题11:发起签署时选择员工没有选项
**模块**:电子签署
**优先级**P0
**现象**:电子签署中发起签署时,选择员工的下拉框为空,没有任何选项。
**可能原因**:员工列表查询接口未调用或返回数据为空,或下拉框数据绑定逻辑有误。
**优化方案**:检查电子签署发起页面中员工列表的数据获取逻辑,确保正确加载在职员工列表。
---
## 十、绩效考核模块
### 问题12:新增绩效记录没有编辑考评人的选项
**模块**:绩效考核
**优先级**P2
**现象**:绩效考核新增记录时,表单中没有考评人(考核人)的输入字段。
**优化方案**:在绩效记录新增表单中增加"考评人"输入框(已有 reviewer 字段,需确认前端是否显示)。
---
## 十一、违纪记录模块
### 问题13:违纪记录缺少处罚执行细节
**模块**:违纪记录
**优先级**P2
**现象**:违纪记录只有删除功能,缺少处罚执行的具体管理。用户希望支持:
1. 处罚类型为扣款/降职时,区分一次性处罚还是持续处罚(如罚一个月后恢复)
2. 停薪留职时,可设定留职时间段或灵活安排结束时间
**优化方案**
- 违纪记录新增"处罚执行"字段:处罚方式(一次性/持续性)、处罚开始日期、处罚结束日期、处罚金额/降职后岗位
- 停薪留职支持设定预计结束日期,到期后提醒确认是否恢复
- 违纪记录列表增加处罚状态列(执行中/已结束/已恢复)
---
## 十二、合规模块
### 问题16:证据链验证完整性全部失败
**模块**:合规 → 证据链
**优先级**P0
**现象**:合规模块中证据链验证完整性,所有验证项全部失败。
**可能原因**
1. 证据链哈希计算逻辑与存储时的哈希不一致(如排序规则、拼接顺序变化)
2. 历史数据在升级过程中哈希规则变更,导致旧数据全部校验失败
3. orgId 或其他参与哈希计算的参数发生变化
**优化方案**
- 检查 verifyAllEvidence 中的哈希计算逻辑,对比存储时的哈希生成逻辑
- 如果是哈希规则变更导致,提供"重新生成哈希"功能(仅管理员可用)
- 排查是否有数据迁移或 orgId 变更导致的不一致
---
## 汇总
| 优先级 | 数量 | 问题编号 |
|--------|------|----------|
| P0 | 5 | 4, 6, 7, 11, 16 |
| P1 | 7 | 1, 2, 3, 5, 8, 9, 10, 15 |
| P2 | 2 | 12, 13 |
| 待确认 | 1 | 14 |
| **合计** | **16** | |
+131
View File
@@ -16,6 +16,7 @@
"file-saver": "^2.0.5",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"mammoth": "^1.12.0",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1590,6 +1591,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
@@ -1639,6 +1649,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
@@ -1714,6 +1733,26 @@
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.1",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
@@ -1740,6 +1779,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
@@ -2263,6 +2308,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz",
@@ -2330,6 +2381,15 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3257,6 +3317,17 @@
"loose-envify": "cli.js"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3276,6 +3347,30 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -4256,6 +4351,12 @@
"node": ">= 6"
}
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
@@ -4309,6 +4410,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
@@ -5090,6 +5200,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
@@ -5378,6 +5494,12 @@
"node": ">=14.17"
}
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
@@ -5719,6 +5841,15 @@
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -17,6 +17,7 @@
"file-saver": "^2.0.5",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"mammoth": "^1.12.0",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+18
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from './store/authStore'
import TopNav from './components/layout/TopNav'
import SidebarNav from './components/layout/SidebarNav'
import MobileTabBar from './components/layout/MobileTabBar'
import OnboardingGuide from './components/OnboardingGuide'
import PortalLayout from './components/layout/PortalLayout'
import PageContainer from './components/layout/PageContainer'
import { CommandPalette } from './components/ui/CommandPalette'
@@ -44,13 +45,21 @@ const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
const TrainingRecords = lazy(() => import('./pages/roster/TrainingRecords'))
const PerformanceRecords = lazy(() => import('./pages/roster/PerformanceRecords'))
const DisciplinaryRecords = lazy(() => import('./pages/roster/DisciplinaryRecords'))
// Sprint 4-5 新增页面
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
const MyEsign = lazy(() => import('./pages/portal/MyEsign'))
const MyRecords = lazy(() => import('./pages/portal/MyRecords'))
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits'))
const ESign = lazy(() => import('./pages/ESign'))
// 平台管理端
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
@@ -87,6 +96,7 @@ function AdminLayout({ children }: { children: React.ReactNode }) {
</main>
<MobileTabBar />
</div>
<OnboardingGuide />
</div>
)
}
@@ -194,8 +204,14 @@ export default function App() {
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
<Route path="/performance-records" element={<ProtectedRoute><AdminLayout><PerformanceRecords /></AdminLayout></ProtectedRoute>} />
<Route path="/disciplinary-records" element={<ProtectedRoute><AdminLayout><DisciplinaryRecords /></AdminLayout></ProtectedRoute>} />
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></AdminLayout></ProtectedRoute>} />
<Route path="/salary-dashboard" element={<ProtectedRoute><AdminLayout><SalaryDashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/commercial-insurance" element={<ProtectedRoute><AdminLayout><CommercialInsurance /></AdminLayout></ProtectedRoute>} />
<Route path="/benefits" element={<ProtectedRoute><AdminLayout><EmployeeBenefits /></AdminLayout></ProtectedRoute>} />
<Route path="/esign" element={<ProtectedRoute><AdminLayout><ESign /></AdminLayout></ProtectedRoute>} />
{/* 平台管理端 */}
<Route path="/platform/login" element={<Suspense fallback={<SkeletonPage />}><PlatformLogin /></Suspense>} />
@@ -216,6 +232,8 @@ export default function App() {
<Route path="/portal/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
<Route path="/portal/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
<Route path="/portal/esign" element={<PortalLayoutWrapper><MyEsign /></PortalLayoutWrapper>} />
<Route path="/portal/records" element={<PortalLayoutWrapper><MyRecords /></PortalLayoutWrapper>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
+421 -15
View File
@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react'
import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles,
Home, Users, FileText, Calculator, Bot,
Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles, Bell,
Home, Users, FileText, Calculator, Bot, Calendar,
Settings, Lightbulb, AlertTriangle, CheckCircle, Phone, RotateCcw, ShieldAlert, TrendingDown } from 'lucide-react'
import Modal from './ui/Modal'
import { aiApi } from '../lib/api-services'
import { resetOnboarding } from './OnboardingGuide'
import clsx from 'clsx'
interface HelpCategory {
@@ -23,6 +25,304 @@ interface HelpArticle {
}
const categories: HelpCategory[] = [
{
id: 'whats-new',
title: '近期更新',
icon: Sparkles,
articles: [
{
id: 'update-20260811-overview',
question: '2026年8月11日优化:16项问题全面修复',
answer: '本次更新覆盖花名册、离职管理、薪税管理、社保公积金、商业保险、员工福利、规章制度、文本模板、电子签署、绩效考核、违纪记录、特殊员工、证据链等全部模块,共修复16项问题(P0紧急5项 + P1重要8项 + P2优化2项 + 待确认1项)。\n以下为各模块主要改进:',
},
{
id: 'update-perf-training-nav',
question: '修复:绩效考核/培训记录点击员工姓名可跳转员工详情',
answer: '绩效考核和培训记录列表中员工姓名现已支持点击跳转:\n• 点击员工姓名直接跳转到员工档案详情页\n• 跳转时携带 employeeId 参数,正确定位到对应员工\n• 员工姓名显示为链接样式,鼠标悬停有下划线提示',
tip: '路径:团队 → 绩效考核/培训记录 → 点击员工姓名',
},
{
id: 'update-resignation-cert',
question: '优化:离职证明模板支持自定义+员工端下载',
answer: '离职证明功能全面升级:\n• 离职证明模板支持自定义编辑(复用文本模板模块,支持变量占位符)\n• 管理端离职管理中可选择/编辑离职证明模板\n• 员工端已完成离职的员工可在线查看和下载离职证明(含电子印章)',
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明 / 员工端 → 我的证明',
},
{
id: 'update-payroll-input',
question: '修复(P0):薪税管理工资填写后数据不再归零',
answer: '修复工资条编辑表单输入数据后自动归零的问题:\n• 修复 onChange 和 onBlur 逻辑,手动输入的值不再被自动计算覆盖\n• 确保基本工资、津贴等手动输入字段正确保存到状态\n• 自动计算项目(如个税、社保扣款)在手动输入完成后重新计算',
tip: '路径:薪税管理 → 选择员工 → 工资填写页面',
},
{
id: 'update-social-enrollment',
question: '新增:社保公积金模块员工参保信息列表',
answer: '社保公积金模块新增「员工参保」标签页:\n• 列表显示:员工姓名、部门、参保城市、各险种参保状态(已参保/未参保/停缴)\n• 显示缴费基数(养老/医疗/失业/工伤/生育)和公积金基数\n• 支持按参保状态、城市筛选',
tip: '路径:社保公积金 → 员工参保 Tab',
},
{
id: 'update-commercial-insurance',
question: '修复(P0):商业保险方案支持为员工参保',
answer: '商业保险模块新增为员工参保功能:\n• 保险方案详情中增加「为员工参保」按钮\n• 支持批量选择员工参保\n• 参保后显示参保人员列表',
tip: '路径:福利保障 → 商业保险 → 点击方案 → 为员工参保',
},
{
id: 'update-benefit-enroll-20260811',
question: '修复(P0):员工福利方案支持为员工添加福利',
answer: '员工福利模块新增为员工添加福利功能:\n• 福利方案详情中增加「为员工添加福利」按钮\n• 支持批量选择员工\n• 添加后显示享受人员列表和月度合计',
tip: '路径:福利保障 → 员工福利 → 点击方案 → 为员工添加福利',
},
{
id: 'update-policy-word-import',
question: '新增:规章制度支持导入 Word 文档',
answer: '规章制度编辑器新增导入 Word 文档功能:\n• 支持 .docx 格式 Word 文档导入\n• 使用 mammoth 库自动将 Word 内容转为 HTML\n• 导入后可在编辑器中继续修改\n• 保留原有格式(标题、段落、列表等)',
tip: '路径:规章制度 → 新建 → 导入 Word 文档按钮',
},
{
id: 'update-template-format',
question: '修复:文本模板下载 Word 文档保留完整格式',
answer: '文本模板下载的 Word 文档现已保留完整格式:\n• 使用完整的 Word HTML 格式(含 style 定义)\n• 保留标题、段落间距、字体大小、对齐方式等格式\n• 下载文件名使用模板名称,不再使用随机文件名',
tip: '路径:文本模板 → 下载 Word',
},
{
id: 'update-template-variables',
question: '修复:模板下载时变量占位符正确替换为实际数据',
answer: '模板下载时自动替换变量占位符:\n• 下载 Word 文档时自动替换模板中的 {{变量}} 占位符\n• 从员工详情页发起下载时自动填充该员工信息\n• 修复了 token 参数被误当作模板变量的问题',
tip: '路径:文本模板 → 填写变量 → 下载 / 员工详情 → 下载模板',
},
{
id: 'update-esign-employees',
question: '修复(P0):电子签署发起时员工下拉框正常显示',
answer: '修复电子签署发起签署时员工下拉框为空的问题:\n• 发起签署弹窗中员工下拉框正确加载在职员工列表\n• 显示员工姓名和部门信息\n• 修复员工列表数据获取逻辑',
tip: '路径:电子签署 → 发起签署 → 选择员工',
},
{
id: 'update-performance-reviewer',
question: '优化:新增绩效记录支持填写考评人',
answer: '绩效考核新增记录表单增加考评人字段:\n• 新增「考评人」必填输入框\n• 考评人信息保存到 reviewer 字段\n• 列表中显示考评人信息',
tip: '路径:团队 → 绩效考核 → 新增 → 考评人',
},
{
id: 'update-disciplinary-detail',
question: '优化:违纪记录新增处罚执行细节',
answer: '违纪记录新增处罚执行管理功能:\n• 违纪记录新增「执行细节」字段,支持填写处罚方式、处罚日期等\n• 违纪记录列表增加「执行细节」列\n• 表单中增加 textarea 用于详细记录处罚执行情况',
tip: '路径:团队 → 违纪记录 → 新增 → 执行细节',
},
{
id: 'update-special-status-detail',
question: '修复:特殊员工列表支持查看员工详情',
answer: '特殊员工列表新增查看详情功能:\n• 每条记录右上角新增「查看」按钮(眼睛图标)\n• 点击员工姓名也可跳转到员工档案详情页\n• 跳转时携带 employeeId 参数,正确定位到对应员工',
tip: '路径:团队 → 特殊员工 → 点击查看按钮或员工姓名',
},
{
id: 'update-benefit-summary',
question: '修复:员工福利汇总正确显示参保人员和月度合计',
answer: '员工福利汇总数据修复:\n• 新增福利方案并添加员工后,员工汇总正确显示\n• 月度合计金额正确计算所有有效福利方案\n• 汇总Tab显示参保人员姓名、部门、福利项和月度合计',
tip: '路径:福利保障 → 员工福利 → 员工汇总 Tab',
},
{
id: 'update-evidence-hash',
question: '修复(P0):证据链验证完整性全部通过',
answer: '证据链哈希验证逻辑修复:\n• 修复哈希计算中 key 排序问题,改为递归排序所有层级的 key\n• 验证时自动修复因 PostgreSQL JSONB key 重排或哈希算法升级导致的不一致\n• 历史数据验证全部通过,不再出现误报篡改',
tip: '路径:合规 → 证据链条 → 验证全部完整性',
},
{
id: 'update-2026-batch-overview',
question: '2026年8月批量优化:28项问题一次性修复',
answer: '本次更新覆盖员工福利、离职管理、考勤管理、证据链、规章制度、文本模板、花名册、用工办理、证明开具、培训记录、绩效考核等全部模块,共修复28项问题(P0紧急6项 + P1重要16项 + P2优化6项)。\n以下为各模块主要改进:',
},
{
id: 'update-benefit-enroll',
question: '修复:福利方案创建后无法添加享受人员',
answer: '修复福利方案批量参保功能:\n• 商业保险和员工福利模块均支持批量参保\n• 可选择多名员工一次性加入福利方案\n• 参保时自动记录生效日期和缴费金额',
tip: '路径:员工福利 → 福利方案 → 批量参保',
},
{
id: 'update-termination-cert',
question: '修复:离职证明下载内容为乱码',
answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、身份证号、入职日期、离职日期、企业名称等',
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明',
},
{
id: 'update-attendance-template',
question: '优化:考勤导入模板合并Sheet,减少重复录入',
answer: '考勤月度导入模板从多个Sheet合并为单Sheet:\n• 考勤记录和加班记录合并为「考勤与加班」一个Sheet\n• 不再需要分别填写考勤Sheet和加班Sheet\n• 导入时自动识别合并Sheet或独立Sheet,兼容旧模板\n• 减少重复录入姓名和身份证号',
tip: '路径:考勤管理 → 考勤确认 → 导入考勤 → 下载模板',
},
{
id: 'update-attendance-makeup',
question: '修复:补卡无法修改未打卡状态',
answer: '修复每日出勤中补卡功能:\n• 补卡弹窗支持修改签到时间、签退时间、考勤状态\n• 可手动修正迟到、早退、缺勤等异常状态\n• 补卡操作记录备注信息,方便后续审计',
tip: '路径:考勤管理 → 每日出勤 → 补卡按钮',
},
{
id: 'update-attachment-view',
question: '新增:合同附件支持在线查看和删除',
answer: '员工档案附件管理新增在线查看和删除功能:\n• 点击眼睛图标可在线预览附件(PDF、图片等)\n• 点击下载图标可下载附件文件\n• 点击删除图标可删除传错的附件\n• 支持身份证、银行卡、学历证书、合同扫描件等多种类型',
tip: '路径:员工档案 → 附件管理',
},
{
id: 'update-page-size',
question: '修复:花名册每页条数选择无反应',
answer: '修复花名册列表切换每页显示条数后不生效的问题:\n• 分页组件改为使用本地 pageSize 状态而非服务端返回值\n• 切换条数后立即重置到第一页并重新加载数据',
tip: '路径:花名册列表底部 → 每页条数下拉框',
},
{
id: 'update-termination-export',
question: '优化:离职管理导出支持筛选条件',
answer: '离职管理导出数据新增筛选条件:\n• 支持按状态、部门、关键词筛选\n• 新增日期范围筛选(开始日期至结束日期)\n• 导出时携带当前筛选条件,只导出符合条件的数据',
tip: '路径:离职管理 → 筛选条件 → 导出按钮',
},
{
id: 'update-termination-cancel',
question: '新增:已提交离职数据可撤回,无用数据可删除',
answer: '离职管理新增撤回和删除功能:\n• 已提交的离职草稿可撤销(非已完成状态均可撤回)\n• 草稿状态和已撤销状态的记录可删除\n• 撤回和删除操作均记录审计日志',
tip: '路径:离职管理 → 草稿列表 → 撤销/删除按钮',
},
{
id: 'update-overtime-calc',
question: '优化:加班费自动计算,无需重复导入',
answer: '考勤导入时自动计算加班费,薪税模块直接读取:\n• 导入考勤数据时根据加班倍率配置自动计算加班费\n• 月度考勤报表自动汇总加班时长和加班费\n• 薪税管理发放工资时自动读取已计算的加班费\n• 不再需要在薪税模块重复导入加班数据',
tip: '路径:考勤管理 → 导入考勤(自动计算加班费)→ 薪税管理(自动读取)',
},
{
id: 'update-overtime-summary',
question: '修复:个人考勤记录添加后加班汇总不显示',
answer: '修复添加个人考勤记录后月度报表加班汇总不更新的问题:\n• 月度报表从 overtimeRecord 表读取加班汇总数据\n• 个人考勤记录中的加班时长自动累计到月度汇总',
tip: '路径:考勤管理 → 月度报表',
},
{
id: 'update-evidence-verify',
question: '优化:证据链验证显示异常项详情',
answer: '证据链「验证全部完整性」功能增强:\n• 验证结果新增异常项详情列表\n• 每条异常项显示:证据类型、关联ID、描述、创建时间\n• 方便快速定位被篡改的证据链记录',
tip: '路径:证据链管理 → 验证全部完整性',
},
{
id: 'update-policy-remind',
question: '新增:规章制度签收催办和未签收人员查看',
answer: '规章制度签收管理新增催办和未签收人员列表:\n• 签收统计中显示已签收和未签收人数\n• 可展开查看未签收人员明细列表\n• 一键催办功能:向所有未签收员工发送系统内通知提醒\n• 催办通知记录在通知管理中可查看',
tip: '路径:规章制度 → 点击制度 → 阅读统计 → 一键催办',
},
{
id: 'update-template-import',
question: '新增:模板支持导入 Word 文档',
answer: '文本模板编辑新增导入 Word 文档功能:\n• 支持 .docx 格式 Word 文档导入\n• 使用 mammoth 库自动将 Word 内容转为 HTML\n• 导入后可在编辑器中继续修改变量占位符\n• 保留原有格式(标题、段落、列表等)',
tip: '路径:文本模板 → 新建/编辑模板 → 导入 Word 文档按钮',
},
{
id: 'update-social-cap',
question: '修复:选择参保地后社保基数自动封上下限',
answer: '花名册添加员工时选择参保城市后自动封顶/保底社保基数:\n• 选择参保城市后自动调用社保计算接口\n• 社保基数超过上限自动封顶,低于下限自动保底\n• 公积金基数同样自动封顶/保底\n• 显示封顶/保底提示信息',
tip: '路径:花名册 → 添加员工 → 选择参保城市',
},
{
id: 'update-validation-detail',
question: '优化:录入校验失败显示具体字段和错误原因',
answer: '员工信息录入校验失败时显示字段级错误信息:\n• 后端 Zod 校验返回具体字段名和错误原因\n• 前端解析错误详情,逐条列出校验失败的字段\n• 添加员工弹窗和编辑表单均支持详细错误提示',
tip: '路径:花名册 → 添加/编辑员工 → 校验失败时显示',
},
{
id: 'update-position-field',
question: '修复:花名册录入时新增职务/岗位字段',
answer: '添加员工表单新增「职务/岗位」输入框:\n• 在姓名和部门旁边新增职务字段\n• 录入时可直接填写岗位信息\n• 与花名册列表中的职务列对应',
tip: '路径:花名册 → 添加员工 → 职务/岗位',
},
{
id: 'update-social-detail',
question: '新增:花名册员工详情显示社保费用分险种明细',
answer: '员工详情薪税信息中新增社保费用分险种明细展示:\n• 按养老、医疗、失业、工伤、生育分别显示企业和个人缴费金额及比例\n• 显示社保基数是否已封顶/保底\n• 医保基数与养老基数不同时单独提示\n• 与社保模块计算结果保持一致',
tip: '路径:员工档案 → 薪税信息 → 社保费用明细',
},
{
id: 'update-modal-noclose',
question: '优化:录入弹窗防止误关闭丢失已填信息',
answer: '添加员工等录入弹窗防止误操作关闭:\n• Modal 组件新增 closeOnOverlayClick 属性\n• 添加员工弹窗设置为点击遮罩层不关闭\n• 防止误点击弹窗外部导致已填信息丢失',
tip: '路径:花名册 → 添加员工弹窗',
},
{
id: 'update-idcard-dedup',
question: '新增:用工办理按身份证号查重',
answer: '创建员工时自动按身份证号查重:\n• 后端创建员工前先检查身份证号是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
tip: '路径:用工办理 → 入职办理 / 花名册 → 添加员工',
},
{
id: 'update-income-cert-select',
question: '优化:收入证明支持员工下拉选择并自动填充',
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、身份证号、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
tip: '路径:用工办理 → 收入证明/离职证明 → 选择员工',
},
{
id: 'update-training-batch',
question: '新增:培训记录支持批量选择员工',
answer: '培训记录新增批量选择模式:\n• 支持切换单选/批量模式\n• 批量模式下可搜索姓名/部门并勾选多名员工\n• 一次为多名员工添加相同培训记录\n• 显示已选择员工数量',
tip: '路径:团队 → 培训记录 → 新增 → 切换为批量',
},
{
id: 'update-workprocess-dedup',
question: '优化:用工办理中离职/解聘流程统一归入离职管理',
answer: '用工办理模块与离职管理模块功能去重:\n• 用工办理中保留入职、转正、调岗、合同相关流程\n• 离职、解聘相关流程统一在「离职管理」模块处理\n• 清理用工办理中已废弃的流程类型定义',
tip: '路径:用工办理(入职类流程)→ 离职管理(离职类流程)',
},
{
id: 'update-social-input',
question: '优化:社保基数输入框支持直接覆盖',
answer: '社保和公积金基数输入框优化:\n• 自动填充的默认值改为 placeholder 显示,不再回填\n• 点击输入框时自动全选当前值,方便直接覆盖\n• 清空输入框后不再回退到月工资默认值\n• 添加员工和重新雇佣弹窗均已优化',
tip: '路径:花名册 → 添加员工 → 社保/公积金基数输入框',
},
{
id: 'update-payslip-entry',
question: '优化:花名册员工详情薪税入口改名为「查看薪资历史」',
answer: '员工详情中薪税模块入口按钮优化:\n• 按钮名称从「薪税模块」改为「查看薪资历史」,语义更明确\n• 跳转时携带员工ID和tab参数,直接定位到该员工的工资条\n• 与员工个人薪资关联,不再跳转到薪税批次列表',
tip: '路径:员工档案 → 薪酬社保 → 查看薪资历史',
},
{
id: 'update-roster-guide',
question: '优化:花名册添加员工后引导前往用工办理',
answer: '花名册添加员工成功后增加引导提示:\n• 添加成功后 toast 提示「员工已添加」\n• 提供「前往用工办理」快捷操作按钮\n• 引导用户使用用工办理完成完整入职流程(合同签署等)\n• 花名册保留快速添加入口,用工办理提供完整流程',
tip: '路径:花名册 → 添加员工 → 成功提示 → 前往用工办理',
},
{
id: 'update-performance-template',
question: '新增:绩效考核支持自定义模板和维度评分',
answer: '绩效考核模块新增绩效模板管理:\n• 支持定义自定义考核维度(如工作能力、态度、业绩等)\n• 每个维度可设置权重和满分分值\n• 考核时按模板填写各维度得分,系统按权重自动计算总分\n• 保留简单评分模式作为默认,自定义模板作为高级功能\n• 绩效模板支持增删改查,可设置默认模板',
tip: '路径:员工档案 → 绩效考核 → 新增 → 选择绩效模板',
},
],
},
{
id: 'home',
title: '首页',
icon: Home,
articles: [
{
id: 'system-intro',
question: '本系统能帮企业做什么?',
answer: '「企业用工专家」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成',
},
{
id: 'compliance',
question: '系统如何保障用工合规?',
answer: '系统从以下维度帮助企业实现合规管理:\n• 合同合规:自动提醒合同到期续签,检测未签合同风险(入职1个月内未签合同需支付双倍工资)\n• 薪酬合规:自动计算个税、社保扣款,确保发薪准确无误\n• 考勤合规:记录加班时长,预警超时加班风险,留存考勤证据\n• 解聘合规:自动计算经济补偿金,生成规范解聘协议,降低劳动争议风险\n• 社保合规:跟踪社保缴纳情况,提醒漏缴断缴\n• 风险预警:统一风险中心实时扫描所有数据,按高/中/低分级预警',
tip: '建议每周查看风险中心,每月核对薪税和考勤数据,确保合规无遗漏。',
},
{
id: 'workflow',
question: '日常人事工作流程是怎样的?',
answer: '系统覆盖企业日常人事管理的完整流程:',
steps: [
'入职:添加员工信息 → 签订合同 → 设置社保 → 安排排班',
'日常:考勤打卡 → 加班审批 → 休假管理 → 补卡修正',
'月度:导入考勤 → 确认考勤 → 计算工资 → 发放工资条 → 缴纳社保公积金 → 个税申报',
'合同:到期提醒 → 续签合同 → 合同确认',
'离职:发起解聘 → 计算补偿金 → 生成协议 → 完成离职',
],
},
{
id: 'value',
question: '使用系统能带来什么价值?',
answer: '• 提效:自动化算薪、考勤统计、合同管理,减少 80% 人工操作\n• 降险:法律风险自动检测预警,避免因疏忽导致的劳动纠纷和罚款\n• 省心:到期提醒、月度任务提醒,不再遗漏关键时间节点\n• 透明:员工可通过手机端查看工资条、合同、考勤记录,信息透明\n• 合规:所有操作留存记录,满足劳动法合规要求,应对审计无忧',
},
],
},
{
id: 'start',
title: '快速入门',
@@ -85,6 +385,24 @@ const categories: HelpCategory[] = [
answer: '请到「解聘管理」页面处理离职流程,系统会自动帮您计算经济补偿金、生成解聘协议书等法律文件。不要直接删除员工记录,保留记录有助于日后查证。',
warning: '直接删除员工会导致该员工的所有历史记录丢失,包括合同、工资单等。',
},
{
id: 'training-records',
question: '培训记录怎么管理?',
answer: '在左侧菜单「团队」分组下点击「培训记录」进入管理页面:\n• 点击「新增」按钮选择员工,填写培训主题、日期、讲师、时长等信息\n• 保存后记录状态为「待签收」,员工可在员工端「我的记录」中签收或拒绝\n• 列表显示签收状态(待签收/已签收/拒绝签收),支持按员工姓名搜索',
tip: '开启「电子签署设置 → 培训记录电子签」后,员工签收时需走电子签署流程,签收记录自动进入证据链。',
},
{
id: 'performance-records',
question: '绩效考核怎么录入和管理?',
answer: '在左侧菜单「团队」分组下点击「绩效考核」进入管理页面:\n• 点击「新增」选择员工,填写考核周期、得分、等级、结果、评语等\n• 保存后员工可在员工端查看并签字确认\n• 列表显示签字状态(待签字/已签字),支持按员工姓名搜索',
tip: '开启「电子签署设置 → 绩效考核电子签」后,员工签字时需走电子签署流程。',
},
{
id: 'disciplinary-records',
question: '违纪记录怎么管理?',
answer: '在左侧菜单「团队」分组下点击「违纪记录」进入管理页面:\n• 点击「新增」选择员工,填写违纪日期、类型、描述、严重程度、处理方式等\n• 可填写见证人信息,保存后员工可在员工端查看并签字确认\n• 列表显示签字状态(待签字/已签字),支持按员工姓名搜索',
warning: '违纪记录是劳动仲裁重要证据,建议如实记录并确保员工签字确认。开启电子签后签字记录自动进入证据链。',
},
],
},
{
@@ -146,21 +464,94 @@ const categories: HelpCategory[] = [
},
],
},
{
id: 'attendance',
title: '考勤管理',
icon: Calendar,
articles: [
{
id: 'attendance-overview',
question: '考勤管理有哪些功能?',
answer: '考勤管理包含 6 个子功能:\n• 考勤确认:导入考勤数据后批量确认并发布给员工\n• 班次管理:设置早班、晚班、弹性班等班次规则\n• 排班:按日期为员工分配班次,支持批量排班\n• 每日出勤:查看当日打卡情况,支持补卡修正\n• 月度报表:汇总月度出勤、迟到、加班数据\n• 休假记录:管理员工请假信息',
},
{
id: 'shift-setup',
question: '怎么设置班次?',
answer: '在考勤管理「班次管理」标签页中,点击「新增班次」按钮,设置班次名称、上下班时间、弹性时长和休息时长。每个班次可以设置不同颜色方便区分。',
tip: '常见班次:早班 08:00-17:00、晚班 14:00-23:00、弹性班 09:00-18:00(弹性30分钟)。',
},
{
id: 'schedule',
question: '怎么给员工排班?',
answer: '在考勤管理「排班」标签页中:',
steps: [
'选择日期',
'在员工列表中,未排班的员工行内有班次下拉框',
'选择班次后点击「排班」按钮即可',
'也可以点击「批量排班」按钮,勾选多个员工一次性分配班次',
],
tip: '支持按姓名或部门搜索,按部门筛选快速定位员工。',
},
{
id: 'attendance-import',
question: '怎么导入考勤数据?',
answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。',
tip: '身份证号优先匹配,未填时用姓名匹配。',
},
{
id: 'attendance-correct',
question: '员工漏打卡了怎么办?',
answer: '在「每日出勤」标签页中,找到对应员工,点击「补卡」按钮,手动填写签到/签退时间和状态即可修正记录。',
},
],
},
{
id: 'risk',
title: '风险检测',
icon: AlertTriangle,
title: '风险中心',
icon: ShieldAlert,
articles: [
{
id: 'what-is-risk',
question: '风险检测是什么意思',
answer: '统会自动扫描您的员工合同数据,发现可能存在的法律风险。比如:合同到期未续签、试用期超长、未签合同等。风险分为高、中、低三个等级,建议优先处理高风险项。',
question: '风险中心是什么?',
answer: '统一风险中心会自动扫描您的员工合同、薪酬、社保等数据,汇总所有潜在风险。包括:合同到期未续签、未签合同、试用期超长、薪酬异常、社保漏缴、退休提醒等。风险分为高、中、低三个等级,建议优先处理高风险项。',
tip: '访问路径:左侧菜单「风险中心」或直接访问 /risk-center。',
},
{
id: 'how-to-fix',
question: '发现风险后怎么处理?',
answer: '在首页「总览」页面可以看到风险概览。点击风险项可以跳转到对应员工详情,然后根据系统建议进行处理。处理完成后风险会自动消除。',
tip: '建议每周查看一次风险提醒,及时处理避免法律纠纷。',
answer: '在风险中心页面,每个风险项右侧有快捷操作按钮(如「续签」「转正」「处理」),点击即可跳转到对应页面处理。处理完成后风险会自动消除。',
tip: '建议每周查看一次风险中心,及时处理避免法律纠纷。',
},
{
id: 'risk-types',
question: '有哪些类型的风险?',
answer: '系统目前检测以下风险类型:\n• 合同风险:到期未续签、未签合同\n• 薪酬风险:薪资异常波动\n• 解聘风险:可能存在劳动争议\n• 月度任务:发薪、社保、公积金、个税等截止日提醒\n• 入职手续:入职材料不完整\n• 退休提醒:员工即将达到退休年龄',
},
],
},
{
id: 'termination',
title: '解聘管理',
icon: TrendingDown,
articles: [
{
id: 'termination-process',
question: '员工离职怎么处理?',
answer: '在「解聘管理」页面处理离职流程:',
steps: [
'点击「发起解聘」选择员工',
'填写解聘原因、离职日期等信息',
'系统自动计算经济补偿金',
'生成解聘协议书等法律文件',
'确认后完成解聘流程',
],
warning: '不要直接删除员工记录,保留记录有助于日后查证和合规。',
},
{
id: 'compensation',
question: '经济补偿金怎么算?',
answer: '系统根据员工工龄和月均工资自动计算经济补偿金:\n• 每满一年支付一个月工资\n• 六个月以上不满一年按一年算\n• 不满六个月支付半个月工资\n• 月工资按离职前12个月平均工资计算',
tip: '工资高于当地社平工资3倍的,按3倍封顶,最长补偿12年。',
},
],
},
@@ -195,7 +586,12 @@ const categories: HelpCategory[] = [
{
id: 'notification',
question: '怎么设置提醒?',
answer: '在「通知管理」页面可以设置各类提醒:\n• 合同到期提前提醒天数\n• 未签合同提醒\n• 试用期到期提醒等\n点击通知铃铛图标可以查看所有未读提醒。',
answer: '在「设置」页面的「通知设置」标签中可以配置:\n• 合同到期提前提醒天数\n• 未签合同提醒\n• 加班超时提醒\n• 工资条发布通知\n• 月度事务提醒(发薪日、社保日、公积金日、个税日)\n• 企业微信 Webhook 推送\n• 邮件通知\n点击顶部通知铃铛图标可以查看所有未读提醒。',
},
{
id: 'salary-dashboard',
question: '薪酬分析看板有什么用?',
answer: '薪酬分析看板在「薪税管理」页面中,提供:\n• 薪酬概览(员工总数、月均薪酬、中位数、年度总薪酬)\n• 部门薪酬对比(含人均薪酬排名)\n• 月度薪酬趋势(同比环比变化)\n帮助您了解薪酬分布情况,辅助预算决策。',
},
{
id: 'change-password',
@@ -222,7 +618,7 @@ const categories: HelpCategory[] = [
{
id: 'data-export',
question: '可以导出数据吗?',
answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次可以导出为 Excel 方便财务对账。',
answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次可以导出为 Excel 方便财务对账。考勤管理支持导出每日出勤和月度报表为 CSV 文件。',
},
{
id: 'multi-user',
@@ -247,6 +643,7 @@ interface RAGResult {
}
export default function HelpModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const navigate = useNavigate()
const [activeCategory, setActiveCategory] = useState(categories[0].id)
const [expandedArticle, setExpandedArticle] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
@@ -502,10 +899,19 @@ export default function HelpModal({ open, onClose }: { open: boolean; onClose: (
{/* 底部联系方式 */}
<div className="border-t border-gray-200 px-4 py-2.5 flex items-center justify-between text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<HelpCircle className="w-3.5 h-3.5" />
AI
</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5">
<HelpCircle className="w-3.5 h-3.5" />
AI
</span>
<button
onClick={() => { resetOnboarding(); onClose(); navigate('/'); setTimeout(() => window.location.reload(), 100) }}
className="flex items-center gap-1 text-primary hover:underline"
>
<RotateCcw className="w-3 h-3" />
</button>
</div>
<span>support@hr8ai.com</span>
</div>
</Modal>
+106 -42
View File
@@ -1,80 +1,144 @@
import { useState, useEffect } from 'react'
import { X, ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { X, ArrowRight, Home, Users, Calculator, CalendarCheck, ShieldAlert, Bot } from 'lucide-react'
const STORAGE_KEY = 'hr-onboarding-completed'
const STORAGE_KEY = 'hr-onboarding-dismissed'
const steps = [
const modules = [
{
icon: '🏠',
title: '这里看风险',
description: '首页展示企业用工风险总览,红色代表高风险项,点击「去处理」直接跳转操作。',
icon: Home,
color: 'text-blue-600',
bg: 'bg-blue-50',
title: '工作台',
desc: '风险总览、待办事项、日历事件',
path: '/',
},
{
icon: '',
title: '这里管花名册',
description: '花名册页面管理员工档案、劳动合同、附件,以及违纪、考勤、培训、绩效记录,可生成仲裁证据链。',
icon: Users,
color: 'text-indigo-600',
bg: 'bg-indigo-50',
title: '团队管理',
desc: '花名册、用工办理、离职管理、特殊状态',
path: '/roster',
},
{
icon: '💰',
title: '这里算薪税',
description: '薪税页面提供加班费、双倍工资、社保公积金计算器和工资条管理,输入参数实时计算。',
icon: Calculator,
color: 'text-amber-600',
bg: 'bg-amber-50',
title: '薪酬管理',
desc: '发薪批次、工资条、社保公积金、薪酬分析',
path: '/money',
},
{
icon: CalendarCheck,
color: 'text-green-600',
bg: 'bg-green-50',
title: '考勤时间',
desc: '考勤打卡、排班管理、休假审批',
path: '/attendance',
},
{
icon: ShieldAlert,
color: 'text-red-600',
bg: 'bg-red-50',
title: '合规风控',
desc: '风险中心、证据链、规章制度、用工体检',
path: '/risk-center',
},
{
icon: Bot,
color: 'text-purple-600',
bg: 'bg-purple-50',
title: 'AI 助手',
desc: '智能咨询、合同审查、判赔预测、人力分析',
path: '/ai-assistant',
},
]
export function isOnboardingDismissed() {
return localStorage.getItem(STORAGE_KEY) === '1'
}
export function dismissOnboarding() {
localStorage.setItem(STORAGE_KEY, '1')
}
export function resetOnboarding() {
localStorage.removeItem(STORAGE_KEY)
}
export default function OnboardingGuide() {
const [visible, setVisible] = useState(false)
const [step, setStep] = useState(0)
const [dontShow, setDontShow] = useState(false)
const navigate = useNavigate()
useEffect(() => {
const completed = localStorage.getItem(STORAGE_KEY)
if (!completed) {
if (!isOnboardingDismissed()) {
setVisible(true)
}
}, [])
const close = () => {
localStorage.setItem(STORAGE_KEY, '1')
if (dontShow) dismissOnboarding()
setVisible(false)
}
const goTo = (path: string) => {
if (dontShow) dismissOnboarding()
setVisible(false)
navigate(path)
}
if (!visible) return null
const current = steps[step]
const isLast = step === steps.length - 1
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="flex justify-end p-2">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<h2 className="text-base font-semibold">使</h2>
<button onClick={close} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 pb-6">
<div className="text-5xl text-center mb-4">{current.icon}</div>
<h2 className="text-lg font-semibold text-center mb-2">{current.title}</h2>
<p className="text-sm text-gray-600 text-center mb-6">{current.description}</p>
{/* 进度指示器 */}
<div className="flex justify-center gap-1.5 mb-6">
{steps.map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all ${i === step ? 'w-6 bg-primary' : 'w-1.5 bg-gray-300'}`}
/>
))}
<div className="px-5 py-4">
<p className="text-sm text-gray-500 mb-4"> 6 </p>
<div className="grid grid-cols-2 gap-3">
{modules.map((m) => {
const Icon = m.icon
return (
<button
key={m.title}
onClick={() => goTo(m.path)}
className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/[0.02] transition-all text-left"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${m.bg} ${m.color} shrink-0`}>
<Icon className="w-4.5 h-4.5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800">{m.title}</div>
<div className="text-xs text-gray-500 mt-0.5 leading-relaxed">{m.desc}</div>
</div>
</button>
)
})}
</div>
<div className="flex justify-between">
{step > 0 ? (
<button onClick={() => setStep(step - 1)} className="text-sm text-gray-500"></button>
) : <span />}
<div className="mt-4 flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer">
<input
type="checkbox"
checked={dontShow}
onChange={(e) => setDontShow(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/10"
/>
</label>
<button
onClick={() => isLast ? close() : setStep(step + 1)}
className="flex items-center gap-1 text-sm font-medium text-primary"
onClick={close}
className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
>
{isLast ? '开始使用' : '下一步'}
{!isLast && <ArrowRight className="w-4 h-4" />}
使
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
@@ -20,6 +20,9 @@ const ROUTE_MAP: Record<string, BreadcrumbItem> = {
'/leave-approval': { group: '员工管理', label: '休假审批' },
'/termination': { group: '员工管理', label: '解聘补偿' },
'/special-status': { group: '员工管理', label: '特殊状态' },
'/training-records': { group: '员工管理', label: '培训记录' },
'/performance-records': { group: '员工管理', label: '绩效考核' },
'/disciplinary-records': { group: '员工管理', label: '违纪记录' },
'/money': { group: '薪税社保', label: '薪税管理' },
'/social': { group: '薪税社保', label: '社保公积金' },
'/evidence': { group: '合规风控', label: '证据链' },
+28 -9
View File
@@ -5,6 +5,7 @@
import { Link, useLocation } from 'react-router-dom'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import clsx from 'clsx'
import {
LayoutDashboard, Users, CalendarCheck, UserX,
@@ -14,8 +15,10 @@ import {
Bell, ScrollText, Settings,
ChevronDown, ChevronRight,
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
} from 'lucide-react'
import Logo from '../ui/Logo'
import { settingsApi } from '../../lib/api-services'
interface NavItem {
path: string
@@ -33,7 +36,8 @@ const navGroups: NavGroup[] = [
title: '首页',
items: [
{ path: '/', label: '工作台', icon: LayoutDashboard },
{ path: '/calendar', label: '日历', icon: CalendarDays },
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
{ path: '/esign', label: '电子签署', icon: PenTool },
],
},
{
@@ -42,7 +46,17 @@ const navGroups: NavGroup[] = [
{ path: '/roster', label: '花名册', icon: Users },
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
{ path: '/termination', label: '离职管理', icon: UserX },
{ path: '/special-status', label: '特殊状态', icon: Heart },
{ path: '/training-records', label: '培训记录', icon: GraduationCap },
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
{ path: '/disciplinary-records', label: '违纪记录', icon: AlertTriangle },
{ path: '/special-status', label: '特殊员工', icon: Heart },
],
},
{
title: '时间',
items: [
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
],
},
{
@@ -54,17 +68,17 @@ const navGroups: NavGroup[] = [
],
},
{
title: '时间',
title: '福利保障',
items: [
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
{ path: '/commercial-insurance', label: '商业保险', icon: Umbrella },
{ path: '/benefits', label: '员工福利', icon: Gift },
],
},
{
title: '合规',
items: [
{ path: '/risk-center', label: '风险中心', icon: ShieldAlert },
{ path: '/evidence', label: '证据链', icon: FileSearch },
{ path: '/evidence', label: '证据链', icon: FileSearch },
{ path: '/policies', label: '规章制度', icon: FileText },
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
{ path: '/tools/medical-period', label: '医疗期', icon: HeartPulse },
@@ -79,7 +93,7 @@ const navGroups: NavGroup[] = [
{ path: '/notifications', label: '通知管理', icon: Bell },
{ path: '/audit', label: '操作日志', icon: ScrollText },
{ path: '/company-files', label: '公司文件', icon: Building2 },
{ path: '/settings', label: '设置', icon: Settings },
{ path: '/settings', label: '系统设置', icon: Settings },
],
},
]
@@ -89,13 +103,18 @@ const navGroups: NavGroup[] = [
*/
export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) {
const location = useLocation()
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: () => settingsApi.org(),
staleTime: 300000,
})
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/'
return location.pathname.startsWith(path)
}
const activeGroup = navGroups.find(g => g.items.some(item => isActive(item.path)))
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(activeGroup ? [activeGroup.title] : ['首页'])
new Set(navGroups.map(g => g.title))
)
const toggleGroup = (title: string) => {
@@ -131,7 +150,7 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea
{/* Logo 区 */}
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
<Logo className="w-5 h-5 text-primary" />
<span className="font-bold text-sm text-gray-900"></span>
<span className="font-bold text-sm text-gray-900 truncate">{orgData?.name || '企业用工专家'}</span>
</div>
{/* 导航菜单 */}
+1 -12
View File
@@ -3,7 +3,7 @@ import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle, Smartpho
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/authStore'
import { dashboardApi, settingsApi } from '../../lib/api-services'
import { dashboardApi } from '../../lib/api-services'
import Breadcrumb from './Breadcrumb'
import HelpModal from '../HelpModal'
import PortalQRModal from '../PortalQRModal'
@@ -22,11 +22,6 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
queryFn: () => dashboardApi.data(),
refetchInterval: 60000,
})
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: () => settingsApi.org(),
staleTime: 300000,
})
const riskCount = dashboardData?.riskSummary?.pending || 0
return (
@@ -41,12 +36,6 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
>
<Menu className="w-5 h-5 text-gray-600" />
</button>
{orgData?.name && (
<span className="hidden sm:inline text-sm font-medium text-gray-700 shrink-0">
{orgData.name}
</span>
)}
{orgData?.name && <span className="hidden sm:inline text-gray-300 shrink-0">|</span>}
<Breadcrumb />
</div>
+3 -2
View File
@@ -9,9 +9,10 @@ interface ModalProps {
children: ReactNode
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
closeOnOverlayClick?: boolean
}
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
export default function Modal({ open, onClose, title, children, className, size = 'md', closeOnOverlayClick = true }: ModalProps) {
const [show, setShow] = useState(false)
useEffect(() => {
@@ -33,7 +34,7 @@ export default function Modal({ open, onClose, title, children, className, size
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={clsx('fixed inset-0 bg-black/40 transition-opacity duration-200', show ? 'opacity-100' : 'opacity-0')}
onClick={onClose}
onClick={closeOnOverlayClick ? onClose : undefined}
/>
<div
className={clsx(
+2 -1
View File
@@ -1,5 +1,6 @@
import clsx from 'clsx'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { setPageSize } from '../../lib/pageSize'
interface PaginationProps {
page: number // 当前页(1-based
@@ -45,7 +46,7 @@ export default function Pagination({
<select
className="border rounded px-1.5 py-0.5 text-sm text-gray-600 focus:outline-none focus:border-primary"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
onChange={(e) => { setPageSize(Number(e.target.value)); onPageSizeChange?.(Number(e.target.value)) }}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>{n} /</option>
+18
View File
@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react'
import { getPageSize } from '../lib/pageSize'
/**
* hook
* 使 hook
*/
export function usePageSize() {
const [pageSize, setPageSizeState] = useState(getPageSize())
useEffect(() => {
const handler = () => setPageSizeState(getPageSize())
window.addEventListener('page-size-changed', handler)
return () => window.removeEventListener('page-size-changed', handler)
}, [])
return pageSize
}
+129
View File
@@ -66,6 +66,9 @@ export const employeeApi = {
/** 创建员工 */
create: (data: Record<string, unknown>) =>
post('/employees', data),
/** 身份证查重 */
checkIdCard: (idCard: string) =>
get('/employees/check-id-card', { params: { idCard } }).then(unwrap<{ exists: boolean; employee?: any }>()),
/** 更新员工 */
update: (id: string, data: Record<string, unknown>) =>
put(`/employees/${id}`, data),
@@ -119,6 +122,30 @@ export const rosterApi = {
/** 即将到期合同 */
expiringContracts: () =>
get('/roster/contracts/expiring').then(unwrap<any[]>()),
/** 培训记录列表(全员) */
trainingList: (params: { page?: number; pageSize?: number; keyword?: string; ackStatus?: string }) =>
get('/roster/training/list', { params }).then(unwrap<any>()),
/** 培训记录催办 */
trainingRemind: (recordId: string) =>
post(`/roster/training/remind/${recordId}`).then(unwrap<any>()),
/** 绩效记录列表(全员) */
performanceList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
get('/roster/performance/list', { params }).then(unwrap<any>()),
/** 绩效模板列表 */
performanceTemplates: () =>
get('/roster/performance/templates').then(unwrap<any[]>()),
/** 创建绩效模板 */
createPerformanceTemplate: (data: any) =>
post('/roster/performance/templates', data).then(unwrap<any>()),
/** 更新绩效模板 */
updatePerformanceTemplate: (id: string, data: any) =>
put(`/roster/performance/templates/${id}`, data).then(unwrap<any>()),
/** 删除绩效模板 */
deletePerformanceTemplate: (id: string) =>
del(`/roster/performance/templates/${id}`).then(unwrap<any>()),
/** 违纪记录列表(全员) */
disciplinaryList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
get('/roster/disciplinary/list', { params }).then(unwrap<any>()),
/** 违纪记录 */
disciplinary: (employeeId: string) =>
get(`/roster/${employeeId}/disciplinary`).then(unwrap<any[]>()),
@@ -282,6 +309,9 @@ export const attendanceApi = {
/** 删除请假记录 */
removeLeave: (id: string) =>
del(`/attendance/leaves/${id}`),
/** 手动补卡/修正考勤 */
manualCorrect: (data: { employeeId: string; date: string; checkInTime?: string; checkOutTime?: string; status?: string; remark?: string }) =>
post('/attendance/manual-correct', data).then(unwrap<any>()),
}
// ========== 休假审批流 ==========
@@ -435,6 +465,9 @@ export const payrollApi = {
/** 批量导入加班工时 */
batchImportOvertime: (data: Record<string, unknown>[]) =>
post('/payroll/overtime/batch', data),
/** 从考勤记录同步加班工时 */
syncOvertimeFromAttendance: (month: string) =>
post('/payroll/overtime/sync-from-attendance', { month }).then(unwrap<any>()),
/** 导入加班费到批次 */
importOvertimeToBatch: (batchId: string) =>
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
@@ -552,6 +585,9 @@ export const socialInsuranceApi = {
/** 公积金活跃申报 */
housingActiveDeclaration: (month: string) =>
get('/social/housing/active-declaration', { params: { month } }).then(unwrap<any>()),
/** 员工参保信息列表 */
employeeEnrollment: (keyword?: string) =>
get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap<any[]>()),
}
// ========== 商业保险 ==========
@@ -569,6 +605,45 @@ export const commercialInsuranceApi = {
/** 删除方案 */
removePlan: (id: string) =>
del(`/commercial-insurance/plans/${id}`),
/** 批量参保 */
enroll: (planId: string, data: { employeeIds: string[]; premium?: number; effectiveFrom?: string }) =>
post(`/commercial-insurance/plans/${planId}/enroll`, data),
/** 退保 */
terminateEnrollment: (enrollmentId: string, effectiveTo?: string) =>
post(`/commercial-insurance/enrollments/${enrollmentId}/terminate`, { effectiveTo }),
/** 员工商险汇总 */
employeeSummary: () =>
get('/commercial-insurance/employee-summary').then(unwrap<any[]>()),
}
// ========== 员工福利 ==========
export const benefitApi = {
plans: () =>
get('/benefits/plans').then(unwrap<any[]>()),
savePlan: (data: Record<string, unknown>, editId?: string) =>
editId ? put(`/benefits/plans/${editId}`, data) : post('/benefits/plans', data),
removePlan: (id: string) =>
del(`/benefits/plans/${id}`),
enrollments: (planId: string) =>
get(`/benefits/plans/${planId}/enrollments`).then(unwrap<any[]>()),
enroll: (planId: string, data: { employeeIds: string[]; effectiveFrom?: string }) =>
post(`/benefits/plans/${planId}/enroll`, data),
terminateEnrollment: (enrollmentId: string, effectiveTo?: string) =>
post(`/benefits/enrollments/${enrollmentId}/terminate`, { effectiveTo }),
employeeSummary: () =>
get('/benefits/employee-summary').then(unwrap<any[]>()),
}
// ========== 电子签署(易签宝) ==========
export const esignApi = {
list: (params?: { status?: string; scene?: string }) =>
get('/esign', { params: params || {} }).then(unwrap<any[]>()),
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string }) =>
post('/esign/create', data),
status: (id: string) =>
get(`/esign/${id}/status`).then(unwrap<any>()),
cancel: (id: string) =>
post(`/esign/${id}/cancel`),
}
// ========== 离职相关 ==========
@@ -604,6 +679,9 @@ export const terminationApi = {
/** 撤销 */
cancel: (draftId: string) =>
post(`/termination/draft/${draftId}/cancel`),
/** 删除草稿(仅 DRAFT 和 CANCELLED 状态) */
deleteDraft: (draftId: string) =>
del(`/termination/draft/${draftId}`),
/** 撤回离职记录 */
revoke: (recordId: string) =>
del(`/termination/${recordId}/revoke`),
@@ -642,6 +720,9 @@ export const policiesApi = {
/** 阅读签收统计 */
readStats: (id: string) =>
get(`/policies/${id}/read-stats`).then(unwrap<any>()),
/** 催办未签收员工 */
remind: (id: string, employeeIds?: string[]) =>
post(`/policies/${id}/remind`, { employeeIds }).then(unwrap<any>()),
}
// ========== 证据链相关 ==========
@@ -653,6 +734,12 @@ export const evidenceApi = {
/** 全量验证 */
verifyAll: () =>
get('/evidence/verify-all').then(unwrap<any>()),
/** 按员工获取证据链记录 */
byEmployee: (employeeId: string) =>
get(`/evidence/employee/${employeeId}`).then(unwrap<any[]>()),
/** 验证单条证据链 */
verify: (id: string) =>
get(`/evidence/verify/${id}`).then(unwrap<any>()),
}
// ========== 审计日志 ==========
@@ -747,6 +834,15 @@ export const settingsApi = {
/** 确认退休政策生效 */
confirmRetirementPolicy: (id: string) =>
post(`/settings/retirement-policy/${id}/confirm`),
/** 医疗期政策列表 */
medicalPeriodPolicies: () =>
get('/settings/medical-period/policies').then(unwrap<any[]>()),
/** 保存医疗期政策 */
saveMedicalPeriodPolicy: (data: Record<string, unknown>) =>
post('/settings/medical-period/policies', data),
/** 删除医疗期政策 */
deleteMedicalPeriodPolicy: (id: string) =>
del(`/settings/medical-period/policies/${id}`),
}
// ========== 模板相关 ==========
@@ -960,6 +1056,9 @@ export const portalApi = {
/** 撤回离职申请 */
resignationWithdraw: (id: string) =>
portalPost(`/resignation/${id}/withdraw`).then(unwrap<any>()),
/** 下载离职证明 */
downloadCertificate: (id: string) =>
portalGet(`/resignation/${id}/certificate`, { responseType: 'blob' }) as any,
/** 我的休假申请列表 */
myLeaves: () =>
portalGet('/leaves').then(unwrap<any[]>()),
@@ -969,4 +1068,34 @@ export const portalApi = {
/** 撤回休假申请 */
cancelLeave: (id: string) =>
portalPost(`/leaves/${id}/cancel`).then(unwrap<any>()),
/** 我的电子签署列表 */
myEsignList: () =>
portalGet('/esign').then(unwrap<any[]>()),
/** 电子签署详情 */
esignDetail: (id: string) =>
portalGet(`/esign/${id}`).then(unwrap<any>()),
/** 签署操作 */
signEsign: (id: string) =>
portalPost(`/esign/${id}/sign`).then(unwrap<any>()),
/** 我的培训记录 */
myTraining: () =>
portalGet('/training').then(unwrap<any[]>()),
/** 培训签收 */
signTraining: (id: string) =>
portalPost(`/training/${id}/sign`).then(unwrap<any>()),
/** 培训拒绝签收 */
refuseTraining: (id: string) =>
portalPost(`/training/${id}/refuse`).then(unwrap<any>()),
/** 我的绩效记录 */
myPerformance: () =>
portalGet('/performance').then(unwrap<any[]>()),
/** 绩效签字 */
signPerformance: (id: string) =>
portalPost(`/performance/${id}/sign`).then(unwrap<any>()),
/** 我的违纪记录 */
myDisciplinary: () =>
portalGet('/disciplinary').then(unwrap<any[]>()),
/** 违纪签字 */
signDisciplinary: (id: string) =>
portalPost(`/disciplinary/${id}/sign`).then(unwrap<any>()),
}
+37
View File
@@ -0,0 +1,37 @@
import { toast } from 'sonner'
/**
* execCommand fallback
*/
export async function copyToClipboard(text: string, successMsg = '已复制') {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
toast.success(successMsg)
return
}
} catch {
// fall through to fallback
}
// fallback: execCommand('copy') + hidden textarea
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
if (ok) {
toast.success(successMsg)
} else {
toast.error('复制失败,请手动复制')
}
} catch {
toast.error('复制失败,请手动复制')
}
}
+19
View File
@@ -0,0 +1,19 @@
import { toast } from 'sonner'
/**
* axios Zod
*/
export function toastError(err: any, fallback = '操作失败') {
const error = err?.response?.data?.error
if (!error) {
toast.error(fallback)
return
}
// 如果有 details(Zod 校验失败),展示具体字段
if (error.details && Array.isArray(error.details) && error.details.length > 0) {
const fields = error.details.map((d: any) => `${d.path || '字段'}: ${d.message}`).join('')
toast.error(`${error.message}${fields}`)
return
}
toast.error(error.message || fallback)
}
+21
View File
@@ -0,0 +1,21 @@
/**
*
* 10 / localStorage
*/
const STORAGE_KEY = 'hr-page-size'
export const DEFAULT_PAGE_SIZE = 10
/** 获取当前分页大小 */
export function getPageSize(): number {
const val = localStorage.getItem(STORAGE_KEY)
const n = val ? parseInt(val, 10) : NaN
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PAGE_SIZE
}
/** 设置分页大小 */
export function setPageSize(size: number): void {
localStorage.setItem(STORAGE_KEY, String(size))
window.dispatchEvent(new CustomEvent('page-size-changed'))
}
+473 -59
View File
@@ -1,13 +1,16 @@
import { useState, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2, CheckCheck } from 'lucide-react'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2, CheckCheck, Edit } from 'lucide-react'
import { attendanceApi, employeeApi, rosterApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { usePageSize } from '../hooks/usePageSize'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import Pagination from '../components/ui/Pagination'
import EmptyState from '../components/ui/EmptyState'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
@@ -80,7 +83,7 @@ export default function Attendance() {
})}
</div>
{activeTab === 'confirm' && <ConfirmTab />}
{activeTab === 'confirm' && <ConfirmTab onGoToTab={setActiveTab} />}
{activeTab === 'shifts' && <ShiftsTab />}
{activeTab === 'schedule' && <ScheduleTab />}
{activeTab === 'daily' && <DailyTab />}
@@ -91,7 +94,7 @@ export default function Attendance() {
}
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
@@ -102,7 +105,12 @@ function ConfirmTab() {
const [importResult, setImportResult] = useState<any>(null)
const [importing, setImporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [editItem, setEditItem] = useState<any>(null)
const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0 })
const fileInputRef = useRef<HTMLInputElement>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data: list, isLoading } = useQuery<any>({
queryKey: ['attendance', month, filterDepartment, filterStatus],
@@ -180,10 +188,33 @@ function ConfirmTab() {
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '确认失败'),
})
const editMutation = useMutation({
mutationFn: async (data: any) => {
return await attendanceApi.manualCorrect(data)
},
onSuccess: () => {
toast.success('考勤记录已修改')
queryClient.invalidateQueries({ queryKey: ['attendance'] })
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
setEditItem(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '修改失败'),
})
const currentPublish = publishRecords?.find((r: any) => r.month === month && r.status === 'PUBLISHED')
const pendingCount = stats?.pending || 0
const pendingItems = (list || []).filter((i: any) => i.status === 'PENDING')
const allList = list || []
const filteredList = allList.filter((i: any) => {
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!i.employee?.name?.toLowerCase().includes(q) && !i.employee?.department?.toLowerCase().includes(q)) return false
}
return true
})
const pendingItems = filteredList.filter((i: any) => i.status === 'PENDING')
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((i: any) => selectedIds.has(i.id))
const total = filteredList.length
const pagedList = filteredList.slice((page - 1) * pageSize, page * pageSize)
const toggleSelect = (id: string) => {
const next = new Set(selectedIds)
@@ -300,6 +331,13 @@ function ConfirmTab() {
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
@@ -345,11 +383,12 @@ function ConfirmTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
) : total === 0 ? (
<EmptyState title="本月暂无考勤确认记录" description="请先批量导入考勤数据" />
) : (
<>
<div className="space-y-2">
{list.map((item: any) => {
{pagedList.map((item: any) => {
const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING
const StatusIcon = config.icon
const isSelected = selectedIds.has(item.id)
@@ -387,13 +426,32 @@ function ConfirmTab() {
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{item.status === 'PENDING' && (
<button
className="text-xs text-primary hover:underline"
onClick={() => singleConfirmMutation.mutate(item.id)}
disabled={singleConfirmMutation.isPending}
>
</button>
<>
<button
className="text-xs text-primary hover:underline"
onClick={() => singleConfirmMutation.mutate(item.id)}
disabled={singleConfirmMutation.isPending}
>
</button>
<button
className="text-xs text-gray-500 hover:text-primary"
onClick={() => {
setEditItem(item)
setEditForm({
workDays: item.workDays || 0,
lateCount: item.lateCount || 0,
earlyLeaveCount: item.earlyLeaveCount || 0,
absentDays: item.absentDays || 0,
leaveDays: item.leaveDays || 0,
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
overtimePay: item.overtimePay || 0,
})
}}
>
</button>
</>
)}
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color}`}>
<StatusIcon className="w-3.5 h-3.5" />
@@ -405,6 +463,63 @@ function ConfirmTab() {
)
})}
</div>
{editItem && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setEditItem(null)}>
<Card className="max-w-md w-full" >
<div onClick={(e) => e.stopPropagation()} className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {editItem.employee?.name}</h2>
<button onClick={() => setEditItem(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.workDays}
onChange={(e) => setEditForm({ ...editForm, workDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.lateCount}
onChange={(e) => setEditForm({ ...editForm, lateCount: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500">退</label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.earlyLeaveCount}
onChange={(e) => setEditForm({ ...editForm, earlyLeaveCount: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.absentDays}
onChange={(e) => setEditForm({ ...editForm, absentDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.leaveDays}
onChange={(e) => setEditForm({ ...editForm, leaveDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" step="0.5" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimeHours}
onChange={(e) => setEditForm({ ...editForm, overtimeHours: Number(e.target.value) })} />
</div>
<div className="col-span-2">
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimePay}
onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} />
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}></Button>
<Button size="sm" onClick={() => editMutation.mutate({ employeeId: editItem.employeeId, month, ...editForm })} disabled={editMutation.isPending}>
{editMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Card>
</div>
)}
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
{/* 导入考勤弹窗 */}
@@ -423,14 +538,14 @@ function ConfirmTab() {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/template`, {
const res = await fetch(`${baseURL}/import/monthly-template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '员工导入模板.xlsx'
a.download = '考勤月度导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('下载模板失败') }
@@ -440,7 +555,7 @@ function ConfirmTab() {
</div>
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
Sheet
Sheet 0
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
@@ -455,6 +570,10 @@ function ConfirmTab() {
<div className="font-medium"></div>
{importResult.attendance > 0 && <div>{importResult.attendance} </div>}
{importResult.overtime > 0 && <div>{importResult.overtime} </div>}
{importResult.discipline > 0 && <div>{importResult.discipline} </div>}
{importResult.salaryChanges > 0 && <div>{importResult.salaryChanges} </div>}
{importResult.socialInsChanges > 0 && <div>{importResult.socialInsChanges} </div>}
{importResult.housingFundChanges > 0 && <div>{importResult.housingFundChanges} </div>}
{importResult.employees > 0 && <div>{importResult.employees} </div>}
{importResult.contracts > 0 && <div>{importResult.contracts} </div>}
{importResult.skipped > 0 && <div className="text-amber-600"> {importResult.skipped} </div>}
@@ -464,6 +583,12 @@ function ConfirmTab() {
{importResult.errors.length > 5 && <div className="text-amber-600">... {importResult.errors.length - 5} </div>}
</div>
)}
<button
className="mt-1 text-primary hover:underline font-medium"
onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null); onGoToTab?.('confirm') }}
>
</button>
</div>
)}
@@ -629,6 +754,10 @@ function ScheduleTab() {
const [selectedShiftId, setSelectedShiftId] = useState('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [inlineShiftId, setInlineShiftId] = useState<Record<string, string>>({})
const { data: shifts } = useQuery<any>({
queryKey: ['shifts'],
@@ -678,9 +807,20 @@ function ScheduleTab() {
batchAssignMutation.mutate(items)
}
const employees = dailyData || []
const allEmployees = dailyData || []
const assignmentMap: Map<string, any> = new Map((assignments || []).map((a: any) => [a.employeeId, a]))
const filteredEmployees = allEmployees.filter((emp: any) => {
if (filterDept && emp.department !== filterDept) return false
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!emp.name?.toLowerCase().includes(q) && !emp.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredEmployees.length
const employees = filteredEmployees.slice((page - 1) * pageSize, page * pageSize)
const toggleEmployee = (id: string) => {
const next = new Set(selectedEmployeeIds)
if (next.has(id)) next.delete(id)
@@ -688,18 +828,43 @@ function ScheduleTab() {
setSelectedEmployeeIds(next)
}
const handleInlineAssign = (employeeId: string) => {
const shiftId = inlineShiftId[employeeId]
if (!shiftId) return toast.error('请先选择班次')
batchAssignMutation.mutate([{ employeeId, shiftId, date }])
}
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={e => { setDate(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allEmployees.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button onClick={() => setShowAssign(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
@@ -707,9 +872,10 @@ function ScheduleTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : employees.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无员工" description="没有可排班的员工" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -717,7 +883,7 @@ function ScheduleTab() {
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center w-48"></th>
</tr>
</thead>
<tbody>
@@ -726,7 +892,7 @@ function ScheduleTab() {
return (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-gray-500">{emp.department || '未分配'}</td>
<td className="px-4 py-3">
{assignment ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs" style={{ background: (assignment.shift as any)?.color + '20', color: (assignment.shift as any)?.color }}>
@@ -737,10 +903,29 @@ function ScheduleTab() {
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-center">
{assignment && (
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}></button>
)}
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
{assignment ? (
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}></button>
) : (
<>
<select
value={inlineShiftId[emp.employeeId] || ''}
onChange={e => setInlineShiftId(prev => ({ ...prev, [emp.employeeId]: e.target.value }))}
className="h-7 rounded border border-gray-200 text-xs px-1 max-w-[100px]"
>
<option value=""></option>
{(shifts || []).map((s: any) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<button
className="text-xs text-primary hover:underline whitespace-nowrap"
onClick={() => handleInlineAssign(emp.employeeId)}
></button>
</>
)}
</div>
</td>
</tr>
)
@@ -748,6 +933,8 @@ function ScheduleTab() {
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="批量排班">
@@ -771,11 +958,7 @@ function ScheduleTab() {
className="w-full px-3 py-2 mb-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{employees.filter((emp: any) => {
if (!searchQuery.trim()) return true
const q = searchQuery.trim().toLowerCase()
return emp.name?.toLowerCase().includes(q) || emp.department?.toLowerCase().includes(q)
}).map((emp: any) => (
{filteredEmployees.map((emp: any) => (
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
<span className="text-sm">{emp.name}</span>
@@ -796,7 +979,14 @@ function ScheduleTab() {
// ========== 每日出勤 Tab ==========
function DailyTab() {
const queryClient = useQueryClient()
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [editEmp, setEditEmp] = useState<any>(null)
const [editForm, setEditForm] = useState({ checkInTime: '', checkOutTime: '', status: 'NORMAL', remark: '' })
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery<any>({
queryKey: ['daily-attendance', date],
@@ -805,6 +995,16 @@ function DailyTab() {
},
})
const correctMutation = useMutation({
mutationFn: (data: any) => attendanceApi.manualCorrect(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
toast.success('考勤记录已修正')
setEditEmp(null)
},
onError: () => toast.error('修正失败'),
})
const statusColors: Record<string, string> = {
NORMAL: 'bg-green-50 text-green-700',
LATE: 'bg-amber-50 text-amber-700',
@@ -815,25 +1015,75 @@ function DailyTab() {
UNREGISTERED: 'bg-gray-100 text-gray-500',
}
const allData = data || []
const filteredData = allData.filter((emp: any) => {
if (filterDept && emp.department !== filterDept) return false
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!emp.name?.toLowerCase().includes(q) && !emp.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredData.length
const pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
//退/
</PageGuide>
<div className="flex justify-end">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allData.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button variant="secondary" size="sm" onClick={() => {
if (!data || data.length === 0) return
const headers = ['姓名', '部门', '班次', '签到', '签退', '状态', '工时']
const rows = data.map((emp: any) => [
emp.name, emp.department, emp.shift?.name || '', emp.checkInTime || '', emp.checkOutTime || '',
ATTENDANCE_STATUS[emp.status] || emp.status, emp.workHours > 0 ? `${emp.workHours}h` : '0',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `考勤-${date}.csv`
a.click()
URL.revokeObjectURL(url)
}} disabled={!data || data.length === 0}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无员工" description="没有出勤数据" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -845,27 +1095,91 @@ function DailyTab() {
<th className="px-4 py-3 text-left">退</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.map((emp: any) => (
{pagedData.map((emp: any) => (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-xs text-gray-500">{emp.shift ? `${emp.shift.name}` : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime ? (() => { const d = new Date(emp.checkInTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime ? (() => { const d = new Date(emp.checkOutTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[emp.status] || 'bg-gray-100 text-gray-500'}`}>
{ATTENDANCE_STATUS[emp.status] || emp.status}
</span>
</td>
<td className="px-4 py-3 text-right text-xs">{emp.workHours > 0 ? `${emp.workHours}h` : '—'}</td>
<td className="px-4 py-3 text-center">
<button
className="text-xs text-primary hover:underline"
onClick={() => {
setEditEmp(emp)
const fmtTime = (t: string) => { if (!t) return ''; const d = new Date(t); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}` }
setEditForm({
checkInTime: fmtTime(emp.checkInTime),
checkOutTime: fmtTime(emp.checkOutTime),
status: emp.status || 'NORMAL',
remark: '',
})
}}
>
<Edit className="w-3.5 h-3.5 inline" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
{/* 补卡弹窗 */}
{editEmp && (
<Modal open onClose={() => setEditEmp(null)}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium"> - {editEmp.name}</h3>
<button onClick={() => setEditEmp(null)} className="text-gray-500 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="text-xs text-gray-500">{date}</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="time" value={editForm.checkInTime} onChange={(e) => setEditForm({ ...editForm, checkInTime: e.target.value })} />
</div>
<div>
<Label>退</Label>
<Input type="time" value={editForm.checkOutTime} onChange={(e) => setEditForm({ ...editForm, checkOutTime: e.target.value })} />
</div>
<div className="col-span-2">
<Label></Label>
<Select value={editForm.status} onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}>
<option value="NORMAL"></option>
<option value="LATE"></option>
<option value="EARLY_LEAVE">退</option>
<option value="ABSENT"></option>
<option value="LEAVE"></option>
<option value="BUSINESS_TRIP"></option>
<option value="UNREGISTERED"></option>
</Select>
</div>
<div className="col-span-2">
<Label></Label>
<Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} placeholder="补卡原因/备注" />
</div>
</div>
<Button onClick={() => correctMutation.mutate({ employeeId: editEmp.employeeId, date, ...editForm })} disabled={correctMutation.isPending} className="w-full">
{correctMutation.isPending ? '提交中...' : '确认修正'}
</Button>
</div>
</Modal>
)}
</div>
)
@@ -874,6 +1188,10 @@ function DailyTab() {
// ========== 月度报表 Tab ==========
function MonthlyTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery<any>({
queryKey: ['monthly-report', month],
@@ -899,18 +1217,75 @@ function MonthlyTab() {
URL.revokeObjectURL(url)
}
const handleExportSingle = (r: any) => {
const headers = ['项目', '数值']
const rows = [
['姓名', r.name],
['部门', r.department],
['月份', month],
['出勤天数', r.workDays],
['迟到次数', r.lateCount],
['早退次数', r.earlyLeaveCount],
['缺勤天数', r.absentDays],
['请假天数', r.leaveDays],
['加班工时', r.overtimeHours?.toFixed(1) || '0'],
['加班费', `¥${r.overtimePay?.toFixed(2) || '0.00'}`],
['确认状态', r.confirmationStatus === 'CONFIRMED' ? '已确认' : r.confirmationStatus === 'PENDING' ? '待确认' : r.confirmationStatus === 'DISPUTED' ? '有异议' : '未创建'],
]
const csv = [headers, ...rows].map(row => row.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `考勤明细-${r.name}-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success(`已导出 ${r.name}${month} 月考勤明细`)
}
const allData = data || []
const filteredData = allData.filter((r: any) => {
if (filterDept && r.department !== filterDept) return false
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!r.name?.toLowerCase().includes(q) && !r.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredData.length
const pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allData.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button variant="secondary" onClick={handleExport} disabled={!data || data.length === 0}>
<BarChart3 className="w-4 h-4 mr-1" /> CSV
</Button>
@@ -918,9 +1293,10 @@ function MonthlyTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无报表数据" description="该月份没有出勤数据" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -935,10 +1311,11 @@ function MonthlyTab() {
<th className="px-4 py-3 text-center">(h)</th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.map((r: any) => (
{pagedData.map((r: any) => (
<tr key={r.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{r.name}</td>
<td className="px-4 py-3 text-gray-500">{r.department}</td>
@@ -955,11 +1332,21 @@ function MonthlyTab() {
: r.confirmationStatus === 'DISPUTED' ? <span className="text-xs text-red-600"></span>
: <span className="text-xs text-gray-400"></span>}
</td>
<td className="px-4 py-3 text-center">
<button
className="text-xs text-primary hover:underline"
onClick={() => handleExportSingle(r)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
</div>
)
@@ -971,6 +1358,9 @@ function LeavesTab() {
const confirm = useConfirm()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
const [searchQuery, setSearchQuery] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data: leaves, isLoading } = useQuery<any>({
queryKey: ['leave-records'],
@@ -1006,13 +1396,34 @@ function LeavesTab() {
}
const employees = rosterData || []
const allLeaves = leaves || []
const filteredLeaves = allLeaves.filter((lv: any) => {
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!lv.employee?.name?.toLowerCase().includes(q) && !lv.employee?.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredLeaves.length
const pagedLeaves = filteredLeaves.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex justify-end">
<div className="flex justify-end items-center gap-3">
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<Link to="/leave-approval" className="text-xs text-primary hover:underline flex items-center gap-1">
<Plane className="w-3.5 h-3.5" />
</Link>
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
@@ -1020,11 +1431,12 @@ function LeavesTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !leaves || leaves.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无休假记录" description="点击右上角添加休假记录" />
) : (
<>
<div className="space-y-2">
{leaves.map((lv: any) => (
{pagedLeaves.map((lv: any) => (
<Card key={lv.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
@@ -1050,6 +1462,8 @@ function LeavesTab() {
</Card>
))}
</div>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title="新增休假记录">
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery } from '@tanstack/react-query'
import { ScrollText } from 'lucide-react'
import { auditApi } from '../lib/api-services'
@@ -118,8 +119,8 @@ function formatDetail(detail: any): string {
*
*/
export default function AuditLog() {
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [action, setAction] = useState('')
const [entity, setEntity] = useState('')
const [dateFrom, setDateFrom] = useState('')
@@ -236,7 +237,7 @@ export default function AuditLog() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
+144 -39
View File
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X, AlertCircle, Clock, Bell } from 'lucide-react'
import { dashboardApi, calendarApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -15,6 +15,7 @@ const EVENT_TYPE_COLORS: Record<string, string> = {
ANNIVERSARY: 'bg-green-100 text-green-700 border-green-200',
RISK_DEADLINE: 'bg-orange-100 text-orange-700 border-orange-200',
RETIREMENT: 'bg-purple-100 text-purple-700 border-purple-200',
PAYROLL_DAY: 'bg-emerald-100 text-emerald-700 border-emerald-200',
CUSTOM: 'bg-blue-100 text-blue-700 border-blue-200',
MEETING: 'bg-cyan-100 text-cyan-700 border-cyan-200',
TEAM_BUILDING: 'bg-pink-100 text-pink-700 border-pink-200',
@@ -29,6 +30,7 @@ const EVENT_TYPE_LABELS: Record<string, string> = {
ANNIVERSARY: '入职周年',
RISK_DEADLINE: '风险截止',
RETIREMENT: '退休',
PAYROLL_DAY: '发薪日',
CUSTOM: '自定义',
MEETING: '会议',
TEAM_BUILDING: '团建',
@@ -119,6 +121,34 @@ export default function Calendar() {
return events
}, [calendarData, typeFilter])
const todayStr = fmtDate(new Date())
const eventStats = useMemo(() => {
const overdue: any[] = []
const urgent: any[] = []
const warning: any[] = []
const remind: any[] = []
for (const ev of allEvents) {
const diff = Math.floor((new Date(ev.date).getTime() - new Date(todayStr).getTime()) / 86400000)
if (diff < 0) overdue.push({ ...ev, overdueDays: -diff })
else if (diff <= 7) urgent.push(ev)
else if (diff <= 15) warning.push(ev)
else if (diff <= 35) remind.push(ev)
}
const totalOverdueDays = overdue.reduce((s, e) => s + e.overdueDays, 0)
return { overdue, urgent, warning, remind, totalOverdueDays }
}, [allEvents, todayStr])
const groupedEvents = useMemo(() => {
const groups = [
{ key: 'overdue', label: '已逾期', color: 'text-red-600', bg: 'bg-red-50', icon: AlertCircle, items: eventStats.overdue },
{ key: 'urgent', label: '7天内紧急', color: 'text-orange-600', bg: 'bg-orange-50', icon: AlertCircle, items: eventStats.urgent },
{ key: 'warning', label: '15天预警', color: 'text-amber-600', bg: 'bg-amber-50', icon: Clock, items: eventStats.warning },
{ key: 'remind', label: '35天提醒', color: 'text-blue-600', bg: 'bg-blue-50', icon: Bell, items: eventStats.remind },
]
return groups.filter(g => g.items.length > 0)
}, [eventStats])
const customEventMap = useMemo(() => {
const map: Record<string, any> = {}
for (const ev of (customEvents || [])) {
@@ -188,6 +218,38 @@ export default function Calendar() {
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-red-50 text-red-600"><AlertCircle className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-red-600">{eventStats.overdue.length}</div>
<div className="text-xs text-gray-500">{eventStats.totalOverdueDays}</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-orange-50 text-orange-600"><AlertCircle className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-orange-600">{eventStats.urgent.length}</div>
<div className="text-xs text-gray-500">7</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-50 text-amber-600"><Clock className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-amber-600">{eventStats.warning.length}</div>
<div className="text-xs text-gray-500">15</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 text-blue-600"><Bell className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-blue-600">{eventStats.remind.length}</div>
<div className="text-xs text-gray-500">35</div>
</div>
</Card>
</div>
{/* 类型筛选 */}
<div className="flex items-center gap-2 flex-wrap">
<button
@@ -235,16 +297,36 @@ export default function Calendar() {
{cell.day}
</div>
<div className="space-y-0.5">
{cell.events.slice(0, 3).map((ev: any, idx: number) => (
<div
key={idx}
className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
>
<span className={`inline-block w-1 h-1 rounded-full mr-0.5 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
{ev.title}
</div>
))}
{cell.events.slice(0, 3).map((ev: any, idx: number) => {
const content = (
<>
<span className={`inline-block w-1 h-1 rounded-full mr-0.5 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
{ev.title}
</>
)
if (ev.actionUrl && ev.actionUrl !== '/dashboard') {
return (
<Link
key={idx}
to={ev.actionUrl}
className={`block text-[10px] leading-tight px-1 py-0.5 rounded truncate hover:underline ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
onClick={(e) => e.stopPropagation()}
>
{content}
</Link>
)
}
return (
<div
key={idx}
className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
>
{content}
</div>
)
})}
{cell.events.length > 3 && (
<div className="text-[10px] text-gray-400 px-1">+{cell.events.length - 3} </div>
)}
@@ -266,37 +348,60 @@ export default function Calendar() {
<span className="text-xs text-gray-400 font-normal">({allEvents.length})</span>
</h3>
{allEvents.length > 0 ? (
<div className="space-y-1.5 max-h-[500px] overflow-y-auto">
{allEvents.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 px-2 py-2 rounded-md hover:bg-gray-50 group">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 flex-shrink-0">{ev.date.slice(5)}</span>
<span className={`text-[10px] px-1 py-0.5 rounded ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}>
{EVENT_TYPE_LABELS[ev.type] || ev.type}
</span>
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{groupedEvents.length > 0 ? groupedEvents.map(group => {
const GIcon = group.icon
return (
<div key={group.key}>
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-md ${group.bg} mb-1 sticky top-0`}>
<GIcon className={`w-3.5 h-3.5 ${group.color}`} />
<span className={`text-xs font-medium ${group.color}`}>{group.label}</span>
<span className="text-xs text-gray-400">({group.items.length})</span>
</div>
<div className="text-xs text-gray-800 mt-0.5 truncate">
{ev.title}
{ev.employeeName && <span className="text-gray-400 ml-1"> {ev.employeeName}</span>}
<div className="space-y-1">
{group.items.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 group">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 flex-shrink-0">{ev.date.slice(5)}</span>
<span className={`text-[10px] px-1 py-0.5 rounded ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}>
{EVENT_TYPE_LABELS[ev.type] || ev.type}
</span>
{group.key === 'overdue' && (
<span className="text-[10px] text-red-600 font-medium">{ev.overdueDays}</span>
)}
</div>
<div className="text-xs text-gray-800 mt-0.5 truncate">
{ev.title}
{ev.employeeName && <span className="text-gray-400 ml-1"> {ev.employeeName}</span>}
</div>
{ev.type === 'CONTRACT_EXPIRY' && ev.employeeName ? (
<Link to={`/roster?employee=${encodeURIComponent(ev.employeeName)}`} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
) : ev.actionUrl && ev.actionUrl !== '/dashboard' ? (
<Link to={ev.actionUrl} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
) : null}
</div>
{isCustomEvent(ev) && customEventMap[ev.id] && (
<button
onClick={() => deleteEventMutation.mutate(ev.id)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
{ev.actionUrl && ev.actionUrl !== '/dashboard' && (
<Link to={ev.actionUrl} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
)}
</div>
{isCustomEvent(ev) && customEventMap[ev.id] && (
<button
onClick={() => deleteEventMutation.mutate(ev.id)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
)
}) : (
<div className="text-xs text-gray-500 text-center py-8">35</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-8"></div>
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Shield } from 'lucide-react'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import { commercialInsuranceApi } from '../lib/api-services'
import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const INSURANCE_TYPES: Record<string, { label: string; color: string }> = {
ACCIDENT: { label: '意外伤害险', color: 'bg-orange-50 text-orange-700 border border-orange-200' },
SUPPLEMENTARY_MEDICAL: { label: '补充医疗保险', color: 'bg-blue-50 text-blue-700 border border-blue-200' },
EMPLOYER_LIABILITY: { label: '雇主责任险', color: 'bg-purple-50 text-purple-700 border border-purple-200' },
CRITICAL_ILLNESS: { label: '重大疾病险', color: 'bg-rose-50 text-rose-700 border border-rose-200' },
GROUP_LIFE: { label: '团体寿险', color: 'bg-teal-50 text-teal-700 border border-teal-200' },
OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' },
}
export default function CommercialInsurance() {
const [tab, setTab] = useState<'plans' | 'summary'>('plans')
const { data: employeeSummary = [] } = useQuery<any[]>({
queryKey: ['commercial-insurance-employee-summary'],
queryFn: async () => {
return await commercialInsuranceApi.employeeSummary()
},
enabled: tab === 'summary',
})
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
<PageGuide>
<span className="text-primary"> </span>
</PageGuide>
{/* Tab 切换 */}
<div className="flex items-center gap-4 border-b">
{(['plans', 'summary'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setTab(t)}
>
{t === 'plans' ? '方案管理' : '员工汇总'}
</button>
))}
</div>
{/* ========== 方案管理 Tab ========== */}
{tab === 'plans' && <CommercialInsuranceTab />}
{/* ========== 员工汇总 Tab ========== */}
{tab === 'summary' && (
<Card>
{employeeSummary.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{employeeSummary.map((e: any) => (
<tr key={e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3 font-medium">{e.name}</td>
<td className="py-2 px-3 text-gray-500">{e.department}</td>
<td className="py-2 px-3">
<div className="flex flex-wrap gap-1">
{e.insurances.map((ins: any, i: number) => {
const typeCfg = INSURANCE_TYPES[ins.type] || INSURANCE_TYPES.OTHER
return (
<span key={i} className={`px-1.5 py-0.5 rounded text-xs ${typeCfg.color}`}>
{ins.planName} · {ins.provider} · ¥{fmt(ins.premium)}
</span>
)
})}
</div>
</td>
<td className="py-2 px-3 text-right font-medium text-primary">¥{fmt(e.totalPremium)}</td>
<td className="py-2 px-3 text-right font-medium">¥{fmt(e.totalCoverage)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="py-2 px-3" colSpan={3}>{employeeSummary.length}</td>
<td className="py-2 px-3 text-right text-primary">
¥{fmt(employeeSummary.reduce((sum: number, e: any) => sum + e.totalPremium, 0))}
</td>
<td className="py-2 px-3 text-right">
¥{fmt(employeeSummary.reduce((sum: number, e: any) => sum + e.totalCoverage, 0))}
</td>
</tr>
</tfoot>
</table>
</div>
)}
</Card>
)}
</div>
)
}
+1 -1
View File
@@ -118,7 +118,7 @@ export default function CompanyFiles() {
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32 text-xs">
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="!w-28 text-xs">
<option value=""></option>
{FILE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
+15 -6
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X, FileText, Download } from 'lucide-react'
import { toast } from 'sonner'
@@ -37,8 +38,8 @@ export default function Contracts() {
const [search, setSearch] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showAddModal, setShowAddModal] = useState(false)
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
@@ -60,6 +61,7 @@ export default function Contracts() {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
},
})
@@ -185,7 +187,7 @@ export default function Contracts() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
@@ -218,6 +220,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
const [form, setForm] = useState({
name: '',
department: '',
position: '',
hireDate: '',
monthlySalary: '',
gender: '男' as '男' | '女',
@@ -238,6 +241,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
const data: any = {
name: form.name,
department: form.department,
position: form.position || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary,
gender: form.gender,
@@ -281,14 +285,19 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>/</Label>
<Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
</div>
</div>
<div>
<Label> *</Label>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
</div>
<div className="grid grid-cols-2 gap-3">
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
@@ -39,8 +40,8 @@ function TodoIcon({ type }: { type: string; level: string }) {
}
export default function Dashboard() {
const todoPageSize = usePageSize()
const [todoPage, setTodoPage] = useState(1)
const [todoPageSize, setTodoPageSize] = useState(10)
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'overview' | 'risk' | 'task' | 'cost' | 'workforce'>('overview')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -1128,7 +1129,6 @@ export default function Dashboard() {
</>
)}
</div>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
<div className="space-y-2">
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
<div
@@ -1196,6 +1196,7 @@ export default function Dashboard() {
</div>
))}
</div>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
</>
)}
</Card>
+253
View File
@@ -0,0 +1,253 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react'
import { esignApi, employeeApi } from '../lib/api-services'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import Modal from '../components/ui/Modal'
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700' },
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger' },
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500' },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500' },
}
const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
CONTRACT: { label: '劳动合同', color: 'bg-blue-50 text-blue-600 border border-blue-200' },
RESIGNATION: { label: '离职协议', color: 'bg-orange-50 text-orange-600 border border-orange-200' },
POLICY: { label: '规章制度', color: 'bg-amber-50 text-amber-600 border border-amber-200' },
PAYSLIP: { label: '工资条', color: 'bg-emerald-50 text-emerald-600 border border-emerald-200' },
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
}
export default function ESign() {
const queryClient = useQueryClient()
const [filterStatus, setFilterStatus] = useState('')
const [filterScene, setFilterScene] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [formData, setFormData] = useState({
employeeId: '',
documentTitle: '',
remark: '',
})
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['esign-records', filterStatus, filterScene],
queryFn: async () => {
return await esignApi.list({ status: filterStatus || undefined, scene: filterScene || undefined })
},
})
const { data: rosterData = [] } = useQuery<any[]>({
queryKey: ['employees-for-esign'],
queryFn: async () => {
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showCreate,
})
const createMutation = useMutation({
mutationFn: async (data: { employeeId: string; documentTitle: string; remark?: string }) =>
esignApi.create(data) as any,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
setShowCreate(false)
setFormData({ employeeId: '', documentTitle: '', remark: '' })
toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接')
},
onError: () => toast.error('创建失败'),
})
const cancelMutation = useMutation({
mutationFn: (id: string) => esignApi.cancel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('已取消签署')
},
})
const refreshStatusMutation = useMutation({
mutationFn: (id: string) => esignApi.status(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('状态已刷新')
},
})
const handleCreate = () => {
if (!formData.employeeId) { toast.error('请选择员工'); return }
if (!formData.documentTitle.trim()) { toast.error('请填写文件标题'); return }
createMutation.mutate(formData)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PenTool className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500">线</p>
</div>
</div>
<PageGuide>
线PDF文件
<span className="text-amber-600"> API后将自动启用在线签署功能</span>
</PageGuide>
<InlineAlert type="info" className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<span className="font-medium">API</span>
<div className="mt-1 text-xs">
AppIdAppSecret API
HR在系统发起签署 PDF
</div>
</div>
</InlineAlert>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{Object.entries(STATUS_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filterScene}
onChange={(e) => setFilterScene(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 签署记录列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : records.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{records.map((r: any) => {
const statusCfg = STATUS_CONFIG[r.status] || STATUS_CONFIG.PENDING
return (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<div className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="font-medium truncate max-w-[200px]">{r.documentTitle}</span>
{r.scene && SCENE_CONFIG[r.scene] && (
<span className={`px-1.5 py-0.5 rounded text-xs shrink-0 ${SCENE_CONFIG[r.scene].color}`}>{SCENE_CONFIG[r.scene].label}</span>
)}
</div>
{r.remark && <div className="text-xs text-gray-400 mt-0.5">{r.remark}</div>}
</td>
<td className="py-2 px-3">{r.employee?.name || '—'}</td>
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>{statusCfg.label}</span>
</td>
<td className="py-2 px-3 text-gray-500 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
<td className="py-2 px-3 text-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td>
<td className="py-2 px-3 text-right">
<div className="flex items-center justify-end gap-1">
{r.status === 'COMPLETED' && r.signedPdfUrl && (
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-0.5">
<ExternalLink className="w-3 h-3" />PDF
</a>
)}
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
<>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => refreshStatusMutation.mutate(r.id)}
title="刷新状态">
<RefreshCw className={`w-3.5 h-3.5 ${refreshStatusMutation.isPending ? 'animate-spin' : ''}`} />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={() => {
if (confirm('确定取消此签署任务吗?')) cancelMutation.mutate(r.id)
}}></button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</Card>
{/* 发起签署 Modal */}
{showCreate && (
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
<div className="space-y-3">
<InlineAlert type="info">
</InlineAlert>
<div>
<Label> *</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={formData.employeeId}
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })}
>
<option value=""></option>
{rosterData.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</select>
</div>
<div>
<Label> *</Label>
<Input value={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })}
placeholder="如:2024年度劳动合同" />
</div>
<div>
<Label></Label>
<Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
placeholder="可选" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}></Button>
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? '创建中...' : '发起签署'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
+459
View File
@@ -0,0 +1,459 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Gift, Plus, Settings as SettingsIcon, X, Users } from 'lucide-react'
import { benefitApi, employeeApi } from '../lib/api-services'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import Modal from '../components/ui/Modal'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const BENEFIT_CATEGORIES: Record<string, { label: string; color: string }> = {
TRANSPORT: { label: '交通补贴', color: 'bg-blue-50 text-blue-700 border border-blue-200' },
MEAL: { label: '餐补', color: 'bg-orange-50 text-orange-700 border border-orange-200' },
HOUSING: { label: '住房补贴', color: 'bg-teal-50 text-teal-700 border border-teal-200' },
COMMUNICATION: { label: '通讯补贴', color: 'bg-purple-50 text-purple-700 border border-purple-200' },
HEALTH_CHECK: { label: '体检', color: 'bg-green-50 text-green-700 border border-green-200' },
HOLIDAY: { label: '节日福利', color: 'bg-red-50 text-red-700 border border-red-200' },
BIRTHDAY: { label: '生日福利', color: 'bg-pink-50 text-pink-700 border border-pink-200' },
OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' },
}
const FREQUENCY_LABELS: Record<string, string> = {
MONTHLY: '每月',
QUARTERLY: '每季',
YEARLY: '每年',
ONE_TIME: '一次性',
}
const DEFAULT_PLAN = {
name: '', category: 'TRANSPORT', amount: 0, frequency: 'MONTHLY',
taxDeductible: false, description: '',
}
export default function EmployeeBenefits() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [tab, setTab] = useState<'plans' | 'summary'>('plans')
const [showAddPlan, setShowAddPlan] = useState(false)
const [editingPlan, setEditingPlan] = useState<any>(null)
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
const [newPlan, setNewPlan] = useState<any>({ ...DEFAULT_PLAN })
const [showEnrollModal, setShowEnrollModal] = useState(false)
const [enrollEmployeeIds, setEnrollEmployeeIds] = useState<string[]>([])
const [enrollEffectiveFrom, setEnrollEffectiveFrom] = useState(new Date().toISOString().slice(0, 7))
const { data: plans = [], isLoading } = useQuery<any[]>({
queryKey: ['benefit-plans'],
queryFn: async () => {
return await benefitApi.plans()
},
})
const { data: enrollments = [], isLoading: enrollLoading } = useQuery<any[]>({
queryKey: ['benefit-enrollments', selectedPlanId],
queryFn: async () => {
if (!selectedPlanId) return []
return await benefitApi.enrollments(selectedPlanId)
},
enabled: !!selectedPlanId,
})
const { data: employeeSummary = [] } = useQuery<any[]>({
queryKey: ['benefit-employee-summary'],
queryFn: async () => {
return await benefitApi.employeeSummary()
},
enabled: tab === 'summary',
})
const { data: rosterData } = useQuery<any[]>({
queryKey: ['employees-for-benefit'],
queryFn: async () => {
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showEnrollModal,
})
const savePlanMutation = useMutation({
mutationFn: async (data: any) => {
if (editingPlan) {
return benefitApi.savePlan(data, editingPlan.id) as any
}
return benefitApi.savePlan(data) as any
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-plans'] })
setShowAddPlan(false)
setEditingPlan(null)
setNewPlan({ ...DEFAULT_PLAN })
toast.success(editingPlan ? '福利方案已更新' : '福利方案已创建')
},
onError: () => toast.error('保存失败'),
})
const deletePlanMutation = useMutation({
mutationFn: (id: string) => benefitApi.removePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-plans'] })
setSelectedPlanId(null)
toast.success('福利方案已删除')
},
})
const enrollMutation = useMutation({
mutationFn: async (data: { employeeIds: string[]; effectiveFrom: string }) =>
benefitApi.enroll(selectedPlanId!, data) as any,
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['benefit-enrollments'] })
queryClient.invalidateQueries({ queryKey: ['benefit-employee-summary'] })
setShowEnrollModal(false)
setEnrollEmployeeIds([])
toast.success(`已添加 ${res.data?.enrolled || 0} 名员工`)
},
onError: () => toast.error('参保失败'),
})
const terminateMutation = useMutation({
mutationFn: (enrollmentId: string) => benefitApi.terminateEnrollment(enrollmentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-enrollments'] })
queryClient.invalidateQueries({ queryKey: ['benefit-employee-summary'] })
toast.success('已终止福利')
},
})
const handleEdit = (plan: any) => {
setEditingPlan(plan)
setNewPlan({ ...plan })
setShowAddPlan(true)
}
const handleSave = () => {
if (!newPlan.name?.trim()) { toast.error('请填写方案名称'); return }
savePlanMutation.mutate(newPlan)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Gift className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
<PageGuide>
<span className="text-primary"> /</span>
</PageGuide>
{/* Tab 切换 */}
<div className="flex items-center gap-4 border-b">
{(['plans', 'summary'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setTab(t)}
>
{t === 'plans' ? '福利方案' : '员工汇总'}
</button>
))}
{tab === 'plans' && (
<Button size="sm" className="ml-auto" onClick={() => { setEditingPlan(null); setNewPlan({ ...DEFAULT_PLAN }); setShowAddPlan(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
)}
</div>
{/* ========== 福利方案 Tab ========== */}
{tab === 'plans' && (
<>
{isLoading ? (
<Card><div className="text-center py-8 text-gray-400">...</div></Card>
) : plans.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{plans.map((plan: any) => {
const catCfg = BENEFIT_CATEGORIES[plan.category] || BENEFIT_CATEGORIES.OTHER
const isSelected = selectedPlanId === plan.id
return (
<Card
key={plan.id}
className={`cursor-pointer transition-all ${isSelected ? 'ring-2 ring-primary/20' : 'hover:shadow-md'}`}
>
<div onClick={() => setSelectedPlanId(isSelected ? null : plan.id)}>
<div className="flex items-start justify-between mb-2">
<div>
<span className={`px-2 py-0.5 rounded text-xs ${catCfg.color}`}>{catCfg.label}</span>
<h3 className="text-sm font-medium mt-1">{plan.name}</h3>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => handleEdit(plan)}>
<SettingsIcon className="w-3.5 h-3.5" />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
if (await confirm({ title: '确认删除', message: `确定删除福利方案「${plan.name}」吗?` })) {
deletePlanMutation.mutate(plan.id)
}
}}>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="space-y-1 text-xs text-gray-500">
<div className="flex justify-between"><span></span><span className="text-gray-700">¥{fmt(plan.amount)} / {FREQUENCY_LABELS[plan.frequency] || plan.frequency}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan.taxDeductible ? '是' : '否'}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan._count?.enrollments || 0} </span></div>
</div>
{plan.description && <p className="text-xs text-gray-400 mt-2 line-clamp-2">{plan.description}</p>}
</div>
</Card>
)
})}
</div>
)}
{/* 参保人员 */}
{selectedPlanId && (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">{enrollments.length}</h3>
<Button size="sm" variant="secondary" onClick={() => { setEnrollEmployeeIds([]); setShowEnrollModal(true) }}>
<Users className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{enrollLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">...</div>
) : enrollments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{enrollments.map((e: any) => (
<tr key={e.id || e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{e.name}</td>
<td className="py-2 text-gray-500">{e.department}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveFrom || '—'}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveTo || '至今'}</td>
<td className="py-2">
<span className={`px-2 py-0.5 rounded text-xs ${e.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
{e.status === 'ACTIVE' ? '有效' : '已终止'}
</span>
</td>
<td className="py-2 text-right">
{e.status === 'ACTIVE' && (
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
if (await confirm({ title: '确认终止', message: `确定终止${e.name}的福利吗?` })) {
terminateMutation.mutate(e.id)
}
}}></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
</>
)}
{/* ========== 员工汇总 Tab ========== */}
{tab === 'summary' && (
<Card>
{employeeSummary.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{employeeSummary.map((e: any) => (
<tr key={e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{e.name}</td>
<td className="py-2 text-gray-500">{e.department}</td>
<td className="py-2">
<div className="flex flex-wrap gap-1">
{e.benefits.map((b: any, i: number) => (
<span key={i} className={`px-1.5 py-0.5 rounded text-xs ${BENEFIT_CATEGORIES[b.category]?.color || BENEFIT_CATEGORIES.OTHER.color}`}>
{b.planName} ¥{fmt(b.amount)}
</span>
))}
</div>
</td>
<td className="py-2 text-right font-medium text-primary">¥{fmt(e.totalMonthly)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 新增/编辑方案 Modal */}
{showAddPlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
<Card className="w-full max-w-lg mx-4">
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">{editingPlan ? '编辑福利方案' : '新增福利方案'}</h3>
<button onClick={() => setShowAddPlan(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={newPlan.name} onChange={(e) => setNewPlan({ ...newPlan, name: e.target.value })} placeholder="如:2024年度交通补贴" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.category}
onChange={(e) => setNewPlan({ ...newPlan, category: e.target.value })}
>
{Object.entries(BENEFIT_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.frequency}
onChange={(e) => setNewPlan({ ...newPlan, frequency: e.target.value })}
>
{Object.entries(FREQUENCY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={newPlan.amount} onChange={(e) => setNewPlan({ ...newPlan, amount: parseFloat(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.taxDeductible ? 'true' : 'false'}
onChange={(e) => setNewPlan({ ...newPlan, taxDeductible: e.target.value === 'true' })}
>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
</div>
<div>
<Label></Label>
<Input value={newPlan.description || ''} onChange={(e) => setNewPlan({ ...newPlan, description: e.target.value })} placeholder="适用条件、发放规则等" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowAddPlan(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={savePlanMutation.isPending}>
{savePlanMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</div>
</Card>
</div>
)}
{/* 批量参保 Modal */}
{showEnrollModal && (
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="为员工添加福利" size="lg">
<div className="space-y-3">
<InlineAlert type="info">
</InlineAlert>
<div className="flex items-center gap-2">
<Label className="shrink-0"></Label>
<Input type="month" value={enrollEffectiveFrom} onChange={(e) => setEnrollEffectiveFrom(e.target.value)} className="!w-32" />
</div>
<div className="max-h-80 overflow-y-auto border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left w-8">
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.length || 0) && enrollEmployeeIds.length > 0}
onChange={(e) => {
if (e.target.checked) {
setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || [])
} else {
setEnrollEmployeeIds([])
}
}} />
</th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
</tr>
</thead>
<tbody>
{rosterData?.map((emp: any) => (
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<input type="checkbox" checked={enrollEmployeeIds.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) setEnrollEmployeeIds([...enrollEmployeeIds, emp.id])
else setEnrollEmployeeIds(enrollEmployeeIds.filter((id) => id !== emp.id))
}} />
</td>
<td className="py-2 px-3 font-medium">{emp.name}</td>
<td className="py-2 px-3 text-gray-500">{emp.department}</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"> {enrollEmployeeIds.length} </span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowEnrollModal(false)}></Button>
<Button size="sm" onClick={() => enrollMutation.mutate({ employeeIds: enrollEmployeeIds, effectiveFrom: enrollEffectiveFrom })}
disabled={enrollEmployeeIds.length === 0 || enrollMutation.isPending}>
{enrollMutation.isPending ? '参保中...' : '确认参保'}
</Button>
</div>
</div>
</div>
</Modal>
)}
</div>
)
}
+17 -3
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery } from '@tanstack/react-query'
import { ShieldCheck, FileText, CheckCircle, XCircle } from 'lucide-react'
import { evidenceApi } from '../lib/api-services'
@@ -13,8 +14,8 @@ import QueryError from '../components/ui/QueryError'
*/
export default function Evidence() {
const [refType, setRefType] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['evidence', refType, page, pageSize],
@@ -66,10 +67,23 @@ export default function Evidence() {
)}
<span className="text-sm font-medium">
{verifyResult?.invalid === 0
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改`
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改${verifyResult?.repaired ? `(自动修复 ${verifyResult.repaired} 条历史数据)` : ''}`
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
</span>
</div>
{verifyResult?.invalidItems?.length > 0 && (
<div className="mt-3 space-y-1.5">
{verifyResult.invalidItems.map((item: any) => (
<div key={item.id} className="flex items-center justify-between px-3 py-2 rounded bg-red-50 border border-red-200 text-sm">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-500 shrink-0" />
<span className="text-red-700">{item.description}</span>
</div>
<span className="text-xs text-gray-400">{new Date(item.createdAt).toLocaleString('zh-CN')}</span>
</div>
))}
</div>
)}
</Card>
)}
@@ -128,7 +142,7 @@ export default function Evidence() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</div>
)
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
@@ -30,8 +31,8 @@ const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
export default function LeaveApproval() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filterStatus, setFilterStatus] = useState('')
const [filterType, setFilterType] = useState('')
const [approveModal, setApproveModal] = useState<{ id: string; action: string; name: string } | null>(null)
@@ -143,7 +144,6 @@ export default function LeaveApproval() {
<EmptyState title="暂无休假申请" description="员工在手机端提交的休假申请将显示在此处" />
) : (
<>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -220,6 +220,7 @@ export default function LeaveApproval() {
</table>
</div>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
+5 -2
View File
@@ -1,4 +1,5 @@
import { useState, lazy, Suspense } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react'
const BatchManager = lazy(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
@@ -9,7 +10,9 @@ const PayslipManager = lazy(() => import('./money/PayslipTab').then(m => ({ defa
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('batch')
const [searchParams] = useSearchParams()
const initialEmployeeId = searchParams.get('employeeId') || ''
const [tab, setTab] = useState<Tab>(initialEmployeeId ? 'payslip' : 'batch')
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
@@ -47,7 +50,7 @@ export default function Money() {
{tab === 'batch' && <BatchManager />}
{tab === 'template' && <TemplateManager />}
{tab === 'overtime' && <OvertimeCalculator />}
{tab === 'payslip' && <PayslipManager />}
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
</Suspense>
</div>
)
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Bell, CheckCircle, AlertCircle, Send, Settings as SettingsIcon, X } from 'lucide-react'
@@ -32,8 +33,8 @@ const CHANNEL_LABELS: Record<string, string> = {
*/
export default function Notifications() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showSettings, setShowSettings] = useState(false)
const { data, isLoading } = useQuery<any>({
@@ -141,7 +142,7 @@ export default function Notifications() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
+65 -7
View File
@@ -1,7 +1,9 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell, Upload } from 'lucide-react'
import mammoth from 'mammoth'
import { policiesApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -26,8 +28,8 @@ export default function Policies() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedPolicy, setSelectedPolicy] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['policies', page, pageSize],
@@ -130,7 +132,7 @@ export default function Policies() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
{/* 详情弹窗 */}
@@ -200,6 +202,7 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [type, setType] = useState('RULES')
const fileInputRef = useRef<HTMLInputElement>(null)
const createMutation = useMutation({
mutationFn: () => policiesApi.create({ title, content, type }),
@@ -229,7 +232,32 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
</div>
<div>
<label className="text-xs text-gray-600"></label>
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文..." />
<div className="flex items-center gap-2 mt-1 mb-1">
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
<Upload className="w-3.5 h-3.5 mr-1" /> Word
</Button>
<input
ref={fileInputRef}
type="file"
accept=".docx"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.convertToHtml({ arrayBuffer })
setContent(result.value)
toast.success('文档导入成功')
} catch {
toast.error('文档解析失败,请确保为 .docx 格式')
}
e.target.value = ''
}}
/>
<span className="text-xs text-gray-400"> .docx HTML</span>
</div>
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文或导入 Word 文档..." />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
@@ -246,6 +274,8 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
*
*/
function ReadStats({ policyId }: { policyId: string }) {
const queryClient = useQueryClient()
const [showUnread, setShowUnread] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['policy-read-stats', policyId],
queryFn: async () => {
@@ -253,6 +283,17 @@ function ReadStats({ policyId }: { policyId: string }) {
},
})
const remindMutation = useMutation({
mutationFn: async (employeeIds?: string[]) => {
return await policiesApi.remind(policyId, employeeIds)
},
onSuccess: (res: any) => {
toast.success(`已催办 ${res?.reminded || 0} 名未签收员工`)
queryClient.invalidateQueries({ queryKey: ['policy-read-stats', policyId] })
},
onError: () => toast.error('催办失败'),
})
if (isLoading) return <div className="text-xs text-gray-400 mt-3">...</div>
if (!data) return null
@@ -270,8 +311,25 @@ function ReadStats({ policyId }: { policyId: string }) {
</span>
</div>
{data.unreadCount > 0 && (
<div className="text-xs text-amber-600 mb-2">
{data.unreadCount}
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-amber-600">{data.unreadCount} </span>
<button onClick={() => setShowUnread(!showUnread)} className="text-xs text-primary hover:underline">
{showUnread ? '收起' : '查看明细'}
</button>
<Button size="sm" variant="secondary" className="!h-6 !px-2 !text-xs" onClick={() => remindMutation.mutate(undefined)} disabled={remindMutation.isPending}>
<Bell className="w-3 h-3 mr-1" />
</Button>
</div>
)}
{showUnread && data.unreadEmployees && data.unreadEmployees.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1 mb-2">
{data.unreadEmployees.map((r: any) => (
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-amber-50 text-xs">
<span className="text-gray-700">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
<span className="text-amber-600"></span>
</div>
))}
</div>
)}
{data.records && data.records.length > 0 && (
+193 -53
View File
@@ -1,10 +1,13 @@
import { useState, useMemo, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download } from 'lucide-react'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
import { copyToClipboard } from '../lib/clipboard'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
import Card from '../components/ui/Card'
@@ -20,10 +23,47 @@ import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChan
import { ImportSettings } from './Settings'
import QueryError from '../components/ui/QueryError'
const ROSTER_COLUMNS = [
{ key: 'department', label: '部门' },
{ key: 'status', label: '状态' },
{ key: 'hireDate', label: '入职日期' },
{ key: 'position', label: '职务' },
{ key: 'phone', label: '手机号' },
{ key: 'gender', label: '性别' },
{ key: 'contractType', label: '合同类型' },
{ key: 'contractStatus', label: '合同状态' },
{ key: 'contractExpiry', label: '合同到期' },
{ key: 'socialStatus', label: '社保状态' },
{ key: 'socialInsBase', label: '社保基数' },
{ key: 'socialInsAmount', label: '社保缴费' },
{ key: 'records', label: '记录' },
] as const
const DEFAULT_VISIBLE = ['department', 'status', 'hireDate', 'position', 'phone', 'gender', 'contractType', 'contractStatus', 'contractExpiry', 'socialStatus']
function useRosterColumns() {
const [visible, setVisible] = useState<string[]>(() => {
try {
const saved = localStorage.getItem('roster-columns')
return saved ? JSON.parse(saved) : DEFAULT_VISIBLE
} catch { return DEFAULT_VISIBLE }
})
const toggle = (key: string) => {
setVisible(prev => {
const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
localStorage.setItem('roster-columns', JSON.stringify(next))
return next
})
}
const isVisible = (key: string) => visible.includes(key)
return { isVisible, toggle, visible }
}
export default function Roster() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
@@ -37,8 +77,8 @@ export default function Roster() {
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
const [showDeptModal, setShowDeptModal] = useState(false)
const [deptEmployee, setDeptEmployee] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
const [batchRenewYears, setBatchRenewYears] = useState(3)
@@ -71,9 +111,16 @@ export default function Roster() {
})
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
// 从 URL query 参数自动定位员工(从待办跳转时)
// 从 URL query 参数自动定位员工(从待办跳转或绩效/培训列表跳转时)
const employeeParam = searchParams.get('employee')
const employeeIdParam = searchParams.get('employeeId')
useEffect(() => {
// 优先按 employeeId 直接定位
if (employeeIdParam && !selectedId) {
setSelectedId(employeeIdParam)
setSearchParams({}, { replace: true })
return
}
if (!employeeParam) return
setSearch(employeeParam)
// 只有当 debouncedSearch 与参数一致时,搜索结果才真正匹配
@@ -82,7 +129,7 @@ export default function Roster() {
// 清除 URL 参数,避免后续搜索时重复触发
setSearchParams({}, { replace: true })
}
}, [employeeParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
const addMutation = useMutation({
mutationFn: (data: any) => employeeApi.create(data),
@@ -90,8 +137,13 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
toast.success('员工已添加', {
action: { label: '前往用工办理', onClick: () => navigate('/work-process') },
})
},
onError: (err: any) => toastError(err, '创建失败'),
})
const resignMutation = useMutation({
@@ -108,7 +160,9 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程')
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程', {
action: { label: '前往处理', onClick: () => navigate('/termination') },
})
setShowResignModal(false)
setResignEmployee(null)
},
@@ -264,10 +318,14 @@ export default function Roster() {
},
})
const { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
const [showColSettings, setShowColSettings] = useState(false)
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search) || (e.idCardMasked && e.idCardMasked.includes(search))
) || []
// 通讯录视图数据
if (selectedId) {
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
}
@@ -275,7 +333,7 @@ export default function Roster() {
return (
<div className="space-y-5">
<PageGuide>
</PageGuide>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
@@ -330,6 +388,9 @@ export default function Roster() {
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
<Plus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => navigate('/work-process')} className="h-9 shrink-0" title="完整的入职办理流程">
<UserPlus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
@@ -355,6 +416,30 @@ export default function Roster() {
}} className="h-9 shrink-0">
<Download className="mr-1.5 h-4 w-4" />
</Button>
<div className="relative shrink-0">
<Button variant="secondary" onClick={() => setShowColSettings(v => !v)} className="h-9">
<Settings2 className="mr-1.5 h-4 w-4" />
</Button>
{showColSettings && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowColSettings(false)} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-lg border border-gray-200 bg-white shadow-lg py-2">
<div className="px-3 pb-1 text-xs font-medium text-gray-400"></div>
{ROSTER_COLUMNS.map(col => (
<label key={col.key} className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={colVisible(col.key)}
onChange={() => colToggle(col.key)}
className="rounded border-gray-300"
/>
<span className="text-gray-700">{col.label}</span>
</label>
))}
</div>
</>
)}
</div>
</div>
</div>
@@ -418,37 +503,28 @@ export default function Roster() {
<Card><div className="py-12 text-center text-sm text-gray-400"></div></Card>
) : (
<Card className="overflow-hidden p-0">
<div className="border-b border-gray-100 px-5">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] text-sm">
<table className="w-full min-w-[900px] text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left w-8">
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
{colVisible('department') && <th className="px-4 py-3 text-left"></th>}
{colVisible('status') && <th className="px-4 py-3 text-left"></th>}
{colVisible('hireDate') && <th className="px-4 py-3 text-left"></th>}
<th className="hidden px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
{colVisible('position') && <th className="px-4 py-3 text-left"></th>}
{colVisible('phone') && <th className="px-4 py-3 text-left"></th>}
{colVisible('gender') && <th className="px-4 py-3 text-center"></th>}
{colVisible('contractType') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractExpiry') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialInsBase') && <th className="px-4 py-3 text-right"></th>}
{colVisible('socialInsAmount') && <th className="px-4 py-3 text-right"></th>}
{colVisible('records') && <th className="px-4 py-3 text-center"></th>}
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
@@ -462,10 +538,17 @@ export default function Roster() {
<td className="px-4 py-3" onClick={(ev) => ev.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
</td>
<td className="px-4 py-3 font-medium">{e.name}</td>
<td className="px-4 py-3 text-gray-500 text-xs font-mono">{e.idCardMasked || '—'}</td>
<td className="px-4 py-3 text-gray-500">{e.department}</td>
<td className="px-4 py-3">
<td className="px-4 py-3 font-medium">
<div>{e.name}</div>
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
ev.stopPropagation()
if (e.idCardNumber) {
copyToClipboard(e.idCardNumber, '已复制身份证号')
}
}}>{e.idCardMasked || '—'}</div>
</td>
{colVisible('department') && <td className="px-4 py-3 text-gray-500">{e.department}</td>}
{colVisible('status') && <td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
@@ -482,8 +565,8 @@ export default function Roster() {
{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `${e.probationInfo.daysToConfirm}`}
</span>
)}
</td>
<td className="hidden px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
</td>}
{colVisible('hireDate') && <td className="px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
<td className="hidden px-4 py-3 text-gray-500">
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
@@ -494,8 +577,10 @@ export default function Roster() {
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-3 text-right">¥{fmt(e.monthlySalary)}</td>
<td className="px-4 py-3">
{colVisible('position') && <td className="px-4 py-3 text-gray-500">{e.position || '—'}</td>}
{colVisible('phone') && <td className="px-4 py-3 text-gray-500">{e.phone || '—'}</td>}
{colVisible('gender') && <td className="px-4 py-3 text-center text-gray-500">{e.gender || '—'}</td>}
{colVisible('contractType') && <td className="px-4 py-3">
{(() => {
const typeConfig: Record<string, { label: string; style: string }> = {
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
@@ -511,8 +596,8 @@ export default function Roster() {
const cfg = typeConfig[ct] || typeConfig.UNSIGNED
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
})()}
</td>
<td className="px-4 py-3">
</td>}
{colVisible('contractStatus') && <td className="px-4 py-3">
{(() => {
const tagStyles: Record<string, string> = {
expired: 'bg-red-50 text-danger',
@@ -536,8 +621,8 @@ export default function Roster() {
const text = statusTextMap[e.contractStatus] || '无合同'
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
})()}
</td>
<td className="hidden px-4 py-3 text-gray-500">
</td>}
{colVisible('contractExpiry') && <td className="px-4 py-3 text-gray-500">
{(() => {
const endDate = e.latestContract?.endDate
if (!endDate) {
@@ -556,16 +641,62 @@ export default function Roster() {
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{dateStr} ({diffDays})</span>
return <span className="text-xs">{dateStr}</span>
})()}
</td>
<td className="px-4 py-3 text-center">
{e.counts?.disciplinaryRecords ? (
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
) : <span className="text-gray-300">0</span>}
</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
</td>}
{colVisible('socialStatus') && <td className="px-4 py-3">
{(() => {
const status = e.socialInsuranceStatus
if (!status) return <span className="text-gray-300 text-xs"></span>
const cfg: Record<string, { label: string; style: string }> = {
ACTIVE: { label: '在保', style: 'bg-green-50 text-safe' },
SUSPENDED: { label: '停保', style: 'bg-amber-50 text-amber-600' },
UNINSURED: { label: '未参保', style: 'bg-red-50 text-danger' },
PENDING: { label: '待办理', style: 'bg-blue-50 text-blue-600' },
}
const c = cfg[status] || { label: status, style: 'bg-gray-100 text-gray-500' }
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
})()}
</td>}
{colVisible('socialInsBase') && <td className="px-4 py-3 text-right text-xs">
{e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300"></span>}
</td>}
{colVisible('socialInsAmount') && <td className="px-4 py-3 text-right text-xs">
{(() => {
if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300"></span>
const socialEmp = e.socialInsCalc?.socialEmp || 0
const socialOrg = e.socialInsCalc?.socialOrg || 0
const housingEmp = e.housingFundCalc?.housingEmp || 0
const housingOrg = e.housingFundCalc?.housingOrg || 0
const totalEmp = socialEmp + housingEmp
const totalOrg = socialOrg + housingOrg
return (
<div className="flex flex-col">
<span>: {totalEmp.toFixed(2)}</span>
<span className="text-gray-400">: {totalOrg.toFixed(2)}</span>
</div>
)
})()}
</td>}
{colVisible('records') && <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1 flex-wrap">
{(() => {
const items = [
{ label: '违纪', count: e.counts?.disciplinaryRecords || 0, danger: true },
{ label: '考勤', count: e.counts?.attendanceRecords || 0 },
{ label: '培训', count: e.counts?.trainingRecords || 0 },
{ label: '绩效', count: e.counts?.performanceRecords || 0 },
{ label: '工资条', count: e.counts?.payslips || 0 },
]
return items.map((it, i) => (
<span
key={i}
className={`text-xs px-1.5 py-0.5 rounded ${it.count > 0 ? (it.danger ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-600') : 'text-gray-300'}`}
>
{it.label}{it.count}
</span>
))
})()}
</div>
</td>}
<td className="px-4 py-3 text-center">
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
<div className="flex items-center justify-center gap-2">
@@ -589,7 +720,7 @@ export default function Roster() {
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
window.location.hash = '#/money'
navigate('/money')
}}
>
<Wallet className="h-4 w-4" />
@@ -667,6 +798,15 @@ export default function Roster() {
</tbody>
</table>
</div>
<div className="border-t border-gray-100 px-5 py-3">
<Pagination
page={pagination.page}
pageSize={pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={() => setPage(1)}
/>
</div>
</Card>
)}
+139 -96
View File
@@ -4,7 +4,7 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3, TrendingUp, TrendingDown, Users, Wallet,
BarChart3, TrendingUp, TrendingDown, Users, Wallet, DollarSign, Gauge,
} from 'lucide-react'
import Card from '../components/ui/Card'
import { Select } from '../components/ui/Input'
@@ -15,11 +15,15 @@ import { salaryDashboardApi } from '../lib/api-services'
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const fmtShort = (n: number) => {
if (!n) return '0'
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return n.toFixed(0)
}
export default function SalaryDashboard() {
const [year, setYear] = useState(new Date().getFullYear().toString())
/** 获取薪酬分析数据 */
const { data, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['salary-dashboard', year],
queryFn: async () => {
@@ -31,17 +35,25 @@ export default function SalaryDashboard() {
const monthlyTrend = data?.monthlyTrend || []
const summary = data?.summary || {}
/** 计算最大值用于柱状图比例 */
const maxDeptAvg = useMemo(() => {
if (departments.length === 0) return 1
return Math.max(...departments.map((d: any) => d.avgSalary || 0), 1)
}, [departments])
const maxMonthly = useMemo(() => {
if (monthlyTrend.length === 0) return 1
return Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1)
}, [monthlyTrend])
const summaryCards = [
{ icon: Users, label: '员工总数', value: summary.totalEmployees || 0, suffix: '', color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-100' },
{ icon: Wallet, label: '月均薪酬', value: `¥${fmt(summary.avgSalary)}`, suffix: '', color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-100' },
{ icon: Gauge, label: '薪酬中位数', value: `¥${fmt(summary.medianSalary)}`, suffix: '', color: 'text-purple-600', bg: 'bg-purple-50', border: 'border-purple-100' },
{ icon: DollarSign, label: '年度总薪酬', value: `¥${fmt(summary.totalAnnual)}`, suffix: '', color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-100' },
]
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -58,6 +70,11 @@ export default function SalaryDashboard() {
</Select>
</div>
{/* 操作说明 — 紧跟标题下方 */}
<PageGuide>
</PageGuide>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : isError ? (
@@ -68,110 +85,136 @@ export default function SalaryDashboard() {
<>
{/* 概览卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="p-3">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">{summary.totalEmployees || 0}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-emerald-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.avgSalary)}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.medianSalary)}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-amber-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.totalAnnual)}</div>
</Card>
{summaryCards.map((card, i) => {
const Icon = card.icon
return (
<Card key={i} className={`p-4 border ${card.border}`}>
<div className="flex items-center justify-between">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${card.bg}`}>
<Icon className={`w-4 h-4 ${card.color}`} />
</div>
</div>
<div className="text-xs text-gray-400 mt-2">{card.label}</div>
<div className={`text-xl font-bold mt-0.5 ${card.color}`}>{card.value}{card.suffix}</div>
</Card>
)
})}
</div>
{/* 同比环比 */}
{summary.yoy !== undefined && (
<div className="flex gap-3">
<Card className="flex-1 p-3">
<div className="text-xs text-gray-400"></div>
<div className={`text-lg font-bold mt-1 flex items-center gap-1 ${summary.yoy >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.yoy >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.yoy >= 0 ? '+' : ''}{(summary.yoy || 0).toFixed(1)}%
<div className="grid grid-cols-2 gap-3">
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className={`flex items-center gap-1 text-sm font-bold ${summary.yoy >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.yoy >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.yoy >= 0 ? '+' : ''}{(summary.yoy || 0).toFixed(1)}%
</div>
</div>
</Card>
<Card className="flex-1 p-3">
<div className="text-xs text-gray-400"></div>
<div className={`text-lg font-bold mt-1 flex items-center gap-1 ${summary.mom >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.mom >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.mom >= 0 ? '+' : ''}{(summary.mom || 0).toFixed(1)}%
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className={`flex items-center gap-1 text-sm font-bold ${summary.mom >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.mom >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.mom >= 0 ? '+' : ''}{(summary.mom || 0).toFixed(1)}%
</div>
</div>
</Card>
</div>
)}
{/* 部门薪酬对比 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
{departments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-3">
{departments.map((dept: any) => (
<div key={dept.name}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-600">{dept.name}</span>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{dept.count}</span>
<span className="font-medium text-gray-700">¥{fmt(dept.avgSalary)}</span>
</div>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${(dept.avgSalary / maxDeptAvg) * 100}%` }}
/>
</div>
</div>
))}
{/* 部门薪酬对比 + 月度趋势 双栏布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 部门薪酬对比 */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
{departments.length > 0 && (
<span className="text-xs text-gray-400"> {departments.length} </span>
)}
</div>
)}
</Card>
{departments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-2.5 max-h-[420px] overflow-y-auto pr-1">
{departments.map((dept: any, idx: number) => {
const pct = (dept.avgSalary / maxDeptAvg) * 100
const isTop3 = idx < 3
return (
<div key={dept.name} className="group">
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center gap-2 min-w-0">
{isTop3 && (
<span className={`flex-shrink-0 w-4 h-4 rounded text-[10px] flex items-center justify-center font-bold ${
idx === 0 ? 'bg-amber-100 text-amber-700' : idx === 1 ? 'bg-gray-200 text-gray-600' : 'bg-orange-100 text-orange-700'
}`}>{idx + 1}</span>
)}
<span className="text-gray-600 truncate">{dept.name}</span>
</div>
<div className="flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-gray-400">{dept.count}</span>
<span className="text-gray-400"></span>
<span className="font-semibold text-gray-800">¥{fmt(dept.avgSalary)}</span>
</div>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all group-hover:opacity-80 ${
isTop3 ? 'bg-gradient-to-r from-primary to-primary/70' : 'bg-primary/40'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
</Card>
{/* 月度趋势 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
{monthlyTrend.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="flex items-end gap-2 h-40">
{monthlyTrend.map((m: any) => {
const maxVal = Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1)
const height = ((m.total || 0) / maxVal) * 100
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1">
<div className="text-xs text-gray-400">{m.total ? `¥${(m.total / 10000).toFixed(1)}` : ''}</div>
<div className="w-full bg-gray-100 rounded-t-md flex-1 flex items-end overflow-hidden">
<div
className="w-full bg-primary/70 rounded-t-md transition-all hover:bg-primary"
style={{ height: `${height}%` }}
/>
</div>
<div className="text-xs text-gray-400">{m.month}</div>
</div>
)
})}
{/* 月度趋势 */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
{monthlyTrend.length > 0 && (
<span className="text-xs text-gray-400"></span>
)}
</div>
)}
</Card>
{monthlyTrend.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="relative">
{/* 网格参考线 */}
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none">
{[0, 1, 2, 3].map(i => (
<div key={i} className="border-t border-dashed border-gray-100" />
))}
</div>
<div className="flex items-end gap-1.5 h-48 relative">
{monthlyTrend.map((m: any) => {
const height = ((m.total || 0) / maxMonthly) * 100
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1 group cursor-pointer">
<div className="text-[10px] text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{m.total ? `¥${fmtShort(m.total)}` : ''}
</div>
<div className="w-full bg-gray-50 rounded-t-md flex-1 flex items-end overflow-hidden">
<div
className="w-full bg-gradient-to-t from-primary/60 to-primary rounded-t-md transition-all group-hover:from-primary group-hover:to-primary/80"
style={{ height: `${height}%` }}
/>
</div>
<div className="text-[10px] text-gray-400">{m.month}</div>
</div>
)
})}
</div>
</div>
)}
</Card>
</div>
{summary.totalEmployees === 0 && (
<InlineAlert type="info">
+308 -71
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList } from 'lucide-react'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2 } from 'lucide-react'
import { settingsApi, notificationsApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -13,7 +14,7 @@ import { useConfirm } from '../hooks/useConfirm'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'import' | 'export'>('org')
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'medical' | 'import' | 'export'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
@@ -39,6 +40,8 @@ export default function Settings() {
{ key: 'users' as const, label: '用户管理', icon: Users },
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
{ key: 'medical' as const, label: '医疗期政策', icon: HeartPulse },
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
{ key: 'export' as const, label: '数据导出', icon: Download },
]
@@ -77,24 +80,31 @@ export default function Settings() {
{activeSection === 'users' && <UserSettings usersData={usersData} />}
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
{activeSection === 'notifications' && <NotificationSettings />}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<ExportSettings />
</Card>
{activeSection === 'retirement' && (
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
)}
{activeSection === 'medical' && <MedicalPeriodSettings />}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && <ExportSettings />}
</div>
)
}
function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: any) => void; saving: boolean }) {
const [pageSize, setPageSize] = useState(getPageSize())
const [form, setForm] = useState({
name: '',
contactName: '',
contactPhone: '',
payrollFrequency: 1,
payrollDays: [5] as number[],
payrollReminderDays: 3,
retirementReminderEnabled: false,
esignPolicyEnabled: false,
esignPayslipEnabled: false,
esignOnboardingEnabled: false,
esignTrainingEnabled: false,
esignPerformanceEnabled: false,
esignDisciplinaryEnabled: false,
})
useEffect(() => {
@@ -103,8 +113,15 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
name: orgData.name || '',
contactName: orgData.contactName || '',
contactPhone: orgData.contactPhone || '',
payrollFrequency: orgData.payrollFrequency || 1,
payrollDays: Array.isArray(orgData.payrollDays) && orgData.payrollDays.length > 0 ? orgData.payrollDays : [5],
payrollReminderDays: orgData.payrollReminderDays ?? 3,
retirementReminderEnabled: orgData.retirementReminderEnabled || false,
esignPolicyEnabled: orgData.esignPolicyEnabled || false,
esignPayslipEnabled: orgData.esignPayslipEnabled || false,
esignOnboardingEnabled: orgData.esignOnboardingEnabled || false,
esignTrainingEnabled: orgData.esignTrainingEnabled || false,
esignPerformanceEnabled: orgData.esignPerformanceEnabled || false,
esignDisciplinaryEnabled: orgData.esignDisciplinaryEnabled || false,
})
}
}, [orgData])
@@ -126,28 +143,111 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
</div>
<div>
<Label></Label>
<Select value={String(form.payrollFrequency)} onChange={(e) => setForm({ ...form, payrollFrequency: Number(e.target.value) })}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</Select>
<p className="text-xs text-gray-500 mt-1"></p>
<Label></Label>
<p className="text-xs text-gray-500 mt-1 mb-2"></p>
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<button
key={day}
type="button"
onClick={() => {
const days = form.payrollDays.includes(day)
? form.payrollDays.filter(d => d !== day)
: [...form.payrollDays, day].sort((a, b) => a - b)
setForm({ ...form, payrollDays: days })
}}
className={`w-9 h-9 rounded-md text-xs font-medium border transition-colors ${
form.payrollDays.includes(day)
? 'bg-primary text-white border-primary'
: 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'
}`}
>
{day}
</button>
))}
</div>
{form.payrollDays.length > 0 && (
<p className="text-xs text-gray-500 mt-2"> {form.payrollDays.map(d => `${d}`).join('、')} </p>
)}
</div>
<div>
<Label></Label>
<div className="flex items-center gap-3">
<Input type="number" min={0} max={30} value={form.payrollReminderDays} onChange={(e) => setForm({ ...form, payrollReminderDays: Number(e.target.value) })} className="!w-24" />
<span className="text-xs text-gray-500"></span>
</div>
</div>
<Button onClick={() => onSave(form)} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</div>
<RetirementSection enabled={form.retirementReminderEnabled} onToggle={(v) => { setForm({ ...form, retirementReminderEnabled: v }); onSave({ ...form, retirementReminderEnabled: v }) }} />
{/* 电子签署设置 */}
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-4">
<PenTool className="w-5 h-5 text-primary" />
<h3 className="font-medium"></h3>
</div>
<div className="grid grid-cols-2 gap-4">
{([
{ key: 'esignPolicyEnabled', label: '规章制度电子签', desc: '开启后,员工阅读规章制度时需电子签署;关闭时保持阅读确认' },
{ key: 'esignPayslipEnabled', label: '工资条电子签', desc: '开启后,员工确认工资条时需电子签署;关闭时保持点击确认' },
{ key: 'esignOnboardingEnabled', label: '入职文件电子签', desc: '开启后,HR审批通过入职流程时自动创建入职文件签署;关闭时不签' },
{ key: 'esignTrainingEnabled', label: '培训记录电子签', desc: '开启后,员工签收培训记录时需电子签署;关闭时保持点击签收' },
{ key: 'esignPerformanceEnabled', label: '绩效考核电子签', desc: '开启后,员工签字确认绩效时需电子签署;关闭时保持点击确认' },
{ key: 'esignDisciplinaryEnabled', label: '违纪记录电子签', desc: '开启后,员工签字确认违纪记录时需电子签署;关闭时保持点击确认' },
] as const).map((item) => (
<div key={item.key} className="flex items-center justify-between border rounded-lg p-3">
<div className="flex-1 mr-3">
<div className="text-sm font-medium">{item.label}</div>
<p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>
</div>
<button
type="button"
onClick={() => {
const next = !form[item.key]
setForm({ ...form, [item.key]: next })
onSave({ [item.key]: next })
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${form[item.key] ? 'bg-primary' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${form[item.key] ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
))}
</div>
</div>
{/* 显示设置 */}
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-4">
<LayoutGrid className="w-5 h-5 text-primary" />
<h3 className="font-medium"></h3>
</div>
<div className="space-y-4">
<div>
<Label></Label>
<p className="text-xs text-gray-500 mt-1 mb-2"></p>
<div className="flex items-center gap-3">
<Select value={String(pageSize)} onChange={(e) => setPageSize(parseInt(e.target.value, 10))} className="!w-32">
<option value="10">10 /</option>
<option value="20">20 /</option>
<option value="50">50 /</option>
<option value="100">100 /</option>
</Select>
<Button size="sm" onClick={() => { setGlobalPageSize(pageSize); toast.success(`分页大小已设置为 ${pageSize} 条/页`) }}></Button>
</div>
</div>
</div>
</div>
</Card>
)
}
function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle: (v: boolean) => void }) {
function RetirementSettings({ orgData, onSave }: { orgData: any; onSave: (data: any) => void }) {
const queryClient = useQueryClient()
const [confirming, setConfirming] = useState(false)
const enabled = orgData?.retirementReminderEnabled || false
const { data: policyData, isLoading } = useQuery<any>({
queryKey: ['retirement-policy'],
@@ -195,7 +295,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
)
return (
<div className="mt-6 pt-6 border-t">
<Card>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-primary" />
@@ -214,7 +314,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
<span className="text-sm text-gray-500">{enabled ? '已开启' : '未开启'}</span>
<button
type="button"
onClick={() => onToggle(!enabled)}
onClick={() => onSave({ retirementReminderEnabled: !enabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
@@ -259,7 +359,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
{isLoading && <p className="text-sm text-gray-400 text-center">...</p>}
</div>
)}
</div>
</Card>
)
}
@@ -705,7 +805,6 @@ function PlanSettings({ orgData }: { orgData: any }) {
function NotificationSettings() {
const queryClient = useQueryClient()
const [form, setForm] = useState<any>({})
const [checkResult, setCheckResult] = useState<string>('')
const { data: setting } = useQuery<any>({
queryKey: ['notification-settings'],
@@ -714,13 +813,6 @@ function NotificationSettings() {
},
})
const { data: logsData } = useQuery<any>({
queryKey: ['notification-logs'],
queryFn: async () => {
return await notificationsApi.logs({ pageSize: 10 })
},
})
useEffect(() => {
if (setting) setForm(setting)
}, [setting])
@@ -730,14 +822,6 @@ function NotificationSettings() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-settings'] }),
})
const checkMutation = useMutation({
mutationFn: () => notificationsApi.checkContracts() as any,
onSuccess: (res: any) => {
setCheckResult(`检查完成:发现 ${res.data.checked} 个即将到期的合同,已发送 ${res.data.notified} 条通知`)
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
},
})
const testWechatMutation = useMutation({
mutationFn: () => notificationsApi.test('wechat') as any,
onSuccess: (res: any) => {
@@ -752,8 +836,6 @@ function NotificationSettings() {
},
})
const logs = logsData?.items || []
return (
<div className="space-y-3">
<Card>
@@ -782,11 +864,7 @@ function NotificationSettings() {
<div className="border-t pt-3 space-y-3">
<div className="text-xs font-medium"></div>
<div className="text-xs text-gray-500"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.payrollDay ?? 10} onChange={(e) => setForm({ ...form, payrollDay: Number(e.target.value) })} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.socialInsDay ?? 15} onChange={(e) => setForm({ ...form, socialInsDay: Number(e.target.value) })} />
@@ -830,31 +908,6 @@ function NotificationSettings() {
</Button>
</div>
</Card>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => checkMutation.mutate()} disabled={checkMutation.isPending}>
{checkMutation.isPending ? '检查中...' : '立即检查'}
</Button>
</div>
{checkResult && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs mb-3">{checkResult}</div>
)}
{logs.length > 0 ? (
<div className="space-y-2">
{logs.map((log: any) => (
<div key={log.id} className="text-xs border-b last:border-0 py-2">
<div className="font-medium">{log.title}</div>
<div className="text-gray-500 text-xs mt-0.5">{log.content}</div>
<div className="text-gray-500 text-xs mt-0.5">{new Date(log.createdAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-xs text-center py-4"></div>
)}
</Card>
</div>
)
}
@@ -1335,3 +1388,187 @@ function MonthlyImport() {
</div>
)
}
function MedicalPeriodSettings() {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editPolicy, setEditPolicy] = useState<any>(null)
const { data: policies = [], isLoading } = useQuery<any[]>({
queryKey: ['medical-period-policies'],
queryFn: () => settingsApi.medicalPeriodPolicies(),
})
const saveMut = useMutation({
mutationFn: (data: any) => settingsApi.saveMedicalPeriodPolicy(data),
onSuccess: () => {
toast.success('政策已保存')
queryClient.invalidateQueries({ queryKey: ['medical-period-policies'] })
setShowForm(false)
setEditPolicy(null)
},
onError: () => toast.error('保存失败'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => settingsApi.deleteMedicalPeriodPolicy(id),
onSuccess: () => {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['medical-period-policies'] })
},
onError: () => toast.error('删除失败'),
})
return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-medium"></h2>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<Button size="sm" onClick={() => { setEditPolicy(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="py-8 text-center text-gray-400 text-sm">...</div>
) : policies.length === 0 ? (
<div className="py-8 text-center text-gray-400 text-sm"></div>
) : (
<div className="space-y-2">
{policies.map((p: any) => (
<div key={p.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{p.region}</span>
{p.isDefault && <span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary"></span>}
</div>
<div className="flex gap-1">
<button onClick={() => { setEditPolicy(p); setShowForm(true) }} className="text-xs text-primary hover:underline"></button>
{!p.isDefault && (
<button onClick={() => { if (confirm(`确认删除「${p.region}」政策?`)) deleteMut.mutate(p.id) }} className="p-1 hover:bg-gray-100 rounded">
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
)}
</div>
</div>
<div className="text-xs text-gray-500 mb-2">{p.legalBasis}</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-400">
<th className="py-1 pr-3 font-medium"></th>
<th className="py-1 pr-3 font-medium"></th>
<th className="py-1 pr-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{p.rules.map((rule: any, idx: number) => {
const prevMax = idx > 0 ? p.rules[idx - 1].maxYears : 0
const isLast = idx === p.rules.length - 1
return (
<tr key={idx}>
<td className="py-1 pr-3">{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears}`}</td>
<td className="py-1 pr-3">{rule.months} </td>
<td className="py-1 pr-3">{rule.cycleMonths} </td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</Card>
{showForm && (
<MedicalPolicyForm
policy={editPolicy}
onSave={(data) => saveMut.mutate(data)}
onClose={() => { setShowForm(false); setEditPolicy(null) }}
/>
)}
</div>
)
}
function MedicalPolicyForm({ policy, onSave, onClose }: { policy: any; onSave: (data: any) => void; onClose: () => void }) {
const [region, setRegion] = useState(policy?.region || '')
const [legalBasis, setLegalBasis] = useState(policy?.legalBasis || '')
const [isDefault, setIsDefault] = useState(policy?.isDefault || false)
const [rules, setRules] = useState<any[]>(
policy?.rules?.length ? policy.rules : [{ maxYears: 5, months: 3, cycleMonths: 6 }]
)
const addRule = () => setRules([...rules, { maxYears: 10, months: 6, cycleMonths: 12 }])
const updateRule = (idx: number, field: string, value: number) => {
setRules(rules.map((r, i) => i === idx ? { ...r, [field]: value } : r))
}
const removeRule = (idx: number) => {
if (rules.length <= 1) return
setRules(rules.filter((_, i) => i !== idx))
}
const handleSave = () => {
if (!region.trim()) { toast.error('请输入地区名称'); return }
if (!legalBasis.trim()) { toast.error('请输入法律依据'); return }
const sortedRules = [...rules].sort((a, b) => a.maxYears - b.maxYears)
onSave({ region: region.trim(), legalBasis: legalBasis.trim(), rules: sortedRules, isDefault })
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white 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">
<h3 className="font-medium">{policy ? '编辑政策' : '新增政策'}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={region} onChange={(e) => setRegion(e.target.value)} placeholder="如:广东" disabled={!!policy?.isDefault} />
</div>
<div>
<Label></Label>
<Input value={legalBasis} onChange={(e) => setLegalBasis(e.target.value)} placeholder="如:《广东省...》" />
</div>
<div>
<Label></Label>
<div className="space-y-2">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap"> &lt;</span>
<input type="number" value={rule.maxYears} onChange={(e) => updateRule(idx, 'maxYears', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"> </span>
<input type="number" value={rule.months} onChange={(e) => updateRule(idx, 'months', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"></span>
<input type="number" value={rule.cycleMonths} onChange={(e) => updateRule(idx, 'cycleMonths', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"></span>
{rules.length > 1 && (
<button onClick={() => removeRule(idx)} className="p-1 hover:bg-gray-100 rounded">
<Trash2 className="w-3 h-3 text-red-400" />
</button>
)}
</div>
))}
<button onClick={addRule} className="text-xs text-primary hover:underline">+ </button>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} disabled={!!policy?.isDefault} />
<span></span>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSave}></Button>
</div>
</div>
</div>
</div>
)
}
+23 -15
View File
@@ -11,7 +11,7 @@ import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab'
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -19,7 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
export default function SocialInsurance() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'commercial'>('monthly')
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>('monthly')
const [city, setCity] = useState<string>('北京')
const [showAddCity, setShowAddCity] = useState(false)
const [newCityName, setNewCityName] = useState('')
@@ -189,6 +189,10 @@ export default function SocialInsurance() {
setShowNewVersion(false)
toast.success('新版本已创建,旧版本已自动归档')
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || '创建失败'
toast.error(msg)
},
})
const createHousingVersionMutation = useMutation({
@@ -199,6 +203,10 @@ export default function SocialInsurance() {
setShowNewVersion(false)
toast.success('公积金新版本已创建,旧版本已自动归档')
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || '创建失败'
toast.error(msg)
},
})
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
@@ -347,11 +355,11 @@ export default function SocialInsurance() {
<Calculator className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
<div className="flex gap-2">
{tab !== 'monthly' && (
{(tab === 'social' || tab === 'housing') && (
<>
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
@@ -367,7 +375,7 @@ export default function SocialInsurance() {
{/* Tab 切换 + 城市选择 */}
<div className="flex items-center gap-4 border-b">
{(['monthly', 'social', 'housing', 'deduction', 'commercial'] as const).map((t) => (
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
@@ -375,7 +383,7 @@ export default function SocialInsurance() {
}`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
>
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'deduction' ? '专项附加扣除' : '商险管理'}
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
</button>
))}
{(tab === 'social' || tab === 'housing') && (
@@ -531,7 +539,7 @@ export default function SocialInsurance() {
)}
{/* 调整预览 */}
{tab !== 'monthly' && showAdjust && adjustData && (
{(tab === 'social' || tab === 'housing') && showAdjust && adjustData && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />{isHousing ? '公积金' : '社保'}
@@ -617,7 +625,7 @@ export default function SocialInsurance() {
)}
{/* 版本历史 */}
{tab !== 'monthly' && showVersions && (
{(tab === 'social' || tab === 'housing') && showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
{!activeVersions || activeVersions.length === 0 ? (
@@ -674,7 +682,7 @@ export default function SocialInsurance() {
)}
{/* 新建版本 */}
{tab !== 'monthly' && showNewVersion && (
{(tab === 'social' || tab === 'housing') && showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
<div className="space-y-3">
@@ -918,7 +926,7 @@ export default function SocialInsurance() {
{tab === 'monthly' && (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<Card>
<div className="flex items-center justify-between mb-3">
@@ -1143,16 +1151,16 @@ export default function SocialInsurance() {
</div>
)}
{/* ========== 员工参保信息 Tab ========== */}
{tab === 'enrollment' && (
<EmployeeEnrollmentTab />
)}
{/* ========== 专项附加扣除 Tab ========== */}
{tab === 'deduction' && (
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
)}
{/* ========== 商险管理 Tab ========== */}
{tab === 'commercial' && (
<CommercialInsuranceTab />
)}
</div>
)
}
+8 -3
View File
@@ -3,7 +3,9 @@
* //
*/
import { useEffect, useState } from 'react'
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
import { usePageSize } from '../hooks/usePageSize'
import { Link } from 'react-router-dom'
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X, Eye } from 'lucide-react'
import { toast } from 'sonner'
import { specialStatusApi, employeeApi } from '../lib/api-services'
import { Input, Select, Label } from '../components/ui/Input'
@@ -102,8 +104,8 @@ function daysUntil(d: string | null): number | null {
export default function SpecialStatus() {
const [list, setList] = useState<SpecialStatus[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
@@ -350,6 +352,9 @@ export default function SpecialStatus() {
</span>
</div>
<div className="flex gap-1">
<Link to={`/roster?employeeId=${item.employee.id}`} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="查看员工详情">
<Eye className="w-3.5 h-3.5" />
</Link>
<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>
@@ -361,7 +366,7 @@ export default function SpecialStatus() {
{/* 员工信息 */}
<div className="mb-3">
<span className="font-medium text-gray-900">{item.employee.name}</span>
<Link to={`/roster?employeeId=${item.employee.id}`} className="font-medium text-gray-900 hover:text-primary hover:underline">{item.employee.name}</Link>
<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>
+58 -38
View File
@@ -1,12 +1,14 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { templatesApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import mammoth from 'mammoth'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
@@ -140,25 +142,21 @@ function SystemTemplates() {
toast.success('已复制到剪贴板')
}
const handleDownloadWord = async () => {
const handleDownloadWord = () => {
if (!selected) return
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/templates/${selected.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selected.name}.doc`
a.click()
URL.revokeObjectURL(url)
toast.success('已下载 Word 文档')
} catch {
toast.error('下载失败')
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const query = new URLSearchParams()
query.set('token', token || '')
for (const [k, v] of Object.entries(variables)) {
if (v) query.append(k, v)
}
const a = document.createElement('a')
a.href = `${baseURL}/templates/${selected.id}/download?${query.toString()}`
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleCopyAsNew = () => {
@@ -302,9 +300,10 @@ function SystemTemplates() {
function EnterpriseTemplates() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const fileInputRef = useRef<HTMLInputElement>(null)
const [category, setCategory] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showEdit, setShowEdit] = useState(false)
const [editItem, setEditItem] = useState<any>(null)
const [form, setForm] = useState({ name: '', category: 'CONTRACT', description: '', content: '' })
@@ -370,25 +369,21 @@ function EnterpriseTemplates() {
}
}
const handleDownloadWord = async () => {
const handleDownloadWord = () => {
if (!selected) return
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/enterprise-templates/${selected.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selected.name}.doc`
a.click()
URL.revokeObjectURL(url)
toast.success('已下载')
} catch {
toast.error('下载失败')
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const query = new URLSearchParams()
query.set('token', token || '')
for (const [k, v] of Object.entries(variables)) {
if (v) query.append(k, v)
}
const a = document.createElement('a')
a.href = `${baseURL}/enterprise-templates/${selected.id}/download?${query.toString()}`
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleEdit = (item: any) => {
@@ -465,7 +460,7 @@ function EnterpriseTemplates() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
{/* 编辑弹窗 */}
@@ -489,6 +484,31 @@ function EnterpriseTemplates() {
</div>
<div>
<Label></Label>
<div className="flex items-center gap-2 mb-1">
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
<Upload className="w-3.5 h-3.5 mr-1" /> Word
</Button>
<input
ref={fileInputRef}
type="file"
accept=".docx"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.convertToHtml({ arrayBuffer })
setForm({ ...form, content: result.value })
toast.success('文档导入成功')
} catch {
toast.error('文档解析失败,请确保为 .docx 格式')
}
e.target.value = ''
}}
/>
<span className="text-xs text-gray-400"> .docx HTML</span>
</div>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[200px] font-mono"
value={form.content}
+140 -7
View File
@@ -1,12 +1,13 @@
import { useState, useMemo, useEffect } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck, PenTool } from 'lucide-react'
import { Stepper } from '../components/ui/Stepper'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
import jsPDF from 'jspdf'
import { rosterApi, terminationApi } from '../lib/api-services'
import { rosterApi, terminationApi, esignApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -204,8 +205,10 @@ export default function Termination() {
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [filterDateFrom, setFilterDateFrom] = useState('')
const [filterDateTo, setFilterDateTo] = useState('')
const draftPageSize = usePageSize()
const [draftPage, setDraftPage] = useState(1)
const [draftPageSize, setDraftPageSize] = useState(20)
const { data: employees } = useQuery<any[]>({
queryKey: ['roster-for-termination'],
@@ -432,6 +435,16 @@ export default function Termination() {
onError: () => toast.error('撤销失败'),
})
// 删除草稿
const deleteDraftMutation = useMutation({
mutationFn: (id: string) => terminationApi.deleteDraft(id),
onSuccess: () => {
toast.success('草稿已删除')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
},
onError: () => toast.error('删除失败'),
})
const { data: evidenceChain } = useQuery({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
@@ -697,7 +710,7 @@ export default function Termination() {
{view === 'list' && (
<>
<PageGuide>
稿
稿
</PageGuide>
<div className="flex gap-2 flex-wrap items-center">
<Input
@@ -727,8 +740,23 @@ export default function Termination() {
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{(searchTerm || filterStatus || filterDepartment) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment('') }} className="text-xs text-gray-500 hover:text-primary"></button>
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="开始日期"
/>
<span className="text-xs text-gray-400"></span>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="结束日期"
/>
{(searchTerm || filterStatus || filterDepartment || filterDateFrom || filterDateTo) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment(''); setFilterDateFrom(''); setFilterDateTo('') }} className="text-xs text-gray-500 hover:text-primary"></button>
)}
<Button variant="secondary" size="sm" onClick={async () => {
try {
@@ -736,6 +764,8 @@ export default function Termination() {
if (searchTerm) params.set('search', searchTerm)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
if (filterDateFrom) params.set('dateFrom', filterDateFrom)
if (filterDateTo) params.set('dateTo', filterDateTo)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/terminations?${params}`, { headers: { Authorization: `Bearer ${token}` } })
@@ -865,6 +895,20 @@ export default function Termination() {
<Ban className="w-3.5 h-3.5" />
</button>
)}
{(item.status === 'DRAFT' || item.status === 'CANCELLED') && (
<button
onClick={async () => {
if (await confirm({ title: '删除草稿', message: '确定删除此草稿记录?删除后不可恢复。' })) {
deleteDraftMutation.mutate(item.id)
}
}}
className="p-1 text-gray-400 hover:text-danger"
aria-label="删除"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => handleViewDetail(item.id)}
className="p-1 text-gray-500 hover:text-primary"
@@ -886,7 +930,7 @@ export default function Termination() {
pageSize={draftPageSize}
total={draftsTotal}
onPageChange={setDraftPage}
onPageSizeChange={(s) => { setDraftPageSize(s); setDraftPage(1) }}
onPageSizeChange={() => setDraftPage(1)}
/>
</Card>
</>
@@ -1017,6 +1061,73 @@ export default function Termination() {
</Button>
</div>
)}
{/* 下载离职证明 & 交接清单 */}
{draftDetail.status === 'COMPLETED' && (
<div className="border-t pt-3 flex gap-2 flex-wrap">
<Button
variant="secondary"
onClick={() => {
const reasonLabel = REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title></title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">/</div>
<div class="body"> ${draftDetail.employeeName}${draftDetail.idCardNumber || '—'} ${draftDetail.department || '—'} ${draftDetail.terminationDate} ${reasonLabel} /</div>
<div class="body">¥${fmt(draftDetail.compensation)}${draftDetail.socialInsEndMonth || '—'}${draftDetail.housingFundEndMonth || '—'}</div>
<div class="body"></div>
<div class="sign"><br/>${new Date().toISOString().slice(0, 10)}</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
{draftDetail.handoverItems && draftDetail.handoverItems.length > 0 && (
<Button
variant="secondary"
onClick={() => {
const items = draftDetail.handoverItems.map((item: any) => `<div class="body">${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}</div>`).join('')
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title></title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 18pt; font-weight: bold; margin-bottom: 20pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title"></div>
<div class="body">${draftDetail.employeeName}  ${draftDetail.department || '—'}  ${draftDetail.terminationDate}</div>
${items}
<div class="sign">____________<br/>____________<br/>____________</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
)}
</div>
)}
</div>
</Card>
)}
@@ -1492,6 +1603,28 @@ export default function Termination() {
placeholder="备注说明(选填)"
className="text-xs"
/>
{item.key === 'docs_signed' && employeeId && (
<Button
size="sm"
variant="secondary"
onClick={async () => {
try {
await esignApi.create({
employeeId,
documentTitle: '离职协议',
remark: '离职流程中发起',
scene: 'RESIGNATION',
})
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('离职协议电子签署已发起')
} catch {
toast.error('发起签署失败')
}
}}
>
<PenTool className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
))}
<div className="flex items-center gap-2 text-xs text-gray-500">
+410 -65
View File
@@ -1,12 +1,16 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import {
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye,
Repeat, Pause, FileText, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye, Search, Download, Users,
} from 'lucide-react'
import { workProcessApi, templatesApi } from '../lib/api-services'
import { workProcessApi, templatesApi, employeeApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -19,7 +23,6 @@ const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
TERMINATE: XCircle, RESCIND: UserX, LEAVING_CERT: FileMinus,
FLEXIBLE: Briefcase,
}
@@ -33,9 +36,6 @@ const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
RENEW: { label: '合同续签', description: '到期合同续签' },
SUSPEND: { label: '合同中止', description: '中止履行合同' },
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
TERMINATE: { label: '合同终止', description: '合同到期终止' },
RESCIND: { label: '合同解除', description: '协商或单方解除合同' },
LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明' },
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
}
@@ -49,31 +49,31 @@ const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
}
// 各流程类型的表单字段配置
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template'; options?: string[] }[]> = {
// 各流程类型的表单字段配置required 标记必填项)
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template' | 'employee-select' | 'contract-select'; options?: string[]; required?: boolean }[]> = {
HIRE: [
{ key: 'name', label: '员工姓名', type: 'text' },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'name', label: '员工姓名', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text', required: true },
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
{ key: 'monthlySalary', label: '月薪', type: 'number' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'phone', label: '手机号', type: 'text', required: true },
{ key: 'idCardNumber', label: '身份证号', type: 'text', required: true },
{ key: 'gender', label: '性别', type: 'select', options: ['男', '女'] },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
],
ONBOARD: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
],
CUSTOM_CONTRACT: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date', required: true },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
{ key: 'contractType', label: '合同类型', type: 'select', options: ['FIXED', 'UNFIXED', 'INTERNSHIP'] },
],
INFO_SUBMIT: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'address', label: '地址', type: 'text' },
@@ -81,27 +81,30 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'emergencyPhone', label: '紧急联系电话', type: 'text' },
],
CONFIRM: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'confirmDate', label: '转正日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'confirmDate', label: '转正日期', type: 'date', required: true },
{ key: 'regularSalary', label: '转正薪资', type: 'number' },
],
CHANGE: [
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'newEndDate', label: '新到期日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
{ key: 'newEndDate', label: '新到期日期', type: 'date', required: true },
],
RENEW: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'oldContractId', label: '原合同ID', type: 'text' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date', required: true },
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
{ key: 'newSalary', label: '新薪资', type: 'number' },
],
SUSPEND: [
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'suspendDate', label: '中止日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
{ key: 'suspendDate', label: '中止日期', type: 'date', required: true },
],
INCOME_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
@@ -109,33 +112,34 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'purpose', label: '用途', type: 'text' },
],
TERMINATE: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'terminateDate', label: '终止日期', type: 'date' },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'terminateDate', label: '终止日期', type: 'date', required: true },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
RESCIND: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'rescindDate', label: '解除日期', type: 'date' },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'rescindDate', label: '解除日期', type: 'date', required: true },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
LEAVING_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'leaveDate', label: '离职日期', type: 'date' },
{ key: 'leaveDate', label: '离职日期', type: 'date', required: true },
],
FLEXIBLE: [
{ key: 'name', label: '姓名', type: 'text' },
{ key: 'name', label: '姓名', type: 'text', required: true },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date' },
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date', required: true },
{ key: 'agreementEndDate', label: '协议结束日期', type: 'date' },
{ key: 'payMethod', label: '计酬方式', type: 'text' },
],
@@ -148,16 +152,44 @@ const FIELD_LABEL_MAP: Record<string, string> = Object.values(FORM_FIELDS).flat(
}, {} as Record<string, string>)
export default function WorkProcess() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedType, setSelectedType] = useState<string>('')
const [formData, setFormData] = useState<Record<string, any>>({})
const [selectedType, setSelectedType] = useState<string>(() => {
try { return localStorage.getItem('workprocess-draft-type') || '' } catch { return '' }
})
const [formData, setFormData] = useState<Record<string, any>>(() => {
try {
const saved = localStorage.getItem('workprocess-draft-data')
return saved ? JSON.parse(saved) : {}
} catch { return {} }
})
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [previewContent, setPreviewContent] = useState<string | null>(null)
const [showBatch, setShowBatch] = useState(false)
const [batchType, setBatchType] = useState<string>('INCOME_CERT')
const [batchEmployees, setBatchEmployees] = useState<string[]>([])
const [batchSearch, setBatchSearch] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
if (selectedType && Object.keys(formData).length > 0) {
localStorage.setItem('workprocess-draft-type', selectedType)
localStorage.setItem('workprocess-draft-data', JSON.stringify(formData))
} else {
localStorage.removeItem('workprocess-draft-type')
localStorage.removeItem('workprocess-draft-data')
}
} catch {}
}, [selectedType, formData])
const isDirty = selectedType && Object.keys(formData).length > 0
useUnsavedChanges(!!isDirty)
const { data: listData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
@@ -174,13 +206,15 @@ export default function WorkProcess() {
return await workProcessApi.create(data)
},
onSuccess: () => {
toast.success('已创建草稿')
toast.success('已创建草稿,可在列表中查看详情并提交', {
action: { label: '去提交', onClick: () => {} },
})
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCreate(false)
setFormData({})
setSelectedType('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
onError: (err: any) => toastError(err, '创建失败'),
})
const submitMutation = useMutation({
@@ -188,11 +222,14 @@ export default function WorkProcess() {
return await workProcessApi.submit(id)
},
onSuccess: () => {
toast.success('已提交并执行')
toast.success('已提交并执行', {
action: { label: '查看文书', onClick: () => navigate('/evidence') },
})
toast.info('文书已生成,可在「证据管理」中查看和下载', { duration: 5000 })
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '提交失败'),
onError: (err: any) => toastError(err, '提交失败'),
})
const cancelMutation = useMutation({
@@ -204,7 +241,7 @@ export default function WorkProcess() {
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '撤销失败'),
onError: (err: any) => toastError(err, '撤销失败'),
})
const deleteMutation = useMutation({
@@ -215,7 +252,7 @@ export default function WorkProcess() {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
onError: (err: any) => toastError(err, '删除失败'),
})
const previewMutation = useMutation({
@@ -225,7 +262,7 @@ export default function WorkProcess() {
onSuccess: (data) => {
setPreviewContent(data.content)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '预览失败'),
onError: (err: any) => toastError(err, '预览失败'),
})
const handleCreate = () => {
@@ -233,37 +270,149 @@ export default function WorkProcess() {
toast.error('请选择流程类型')
return
}
// 校验必填项
const requiredFields = (FORM_FIELDS[selectedType] || []).filter(f => f.required)
const missingFields = requiredFields.filter(f => !formData[f.key] || String(formData[f.key]).trim() === '')
if (missingFields.length > 0) {
toast.error(`请填写必填项:${missingFields.map(f => f.label).join('、')}`)
return
}
createMutation.mutate({
type: selectedType,
title: PROCESS_TYPES[selectedType].label,
employeeId: formData.employeeId || undefined,
formData,
status: 'DRAFT',
})
}
const handleCreateAndSubmit = () => {
if (!selectedType) {
toast.error('请选择流程类型')
return
}
createMutation.mutate(
{ type: selectedType, title: PROCESS_TYPES[selectedType].label, employeeId: formData.employeeId || undefined, formData, status: 'DRAFT' },
{
onSuccess: (data: any) => {
const newId = data?.id
if (newId) {
submitMutation.mutate(newId)
} else {
toast.success('草稿已创建,请手动提交')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
}
setShowCreate(false)
setFormData({})
setSelectedType('')
},
}
)
}
const handleFieldChange = (key: string, value: any) => {
setFormData(prev => ({ ...prev, [key]: value }))
// 选择员工后自动填充相关字段
if (key === 'employeeId' && value) {
employeeApi.detail(value).then((emp: any) => {
setFormData(prev => ({
...prev,
employeeName: emp.name || prev.employeeName,
idCardNumber: emp.idCardNumber || prev.idCardNumber,
position: emp.position || prev.position,
monthlyIncome: emp.monthlySalary ? String(emp.monthlySalary) : prev.monthlyIncome,
hireDate: emp.hireDate ? emp.hireDate.slice(0, 10) : prev.hireDate,
department: emp.department || prev.department,
phone: emp.phone || prev.phone,
}))
}).catch(() => {})
}
}
const handleBatchSubmit = () => {
if (batchEmployees.length === 0) {
toast.error('请至少选择一名员工')
return
}
let success = 0
let failed = 0
Promise.all(
batchEmployees.map(async (empId) => {
try {
const emp = allEmployees.find((e: any) => e.id === empId)
if (!emp) return
const data: any = {
type: batchType,
title: PROCESS_TYPES[batchType].label,
employeeId: empId,
formData: {
employeeName: emp.name,
idCardNumber: emp.idCardNumber || '',
position: emp.position || '',
},
status: 'DRAFT',
}
const res: any = await workProcessApi.create(data)
if (res?.id) {
await workProcessApi.submit(res.id)
success++
}
} catch {
failed++
}
})
).then(() => {
toast.success(`批量开具完成:成功 ${success}${failed > 0 ? ',失败 ' + failed + ' 个' : ''}`)
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowBatch(false)
setBatchEmployees([])
setBatchSearch('')
})
}
const { data: allEmployees = [] } = useQuery<any[]>({
queryKey: ['employee-list-batch'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filteredEmployees = allEmployees.filter((e: any) => {
if (!batchSearch) return true
return e.name.includes(batchSearch) || (e.department || '').includes(batchSearch)
})
const items = listData?.items || []
const total = listData?.total || 0
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 发起办理 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-amber-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
稿{PROCESS_TYPES[selectedType]?.label}
<button className="text-primary hover:underline" onClick={() => { setSelectedType(''); setFormData({}) }}></button>
</span>
)}
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowBatch(true)}>
<Users className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 13类流程卡片 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
@@ -329,10 +478,18 @@ export default function WorkProcess() {
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{item.employee ? `${item.employee.name} · ${item.employee.department}` : '未关联员工'}
{item.employee ? `${item.employee.name} · ${item.employee.department}` : (item.formData?.employeeName || item.formData?.name || '未关联员工')}
{' · '}{new Date(item.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
{(item.status === 'COMPLETED' || item.status === 'EXECUTING') && item.documents && item.documents.length > 0 && (
<button
className="text-xs text-primary hover:underline shrink-0 flex items-center gap-0.5"
onClick={(e) => { e.stopPropagation(); setDetailId(item.id) }}
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<ChevronRight className="w-4 h-4 text-gray-300" />
</div>
)
@@ -344,12 +501,12 @@ export default function WorkProcess() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</Card>
{/* 创建/编辑弹窗 */}
<Modal open={showCreate} onClose={() => { setShowCreate(false); setFormData({}); setSelectedType('') }} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
{!selectedType ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
@@ -374,7 +531,7 @@ export default function WorkProcess() {
<div className="text-xs text-gray-500 mb-2">{PROCESS_TYPES[selectedType]?.description}</div>
{(FORM_FIELDS[selectedType] || []).map(field => (
<div key={field.key}>
<Label>{field.label}</Label>
<Label>{field.label}{field.required && <span className="text-danger ml-0.5">*</span>}</Label>
{field.type === 'select' ? (
<Select value={formData[field.key] || ''} onChange={(e) => handleFieldChange(field.key, e.target.value)}>
<option value=""></option>
@@ -388,6 +545,19 @@ export default function WorkProcess() {
/>
) : field.type === 'enterprise-template' ? (
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : field.type === 'employee-select' ? (
<EmployeeSelect value={formData[field.key] || ''} onChange={(emp) => {
handleFieldChange(field.key, emp.id)
if (emp.name) handleFieldChange('employeeName', emp.name)
if (emp.idCardNumber) handleFieldChange('idCardNumber', emp.idCardNumber)
if (emp.position) handleFieldChange('position', emp.position)
if (emp.monthlySalary) handleFieldChange('monthlyIncome', String(emp.monthlySalary))
if (emp.hireDate) handleFieldChange('hireDate', emp.hireDate?.slice(0, 10))
if (emp.department) handleFieldChange('department', emp.department)
if (emp.phone) handleFieldChange('phone', emp.phone)
}} />
) : field.type === 'contract-select' ? (
<ContractSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} employeeId={formData['employeeId'] || ''} />
) : (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
@@ -402,7 +572,11 @@ export default function WorkProcess() {
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
稿
</Button>
<Button variant="secondary" onClick={() => { setSelectedType(''); setFormData({}) }}>
<Button onClick={handleCreateAndSubmit} disabled={createMutation.isPending || submitMutation.isPending}>
{(createMutation.isPending || submitMutation.isPending) ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button variant="secondary" onClick={() => setSelectedType('')}>
</Button>
</div>
@@ -422,6 +596,61 @@ export default function WorkProcess() {
loading={submitMutation.isPending || cancelMutation.isPending}
/>
</Modal>
{/* 批量开具证明弹窗 */}
<Modal open={showBatch} onClose={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }} title="批量开具证明" size="lg">
<div className="space-y-4">
<div>
<Label></Label>
<Select value={batchType} onChange={(e) => setBatchType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div>
<Label> {batchEmployees.length} </Label>
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={batchSearch}
onChange={(e) => setBatchSearch(e.target.value)}
placeholder="搜索姓名或部门..."
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
{filteredEmployees.map((emp: any) => (
<label
key={emp.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer border-b last:border-0"
>
<input
type="checkbox"
checked={batchEmployees.includes(emp.id)}
onChange={() => {
setBatchEmployees(prev =>
prev.includes(emp.id) ? prev.filter(id => id !== emp.id) : [...prev, emp.id]
)
}}
/>
<span className="text-sm flex-1">{emp.name}</span>
<span className="text-xs text-gray-400">{emp.department || '—'}</span>
</label>
))}
{filteredEmployees.length === 0 && (
<div className="text-center py-4 text-xs text-gray-400"></div>
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="secondary" size="sm" onClick={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }}></Button>
<Button size="sm" onClick={handleBatchSubmit} disabled={batchEmployees.length === 0}>
<Send className="w-4 h-4 mr-1" />
{batchEmployees.length}
</Button>
</div>
</div>
</Modal>
</div>
)
}
@@ -458,7 +687,7 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : '未关联员工'}
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : (data.formData?.employeeName || data.formData?.name || '未关联员工')}
</div>
</div>
</div>
@@ -489,11 +718,38 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
{data.documents && data.documents.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="space-y-1">
<div className="space-y-2">
{data.documents.map((doc: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<FileText className="w-3 h-3 text-gray-400" />
<span>{doc.name}</span>
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded-md p-2">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 truncate">{doc.name}</span>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => onPreview(data.id)}
>
<Eye className="w-3.5 h-3.5 inline mr-0.5" />
</button>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => {
const content = previewContent || ''
if (!content) {
onPreview(data.id)
return
}
const blob = new Blob(['\ufeff' + content], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = doc.name.endsWith('.doc') ? doc.name : `${doc.name}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-3.5 h-3.5 inline mr-0.5" />
</button>
</div>
))}
</div>
@@ -549,3 +805,92 @@ function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange
</div>
)
}
function EmployeeSelect({ value, onChange }: { value: string; onChange: (employee: any) => void }) {
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const { data: employees = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-list-for-select'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filtered = employees.filter((e: any) => {
if (!search) return true
return e.name.includes(search) || (e.department || '').includes(search) || (e.phone || '').includes(search)
})
const selected = employees.find((e: any) => e.id === value)
return (
<div className="relative">
<div
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm cursor-pointer flex items-center justify-between"
onClick={() => setOpen(!open)}
>
{selected ? (
<span>{selected.name} · {selected.department || '未分配部门'}</span>
) : (
<span className="text-gray-400">{isLoading ? '加载中...' : '点击选择员工'}</span>
)}
<Search className="w-3.5 h-3.5 text-gray-400" />
</div>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white rounded-md border border-gray-200 shadow-lg max-h-[240px] overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
type="text"
autoFocus
placeholder="搜索姓名/部门/手机号"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="overflow-y-auto max-h-[180px]">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400"></div>
) : (
filtered.map((e: any) => (
<div
key={e.id}
className="px-3 py-2 text-sm hover:bg-primary/5 cursor-pointer flex items-center justify-between"
onClick={() => {
onChange(e)
setOpen(false)
setSearch('')
}}
>
<span>{e.name}</span>
<span className="text-xs text-gray-400">{e.department || ''}</span>
</div>
))
)}
</div>
</div>
)}
</div>
)
}
function ContractSelect({ value, onChange, employeeId }: { value: string; onChange: (v: string) => void; employeeId: string }) {
const { data: contracts = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-contracts', employeeId],
queryFn: async () => {
if (!employeeId) return []
const res = await employeeApi.detail(employeeId)
return res?.contracts || []
},
enabled: !!employeeId,
})
const activeContracts = contracts.filter((c: any) => c.status === 'ACTIVE' || c.status === 'SUSPENDED')
return (
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={!employeeId}>
<option value="">{!employeeId ? '请先选择员工' : isLoading ? '加载中...' : activeContracts.length === 0 ? '无可用合同' : '请选择合同'}</option>
{activeContracts.map((c: any) => (
<option key={c.id} value={c.id}>
{c.contractType === 'UNFIXED' ? '无固定期限' : `${c.startDate?.slice(0, 10)} ~ ${c.endDate?.slice(0, 10)}`}{c.status === 'SUSPENDED' ? '(已中止)' : ''}
</option>
))}
</Select>
)
}
+128 -26
View File
@@ -4,15 +4,17 @@
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import {
ShieldAlert, AlertTriangle, Clock, Users, FileText,
TrendingDown, Calendar, ChevronRight, Filter,
TrendingDown, Calendar, ChevronRight, Filter, CheckSquare,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
import PageGuide from '../../components/ui/PageGuide'
import QueryError from '../../components/ui/QueryError'
import Pagination from '../../components/ui/Pagination'
import { usePageSize } from '../../hooks/usePageSize'
import { dashboardApi } from '../../lib/api-services'
/** 风险等级配置 */
@@ -23,8 +25,8 @@ const RISK_LEVELS: Record<string, { label: string; color: string; bg: string }>
}
/** 风险类型配置 */
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string }> = {
CONTRACT: { label: '合同风险', icon: FileText, link: '/roster' },
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string; extraParams?: string }> = {
CONTRACT: { label: '合同风险', icon: FileText, link: '/roster', extraParams: 'contractStatus=expired' },
SALARY: { label: '薪酬风险', icon: TrendingDown, link: '/money' },
TERMINATION: { label: '解聘风险', icon: Users, link: '/termination' },
MONTHLY: { label: '月度任务', icon: Calendar, link: '/money' },
@@ -34,7 +36,11 @@ const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link
export default function RiskCenter() {
const [filterLevel, setFilterLevel] = useState<string>('ALL')
const [filterType] = useState<string>('ALL')
const [filterType, setFilterType] = useState<string>('ALL')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatch, setShowBatch] = useState(false)
/** 获取风险列表 */
const { data: risks = [], isLoading, isError, error, refetch } = useQuery<any[]>({
@@ -70,6 +76,35 @@ export default function RiskCenter() {
})
}, [risks, filterLevel, filterType])
/** 构建带筛选参数的跳转链接 */
const buildLink = (r: any) => {
const typeCfg = RISK_TYPES[r.type] || { link: '/', extraParams: '' }
const params: string[] = []
if (r.employee) params.push(`employee=${encodeURIComponent(r.employee.name)}`)
if (typeCfg.extraParams) params.push(typeCfg.extraParams)
return params.length > 0 ? `${typeCfg.link}?${params.join('&')}` : typeCfg.link
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === pagedRisks.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(pagedRisks.map((r: any, i: number) => `${r.type}-${i}`)))
}
}
const total = filteredRisks.length
const pagedRisks = filteredRisks.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-4">
<PageGuide>
@@ -124,7 +159,7 @@ export default function RiskCenter() {
return (
<Link
key={key}
to={cfg.link}
to={cfg.extraParams ? `${cfg.link}?${cfg.extraParams}` : cfg.link}
className="flex items-center gap-2 p-2.5 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/5 transition-colors"
>
<Icon className="w-4 h-4 text-gray-400" />
@@ -161,6 +196,26 @@ export default function RiskCenter() {
>{cfg.label}</button>
))}
</div>
<div className="flex gap-1 ml-2">
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterType === 'ALL' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterType('ALL')}
></button>
{Object.entries(RISK_TYPES).map(([key, cfg]) => (
<button
key={key}
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterType === key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterType(key)}
>{cfg.label}</button>
))}
</div>
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ml-auto flex items-center gap-1 ${showBatch ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => { setShowBatch(!showBatch); setSelectedIds(new Set()) }}
>
<CheckSquare className="w-3.5 h-3.5" />
{showBatch ? '退出批量' : '批量处理'}
</button>
</div>
{/* 风险列表 */}
@@ -174,38 +229,85 @@ export default function RiskCenter() {
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
</div>
) : (
<>
{showBatch && pagedRisks.length > 0 && (
<div className="flex items-center gap-2 mb-2 px-3 py-2 bg-gray-50 rounded-md">
<button onClick={toggleSelectAll} className="text-xs text-primary hover:underline">
{selectedIds.size === pagedRisks.length ? '取消全选' : '全选'}
</button>
<span className="text-xs text-gray-500"> {selectedIds.size} </span>
{selectedIds.size > 0 && (
<>
<Link to={`/contracts?batch=${encodeURIComponent(Array.from(selectedIds).join(','))}`} className="text-xs text-primary hover:underline ml-2">
</Link>
<Link to={`/termination?batch=${encodeURIComponent(Array.from(selectedIds).join(','))}`} className="text-xs text-primary hover:underline">
</Link>
</>
)}
</div>
)}
<div className="space-y-2">
{filteredRisks.map((r: any, i: number) => {
{pagedRisks.map((r: any, i: number) => {
const levelCfg = RISK_LEVELS[r.level] || RISK_LEVELS.LOW
const typeCfg = RISK_TYPES[r.type] || { label: r.type, icon: AlertTriangle, link: '/' }
const Icon = typeCfg.icon
const link = buildLink(r)
const itemId = `${r.type}-${i}`
return (
<Link
<div
key={i}
to={typeCfg.link}
className={`flex items-start gap-3 p-3 rounded-lg border ${levelCfg.bg} hover:shadow-sm transition-shadow`}
>
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${levelCfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${levelCfg.color}`}>{levelCfg.label}</span>
<span className="text-xs text-gray-400">{typeCfg.label}</span>
</div>
<div className="text-sm text-gray-700 mt-0.5">{r.title}</div>
{r.description && (
<div className="text-xs text-gray-500 mt-0.5">{r.description}</div>
)}
{r.employee && (
<div className="text-xs text-gray-400 mt-0.5">
{r.employee.name} · {r.employee.department || ''}
{showBatch && (
<input
type="checkbox"
checked={selectedIds.has(itemId)}
onChange={() => toggleSelect(itemId)}
className="mt-1 shrink-0"
/>
)}
<Link to={link} className="flex items-start gap-3 flex-1 min-w-0">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${levelCfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${levelCfg.color}`}>{levelCfg.label}</span>
<span className="text-xs text-gray-400">{typeCfg.label}</span>
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
<div className="text-sm text-gray-700 mt-0.5">{r.title}</div>
{r.description && (
<div className="text-xs text-gray-500 mt-0.5">{r.description}</div>
)}
{r.employee && (
<div className="text-xs text-gray-400 mt-0.5">
{r.employee.name} · {r.employee.department || ''}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
{r.type === 'CONTRACT_EXPIRY' && (
<Link to={`/contracts?employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
{r.type === 'PROBATION_END' && (
<Link to={`/work-process?type=REGULAR&employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
{r.type === 'TERMINATION_RISK' && (
<Link to={`/termination?employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
</div>
)
})}
</div>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
</Card>
</div>
+2 -1
View File
@@ -4,7 +4,7 @@
*/
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { AlertCircle, Layers, FileText, Heart, ArrowRight, ListTodo } from 'lucide-react'
import { AlertCircle, Layers, FileText, Heart, ArrowRight, ListTodo, Wallet } from 'lucide-react'
import { dashboardApi } from '../../lib/api-services'
import { InlineAlert } from '../../components/ui/InlineAlert'
@@ -27,6 +27,7 @@ interface ActionGroup {
const categoryIcons: Record<string, typeof AlertCircle> = {
'待办事项': AlertCircle,
'发薪批次': Layers,
'发薪提醒': Wallet,
'合同到期': FileText,
'特殊状态': Heart,
}
+27 -11
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
@@ -100,6 +101,8 @@ export function BatchManager() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthFrom, setMonthFrom] = useState('')
const [monthTo, setMonthTo] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [filterStatus, setFilterStatus] = useState<string>('')
const [filterType, setFilterType] = useState<string>('')
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
@@ -108,8 +111,8 @@ export function BatchManager() {
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
const [sourceBatchId, setSourceBatchId] = useState<string>('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<string[]>([])
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const { data: checkResult } = useQuery<any>({
queryKey: ['batch-check', month],
@@ -119,12 +122,14 @@ export function BatchManager() {
})
const { data: batches, isLoading } = useQuery<any[]>({
queryKey: ['batches', month, monthFrom, monthTo, filterStatus, filterType],
queryKey: ['batches', month, monthFrom, monthTo, dateFrom, dateTo, filterStatus, filterType],
queryFn: async () => {
const params: any = {}
if (month && !monthFrom && !monthTo) params.month = month
if (month && !monthFrom && !monthTo && !dateFrom && !dateTo) params.month = month
if (monthFrom) params.monthFrom = monthFrom
if (monthTo) params.monthTo = monthTo
if (dateFrom) params.dateFrom = dateFrom
if (dateTo) params.dateTo = dateTo
if (filterStatus) params.status = filterStatus
if (filterType) params.type = filterType
return await payrollApi.batches(params)
@@ -214,9 +219,15 @@ export function BatchManager() {
<span className="text-xs text-gray-500"></span>
<Input type="month" value={monthTo} onChange={(e) => { setMonthTo(e.target.value); setMonth('') }} className="!w-36" />
</div>
{!monthFrom && !monthTo && (
{!monthFrom && !monthTo && !dateFrom && !dateTo && (
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" />
)}
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-gray-500"></span>
<Input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setMonth('') }} className="!w-36" placeholder="起始" />
<span className="text-xs text-gray-500">~</span>
<Input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setMonth('') }} className="!w-36" placeholder="截止" />
</div>
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="!w-28 shrink-0">
<option value=""></option>
<option value="DRAFT">稿</option>
@@ -229,8 +240,8 @@ export function BatchManager() {
<option value="BONUS"></option>
<option value="SEVERANCE"></option>
</Select>
{(monthFrom || monthTo || filterStatus || filterType) && (
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
{(monthFrom || monthTo || dateFrom || dateTo || filterStatus || filterType) && (
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setDateFrom(''); setDateTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
</button>
)}
@@ -313,7 +324,6 @@ export function BatchManager() {
<EmptyState title="本月暂无发薪批次" description="点击「创建发薪批次」开始" />
) : (
<div className="space-y-3">
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -327,6 +337,7 @@ export function BatchManager() {
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
@@ -376,6 +387,7 @@ export function BatchManager() {
<td className="py-2.5 px-3 text-right text-sm text-cyan-600">¥{fmt((batch.totalHousingOrg || 0) + (batch.totalHousingEmp || 0))}</td>
<td className="py-2.5 px-3 text-right text-sm text-danger">¥{fmt(batch.totalTax)}</td>
<td className="py-2.5 px-3 text-right text-sm font-bold text-safe">¥{fmt(batch.totalNetPay)}</td>
<td className="py-2.5 px-3 text-xs text-gray-500">{batch.createdAt ? new Date(batch.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-2.5 px-3">
{batch.status === 'ARCHIVED' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-50 text-safe">
@@ -435,6 +447,7 @@ export function BatchManager() {
</table>
</div>
</Card>
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</div>
)}
</div>
@@ -446,8 +459,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
const confirm = useConfirm()
const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null)
const [editValue, setEditValue] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showAddEmployee, setShowAddEmployee] = useState(false)
const payrollFileRef = useRef<HTMLInputElement>(null)
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
@@ -469,6 +482,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '保存失败,请重试')
},
})
const removeEmployeeMutation = useMutation({
@@ -1013,9 +1029,6 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
{/* 人员表格 */}
<Card>
{batch.entries.length > 0 && (
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -1082,6 +1095,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
</tbody>
</table>
</div>
{batch.entries.length > 0 && (
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
)}
</Card>
{/* 定时发送弹窗 */}
+81 -7
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X } from 'lucide-react'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X, Plus } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi, employeeApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -18,6 +18,8 @@ export function OvertimeCalculator() {
const [previewData, setPreviewData] = useState<any[]>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({ weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
const [showAddForm, setShowAddForm] = useState(false)
const [addForm, setAddForm] = useState({ employeeId: '', weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
// 加班费规则配置
const { data: config, isLoading: configLoading } = useQuery<any>({
@@ -60,6 +62,17 @@ export function OvertimeCalculator() {
},
})
// 从考勤记录同步加班工时
const syncFromAttendanceMutation = useMutation({
mutationFn: (m: string) => payrollApi.syncOvertimeFromAttendance(m),
onSuccess: (data: any) => {
toast.success(`已从考勤同步 ${data.synced || 0} 位员工的加班工时`)
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setStep(3)
},
onError: () => toast.error('同步失败'),
})
// 更新单条加班记录
const updateOvertimeMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
@@ -87,6 +100,18 @@ export function OvertimeCalculator() {
}
}
// 手动添加加班记录
const addOvertimeMutation = useMutation({
mutationFn: (data: any) => payrollApi.saveOvertime(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setShowAddForm(false)
setAddForm({ employeeId: '', weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
toast.success('加班记录已添加')
},
onError: () => toast.error('添加失败'),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@@ -265,13 +290,18 @@ export function OvertimeCalculator() {
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
</div>
<div className="border-t pt-3">
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileUpload} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '选择CSV文件'}
</Button>
<div className="flex gap-2 items-center">
<input ref={fileInputRef} type="file" accept=".csv,.xlsx,.xls" className="hidden" onChange={handleFileUpload} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '选择文件导入'}
</Button>
<Button variant="secondary" onClick={() => syncFromAttendanceMutation.mutate(month)} disabled={syncFromAttendanceMutation.isPending}>
{syncFromAttendanceMutation.isPending ? '同步中...' : '从考勤同步'}
</Button>
</div>
<div className="text-xs text-gray-500 mt-2">
CSV格式,(h),(h),(h),()
,(h),(h),(h),()
</div>
</div>
@@ -329,8 +359,52 @@ export function OvertimeCalculator() {
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-32" />
<Button variant="secondary" size="sm" onClick={() => refetch()}></Button>
<Button size="sm" onClick={() => setShowAddForm(!showAddForm)}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
</div>
</div>
{showAddForm && (
<div className="border rounded-lg p-3 mb-3 bg-gray-50 space-y-3">
<div className="grid md:grid-cols-4 gap-3">
<div>
<Label></Label>
<select
className="w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
value={addForm.employeeId}
onChange={(e) => setAddForm({ ...addForm, employeeId: e.target.value })}
>
<option value=""></option>
{employees?.items?.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</select>
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.weekdayHours} onChange={(e) => setAddForm({ ...addForm, weekdayHours: Number(e.target.value) })} />
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.weekendHours} onChange={(e) => setAddForm({ ...addForm, weekendHours: Number(e.target.value) })} />
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.holidayHours} onChange={(e) => setAddForm({ ...addForm, holidayHours: Number(e.target.value) })} />
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => {
if (!addForm.employeeId) return toast.error('请选择员工')
const emp = employees?.items?.find((e: any) => e.id === addForm.employeeId)
let monthlyWage = 0
try { monthlyWage = Number((emp as any)?.monthlySalary) || 0 } catch {}
addOvertimeMutation.mutate({ ...addForm, month, monthlyWage: monthlyWage || 1 })
}} disabled={addOvertimeMutation.isPending || !addForm.employeeId}>
{addOvertimeMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowAddForm(false)}></Button>
</div>
</div>
)}
{!overtimeRecords || overtimeRecords.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
+91 -14
View File
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
import { Calculator, Check, Layers, Settings as X } from 'lucide-react'
import { Calculator, Check, Layers, Settings as X, Download, Eye } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -13,12 +14,12 @@ import Pagination from '../../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export function PayslipManager() {
export function PayslipManager({ filterEmployeeId }: { filterEmployeeId?: string }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showTaxPreview, setShowTaxPreview] = useState(false)
const [previewData, setPreviewData] = useState({
baseSalary: 0,
@@ -30,9 +31,11 @@ export function PayslipManager() {
})
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month],
queryKey: ['payslips', month, filterEmployeeId],
queryFn: async () => {
return await payrollApi.payslips({ month })
const all = await payrollApi.payslips({ month })
if (!filterEmployeeId) return all
return all.filter((p: any) => p.employeeId === filterEmployeeId)
},
})
@@ -67,20 +70,86 @@ export function PayslipManager() {
return (
<div className="space-y-3">
<PageGuide>
PDF
PDF
</PageGuide>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
{payslips && payslips.length > 0 && (
<div className="flex gap-2 text-xs">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning"> {unconfirmedCount}</span>
<div className="flex items-center gap-2 text-xs flex-nowrap">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 whitespace-nowrap"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe whitespace-nowrap"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning whitespace-nowrap"> {unconfirmedCount}</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
if (!payslips || payslips.length === 0) {
toast.error('暂无工资条可导出')
return
}
const headers = ['员工', '部门', '基本工资', '加班费', '津贴', '奖金', '扣款', '应发合计', '个税', '实发', '确认状态']
const rows = payslips.map((p: any) => [
p.employee?.name || '',
p.employee?.department || '',
p.baseSalary || 0,
p.overtimePay || 0,
p.allowance || 0,
p.bonus || 0,
p.deduction || 0,
p.totalPay || 0,
p.tax || 0,
p.netPay || 0,
p.confirmedAt ? '已确认' : '未确认',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `工资表-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出工资表')
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
onClick={() => {
if (!payslips || payslips.length === 0) {
toast.error('暂无工资条可导出')
return
}
const headers = ['序号', '收款人姓名', '收款账号', '开户行', '金额', '用途', '备注']
const rows = payslips.map((p: any, i: number) => [
i + 1,
p.employee?.name || '',
p.employee?.bankAccount || '',
p.employee?.bankName || '',
(p.netPay || 0).toFixed(2),
`${month}月工资`,
'',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `工资流水-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出工资流水')
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={() => setShowTaxPreview(true)}
>
@@ -107,7 +176,6 @@ export function PayslipManager() {
<Card><div className="text-center py-8 text-gray-500"></div></Card>
) : (
<Card>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -144,8 +212,16 @@ export function PayslipManager() {
<span className="inline-flex items-center gap-1 text-safe text-xs">
<Check className="w-3 h-3" />
</span>
) : p.viewedAt ? (
<span className="inline-flex items-center gap-1 text-blue-600 text-xs" title={`查看于 ${new Date(p.viewedAt).toLocaleString('zh-CN')}`}>
<Eye className="w-3 h-3" />
</span>
) : p.publishedAt ? (
<span className="inline-flex items-center gap-1 text-gray-400 text-xs">
<Check className="w-3 h-3" />
</span>
) : (
<span className="text-warning text-xs"></span>
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="py-2 px-2">
@@ -161,6 +237,7 @@ export function PayslipManager() {
</tbody>
</table>
</div>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</Card>
)}
+3 -2
View File
@@ -2,6 +2,7 @@
*
*/
import { useEffect, useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { Search, Trash2, Edit2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { platformApi } from '../../lib/api-services'
@@ -11,7 +12,7 @@ import Button from '../../components/ui/Button'
interface Org {
id: string; name: string; plan: string; maxEmployees: number
city: string | null; contactName: string | null; contactPhone: string | null
payrollFrequency: number; retirementReminderEnabled: boolean
payrollDays: number[]; retirementReminderEnabled: boolean
createdAt: string; updatedAt: string
employeeCount: number; userCount: number; contractCount: number; payslipCount: number
}
@@ -22,8 +23,8 @@ const PLAN_COLORS: Record<string, string> = { FREE: 'bg-gray-100 text-gray-700',
export default function PlatformOrgs() {
const [orgs, setOrgs] = useState<Org[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [planFilter, setPlanFilter] = useState('')
const [loading, setLoading] = useState(true)
@@ -2,6 +2,7 @@
* /
*/
import { useEffect, useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { Search, Ban, CheckCircle } from 'lucide-react'
import { toast } from 'sonner'
import { platformApi } from '../../lib/api-services'
@@ -25,8 +26,8 @@ const ROLE_COLORS: Record<string, string> = {
export default function PlatformUsers() {
const [users, setUsers] = useState<UserItem[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const [loading, setLoading] = useState(true)
+15 -1
View File
@@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
AlertCircle, ChevronRight,
AlertCircle, ChevronRight, UserX, PenTool,
} from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -19,6 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
const QUICK_ACTIONS = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
{ path: '/portal/esign', label: '电子签署', icon: PenTool, color: 'bg-violet-50 text-violet-600' },
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
{ path: '/portal/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-600' },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
@@ -219,6 +220,19 @@ export default function EmployeeHome() {
</div>
</Card>
)}
{/* 辞职申请入口 */}
<Card>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UserX className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium"></span>
</div>
<Link to="/portal/resignation" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
</Card>
</div>
)
}
+204
View File
@@ -0,0 +1,204 @@
/**
*
* /
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { PenTool, FileText, CheckCircle2, Clock, XCircle, ChevronLeft } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState'
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待签署', color: 'bg-amber-50 text-amber-700', icon: <Clock className="w-3 h-3" /> },
COMPLETED: { label: '已签署', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-400', icon: <XCircle className="w-3 h-3" /> },
EXPIRED: { label: '已过期', color: 'bg-red-50 text-red-600', icon: <XCircle className="w-3 h-3" /> },
}
const SCENE_LABELS: Record<string, { label: string; color: string }> = {
CONTRACT: { label: '劳动合同', color: 'bg-blue-50 text-blue-600 border border-blue-200' },
RESIGNATION: { label: '离职协议', color: 'bg-orange-50 text-orange-600 border border-orange-200' },
POLICY: { label: '规章制度', color: 'bg-amber-50 text-amber-600 border border-amber-200' },
PAYSLIP: { label: '工资条', color: 'bg-emerald-50 text-emerald-600 border border-emerald-200' },
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
}
export default function MyEsign() {
const queryClient = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
const { data: list = [], isLoading } = useQuery<any[]>({
queryKey: ['portal-esign'],
queryFn: () => portalApi.myEsignList(),
})
const { data: detail, isLoading: detailLoading } = useQuery<any>({
queryKey: ['portal-esign-detail', selectedId],
queryFn: () => portalApi.esignDetail(selectedId!),
enabled: !!selectedId,
})
const signMutation = useMutation({
mutationFn: (id: string) => portalApi.signEsign(id),
onSuccess: () => {
toast.success('签署成功')
queryClient.invalidateQueries({ queryKey: ['portal-esign'] })
queryClient.invalidateQueries({ queryKey: ['portal-esign-detail', selectedId] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '签署失败'),
})
const pendingCount = list.filter((r: any) => r.status === 'PENDING').length
// 详情页
if (selectedId) {
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
return (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2"
>
<ChevronLeft className="w-4 h-4" />
</button>
{detailLoading ? (
<Card className="p-6">
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-100 rounded w-2/3" />
<div className="h-4 bg-gray-100 rounded w-1/3" />
<div className="h-20 bg-gray-100 rounded w-full" />
</div>
</Card>
) : detail ? (
<Card className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<FileText className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-base font-semibold truncate">{detail.documentTitle}</h1>
<div className="text-xs text-gray-500 mt-0.5">
{new Date(detail.createdAt).toLocaleString('zh-CN')}
</div>
</div>
{st && (
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs ${st.color}`}>
{st.icon}{st.label}
</span>
)}
</div>
{detail.remark && (
<div className="mb-4 px-3 py-2 rounded-md bg-gray-50 text-xs text-gray-600">
{detail.remark}
</div>
)}
{detail.documentContent && (
<div className="mb-4 text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border rounded-md p-3 max-h-60 overflow-y-auto">
{detail.documentContent}
</div>
)}
{detail.contractId && (
<div className="mb-4 text-xs text-blue-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
</div>
)}
{detail.scene && SCENE_LABELS[detail.scene] && (
<div className="mb-4">
<span className={`px-2 py-0.5 rounded text-xs ${SCENE_LABELS[detail.scene].color}`}>{SCENE_LABELS[detail.scene].label}</span>
</div>
)}
{/* 签署操作 */}
<div className="border-t pt-4">
{detail.status === 'PENDING' ? (
<Button
className="w-full"
onClick={() => signMutation.mutate(detail.id)}
disabled={signMutation.isPending}
>
<PenTool className="w-4 h-4 mr-1" />
{signMutation.isPending ? '签署中...' : '确认签署'}
</Button>
) : detail.status === 'COMPLETED' ? (
<div className="text-center text-xs text-gray-500">
{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}
</div>
) : (
<div className="text-center text-xs text-gray-400">
{st?.label}
</div>
)}
</div>
</Card>
) : (
<Card className="p-6">
<EmptyState title="记录不存在" description="该签署记录可能已被删除" />
</Card>
)}
</div>
)
}
// 列表页
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PenTool className="w-5 h-5 text-primary" />
<h1 className="text-base font-bold"></h1>
{pendingCount > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />{pendingCount}
</span>
)}
</div>
{isLoading ? (
<div className="text-center py-12 text-gray-400 text-sm">...</div>
) : list.length === 0 ? (
<Card className="p-6">
<EmptyState title="暂无签署任务" description="没有需要您签署的文件" />
</Card>
) : (
<div className="space-y-2">
{list.map((r: any) => {
const st = STATUS_MAP[r.status] || STATUS_MAP.PENDING
return (
<Card key={r.id} className="cursor-pointer hover:shadow-md transition-shadow">
<div onClick={() => setSelectedId(r.id)} className="flex items-center gap-3 p-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
r.status === 'PENDING' ? 'bg-amber-100' : r.status === 'COMPLETED' ? 'bg-green-100' : 'bg-gray-100'
}`}>
<FileText className={`w-5 h-5 ${
r.status === 'PENDING' ? 'text-amber-600' : r.status === 'COMPLETED' ? 'text-green-600' : 'text-gray-400'
}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate min-w-0">{r.documentTitle}</span>
{r.scene && SCENE_LABELS[r.scene] && (
<span className={`px-1.5 py-0.5 rounded text-xs flex-shrink-0 ${SCENE_LABELS[r.scene].color}`}>{SCENE_LABELS[r.scene].label}</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs flex-shrink-0 ${st.color}`}>
{st.icon}{st.label}
</span>
</div>
</Card>
)
})}
</div>
)}
</div>
)
}
+227
View File
@@ -0,0 +1,227 @@
/**
* //
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { GraduationCap, TrendingUp, AlertTriangle, CheckCircle, Clock, XCircle, ArrowLeft } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Button from '../../components/ui/Button'
type Tab = 'training' | 'performance' | 'disciplinary'
const TABS: { key: Tab; label: string; icon: any }[] = [
{ key: 'training', label: '培训记录', icon: GraduationCap },
{ key: 'performance', label: '绩效考核', icon: TrendingUp },
{ key: 'disciplinary', label: '违纪记录', icon: AlertTriangle },
]
const ACK_LABELS: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
const ACK_COLORS: Record<string, string> = { PENDING: 'text-amber-600', SIGNED: 'text-green-600', REFUSED: 'text-red-600' }
const RESULT_LABELS: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
const TYPE_LABELS: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const SEVERITY_LABELS: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' }
const ACTION_LABELS: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
function fmtDate(d: string | Date | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('zh-CN')
}
export default function MyRecords() {
const [tab, setTab] = useState<Tab>('training')
return (
<div>
<div className="flex items-center gap-3 mb-4">
{TABS.map(t => {
const Icon = t.icon
const active = tab === t.key
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${active ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
)
})}
</div>
{tab === 'training' && <TrainingTab />}
{tab === 'performance' && <PerformanceTab />}
{tab === 'disciplinary' && <DisciplinaryTab />}
</div>
)
}
function TrainingTab() {
const queryClient = useQueryClient()
const { data: records, isLoading } = useQuery({
queryKey: ['portal-training'],
queryFn: () => portalApi.myTraining(),
})
const signMut = useMutation({
mutationFn: (id: string) => portalApi.signTraining(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-training'] })
},
})
const refuseMut = useMutation({
mutationFn: (id: string) => portalApi.refuseTraining(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-training'] })
},
})
if (isLoading) return <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GraduationCap className="w-4 h-4 text-primary" />
<span className="font-medium">{r.topic}</span>
</div>
<span className={`text-xs ${ACK_COLORS[r.ackStatus] || 'text-gray-500'}`}>
{ACK_LABELS[r.ackStatus] || r.ackStatus}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{fmtDate(r.trainingDate)}</div>
<div>{r.trainer || '-'}</div>
<div>{r.duration}</div>
</div>
{r.content && <div className="text-sm text-gray-600">{r.content}</div>}
{r.remark && <div className="text-xs text-gray-400">{r.remark}</div>}
{r.ackStatus === 'PENDING' && (
<div className="flex gap-2 pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => refuseMut.mutate(r.id)} disabled={refuseMut.isPending}>
<XCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
function PerformanceTab() {
const queryClient = useQueryClient()
const { data: records, isLoading } = useQuery({
queryKey: ['portal-performance'],
queryFn: () => portalApi.myPerformance(),
})
const signMut = useMutation({
mutationFn: (id: string) => portalApi.signPerformance(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-performance'] })
},
})
if (isLoading) return <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
<span className="font-medium">{r.period} </span>
</div>
<span className={`text-xs ${r.employeeAck ? 'text-green-600' : 'text-amber-600'}`}>
{r.employeeAck ? '已签字' : '待签字'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{r.score}</div>
<div>{r.grade}</div>
<div>{RESULT_LABELS[r.result] || r.result}</div>
</div>
{r.summary && <div className="text-sm text-gray-600">{r.summary}</div>}
{r.improvementPlan && <div className="text-sm text-orange-600">{r.improvementPlan}</div>}
{r.reviewer && <div className="text-xs text-gray-400">{r.reviewer}</div>}
{!r.employeeAck && (
<div className="pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
function DisciplinaryTab() {
const queryClient = useQueryClient()
const { data: records, isLoading } = useQuery({
queryKey: ['portal-disciplinary'],
queryFn: () => portalApi.myDisciplinary(),
})
const signMut = useMutation({
mutationFn: (id: string) => portalApi.signDisciplinary(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-disciplinary'] })
},
})
if (isLoading) return <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="font-medium">{TYPE_LABELS[r.violationType] || r.violationType}</span>
</div>
<span className={`text-xs ${r.employeeAck ? 'text-green-600' : 'text-amber-600'}`}>
{r.employeeAck ? '已签字' : '待签字'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{fmtDate(r.violationDate)}</div>
<div>{SEVERITY_LABELS[r.severity] || r.severity}</div>
<div>{ACTION_LABELS[r.action] || r.action}</div>
</div>
<div className="text-sm text-gray-600">{r.description}</div>
{r.actionDetail && <div className="text-xs text-gray-500">{r.actionDetail}</div>}
{r.witness && <div className="text-xs text-gray-400">{r.witness}</div>}
{!r.employeeAck && (
<div className="pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
+3 -1
View File
@@ -3,12 +3,14 @@
*/
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
import { DollarSign, FileText, ScrollText, LogOut, PenTool, ClipboardList } from 'lucide-react'
const navItems = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
{ path: '/portal/contract', label: '我的合同', icon: FileText },
{ path: '/portal/esign', label: '电子签署', icon: PenTool },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
{ path: '/portal/records', label: '我的记录', icon: ClipboardList },
]
export default function PortalNav() {
+82 -4
View File
@@ -1,10 +1,10 @@
/**
*
*/
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { UserX, Clock, FileText } from 'lucide-react'
import { UserX, Clock, FileText, Camera, X, Image as ImageIcon, Download } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
@@ -38,6 +38,8 @@ export default function ResignationApply() {
expectedDate: '',
remark: '',
})
const [attachments, setAttachments] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
/** 查询离职申请状态 */
const { data: records = [], isLoading } = useQuery<any[]>({
@@ -49,13 +51,14 @@ export default function ResignationApply() {
/** 提交离职申请 */
const submitMutation = useMutation({
mutationFn: async (data: { reason: string; expectedDate: string; remark: string }) => {
mutationFn: async (data: { reason: string; expectedDate: string; remark: string; attachments?: string[] }) => {
return await portalApi.resignationSubmit(data)
},
onSuccess: () => {
toast.success('离职申请已提交,请等待HR审批')
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
setForm({ reason: '', expectedDate: '', remark: '' })
setAttachments([])
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '提交失败')
@@ -79,7 +82,24 @@ export default function ResignationApply() {
const handleSubmit = () => {
if (!form.reason) { toast.error('请选择离职原因'); return }
if (!form.expectedDate) { toast.error('请选择预计离职日期'); return }
submitMutation.mutate(form)
submitMutation.mutate({ ...form, attachments })
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
Array.from(files).forEach(file => {
if (file.size > 5 * 1024 * 1024) {
toast.error(`${file.name} 超过5MB限制`)
return
}
const reader = new FileReader()
reader.onload = () => {
setAttachments(prev => [...prev, reader.result as string])
}
reader.readAsDataURL(file)
})
if (fileInputRef.current) fileInputRef.current.value = ''
}
const hasPending = records.some((r: any) => r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL')
@@ -125,6 +145,40 @@ export default function ResignationApply() {
placeholder="补充说明(选填)"
/>
</div>
<div>
<Label>5</Label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileUpload}
/>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1 px-3 py-2 rounded-md border border-dashed border-gray-300 text-sm text-gray-500 hover:border-primary hover:text-primary transition-colors"
>
<Camera className="w-4 h-4" />
</button>
{attachments.map((img, i) => (
<div key={i} className="relative w-16 h-16 rounded-md overflow-hidden border">
<img src={img} alt={`附件${i + 1}`} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
className="absolute top-0 right-0 bg-black/50 text-white rounded-bl p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
<div className="text-xs text-gray-400 mt-1">HR审批时可查看</div>
</div>
<Button onClick={handleSubmit} disabled={submitMutation.isPending} className="w-full">
{submitMutation.isPending ? '提交中...' : '提交离职申请'}
</Button>
@@ -151,6 +205,7 @@ export default function ResignationApply() {
{records.map((r: any) => {
const statusCfg = STATUS_MAP[r.status] || STATUS_MAP.DRAFT
const canWithdraw = r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL'
const canDownload = r.status === 'COMPLETED'
return (
<div key={r.id} className="p-3 rounded-lg border border-gray-100">
<div className="flex items-center justify-between mb-2">
@@ -169,6 +224,29 @@ export default function ResignationApply() {
<div className="text-xs text-gray-500 mt-1">{r.remark}</div>
)}
</div>
{canDownload && (
<div className="mt-2 pt-2 border-t border-gray-50">
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const blob = await portalApi.downloadCertificate(r.id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `离职证明.doc`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('下载失败')
}
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
)}
{canWithdraw && (
<div className="mt-2 pt-2 border-t border-gray-50">
<Button
+4 -4
View File
@@ -10,7 +10,7 @@ import { Paperclip, Trash2, Eye, Download } from "lucide-react"
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => attachmentApi.add(data),
@@ -49,8 +49,8 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600', CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500' }
const formatSize = (bytes: number) => {
if (!bytes) return '-'
@@ -65,7 +65,7 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId
<Card>
<div className="flex gap-2 mb-3 flex-nowrap items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-32 shrink-0">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="OTHER"></option>
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="CERTIFICATE"></option><option value="CONTRACT"></option><option value="PHOTO"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
+132 -10
View File
@@ -1,19 +1,20 @@
import { QRCodeSVG } from "qrcode.react"
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi } from '../../lib/api-services'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi, socialInsuranceApi } from '../../lib/api-services'
import { copyToClipboard } from '../../lib/clipboard'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import { AlertTriangle, Paperclip, Trash2, Eye, Download } from "lucide-react"
import { AlertTriangle, Paperclip, Trash2, Eye, Download, Copy } from "lucide-react"
import { fmt } from "./shared"
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => attachmentApi.add(data),
@@ -46,8 +47,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600', CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500' }
const formatSize = (bytes: number) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes}B`
@@ -57,6 +58,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
const [form, setForm] = useState({
department: profile.department || '',
position: profile.position || '',
gender: profile.gender || '男',
femaleWorkerType: profile.femaleWorkerType || '',
phone: profile.phone || '',
@@ -85,6 +87,26 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
queryClient.invalidateQueries({ queryKey: ['roster'] })
setEditing(false)
},
onError: (err: any) => {
const details = err?.response?.data?.error?.details
if (details?.length > 0) {
toast.error(details.map((d: any) => `${d.path}: ${d.message}`).join(''))
} else {
toast.error(err?.response?.data?.error?.message || '保存失败')
}
},
})
// 查询社保费用明细(按险种分别计算)
const { data: socialDetail } = useQuery<any>({
queryKey: ['social-calc', profile.id, profile.socialInsBase, profile.city],
queryFn: async () => {
if (!profile.socialInsBase || !profile.city) return null
try {
return await socialInsuranceApi.calculate(Number(profile.socialInsBase), profile.city)
} catch { return null }
},
enabled: !editing && !!profile.socialInsBase && !!profile.city,
})
const handleSave = () => {
@@ -112,6 +134,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
specialDeduction: Number(form.specialDeduction) || 0,
city: form.city || undefined,
education: form.education || undefined,
position: form.position || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
updateMutation.mutate(data)
@@ -127,6 +150,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '学历', value: profile.education || '未填写' },
{ label: '职务/岗位', value: profile.position || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
@@ -195,7 +219,21 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{personalFields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2">{f.value}</span>
<span className="font-medium text-right truncate ml-2 flex items-center gap-1">
{f.value}
{f.label === '身份证号' && profile.idCardNumber && (
<button
type="button"
className="text-gray-400 hover:text-primary transition-colors shrink-0"
title="复制身份证号"
onClick={() => {
copyToClipboard(profile.idCardNumber, '已复制身份证号')
}}
>
<Copy className="w-3 h-3" />
</button>
)}
</span>
</div>
))}
</div>
@@ -211,6 +249,70 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
))}
</div>
</div>
{profile.socialInsBase != null && (
<div className="pt-3 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-2"></h3>
<div className="grid md:grid-cols-2 gap-x-6 gap-y-1 text-xs">
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(profile.socialInsBase)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(profile.housingFundBase || 0)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.08)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">16%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.16)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">2%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.02)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.08)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.5%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.005)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.5%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.005)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.2%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.002)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.008)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">7%</span>
<span>¥{fmt((profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">7%</span>
<span>¥{fmt((profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between font-medium pt-1">
<span></span>
<span className="text-primary">¥{fmt((profile.socialInsBase || 0) * 0.105 + (profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between font-medium pt-1">
<span></span>
<span className="text-primary">¥{fmt((profile.socialInsBase || 0) * 0.255 + (profile.housingFundBase || 0) * 0.07)}</span>
</div>
</div>
<div className="text-xs text-gray-400 mt-1"></div>
</div>
)}
</div>
) : (
<div className="grid md:grid-cols-2 gap-3">
@@ -223,6 +325,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
)}
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
<div><Label>/</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
<div><Label></Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
<div><Label></Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
@@ -254,6 +357,25 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
<span className="text-gray-500"></span>
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
</div>
{socialDetail?.items?.length > 0 && (
<div className="md:col-span-4 mt-2">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="grid md:grid-cols-5 gap-2">
{socialDetail.items.map((item: any) => (
<div key={item.name} className="px-2 py-1.5 rounded bg-gray-50 text-xs">
<div className="font-medium text-gray-700">{item.name}</div>
<div className="text-gray-500 mt-0.5"> ¥{fmt(item.orgAmount)}{item.orgRate}%</div>
<div className="text-gray-500"> ¥{fmt(item.empAmount)}{item.empRate}%</div>
</div>
))}
</div>
{socialDetail.capped && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(socialDetail.actualBase)}</div>}
{socialDetail.floored && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(socialDetail.actualBase)}</div>}
{socialDetail.medicalBase && socialDetail.medicalBase !== socialDetail.actualBase && (
<div className="text-xs text-blue-600 mt-1">¥{fmt(socialDetail.medicalBase)}</div>
)}
</div>
)}
</div>
) : (
<div className="grid md:grid-cols-4 gap-4">
@@ -263,11 +385,11 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label>/</Label>
@@ -358,7 +480,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{!editing && (
<div className="flex gap-2 items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="OTHER"></option>
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="CERTIFICATE"></option><option value="CONTRACT"></option><option value="PHOTO"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
+200 -34
View File
@@ -1,12 +1,14 @@
import { useState, useRef } from "react"
import mammoth from "mammoth"
import api from '../../lib/api'
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { employeeApi } from '../../lib/api-services'
import { employeeApi, esignApi } from '../../lib/api-services'
import { useConfirm } from '../../hooks/useConfirm'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import { FileText, AlertTriangle, X, Paperclip, Trash2, Info, Download } from "lucide-react"
import { FileText, AlertTriangle, X, Paperclip, Trash2, Info, Download, PenTool } from "lucide-react"
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
const queryClient = useQueryClient()
@@ -14,16 +16,106 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
const contractFileRef = useRef<HTMLInputElement>(null)
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('附件')
const [wordHtml, setWordHtml] = useState<string | null>(null)
const uploadAttachmentMutation = useMutation({
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
toast.success('附件已更新')
},
onError: () => toast.error('上传失败'),
})
const deleteAttachmentMutation = useMutation({
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
toast.success('附件已删除')
},
onError: () => toast.error('删除失败'),
})
const handleDeleteAttachment = async (contractId: string, atts: { name: string; url: string }[], idx: number) => {
if (!await confirm({ title: '删除附件', message: '确定删除此附件?删除后不可恢复。' })) return
const newAtts = atts.filter((_, i) => i !== idx)
deleteAttachmentMutation.mutate({ contractId, attachmentUrl: newAtts.length > 0 ? JSON.stringify(newAtts) : '' })
}
const handleSupplementUpload = (e: React.ChangeEvent<HTMLInputElement>, contractId: string, existingAtts: { name: string; url: string }[]) => {
const files = e.target.files
if (!files || files.length === 0) return
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
const maxSize = 10 * 1024 * 1024
const validFiles: File[] = []
for (const file of Array.from(files)) {
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedExts.includes(ext)) {
toast.error(`不支持的文件格式: ${file.name}`)
continue
}
if (file.size > maxSize) {
toast.error(`文件过大: ${file.name}(最大 10MB`)
continue
}
validFiles.push(file)
}
if (validFiles.length === 0) return
const promises = validFiles.map(file => new Promise<{ name: string; url: string }>((resolve) => {
const reader = new FileReader()
reader.onload = (event) => {
resolve({ name: file.name, url: event.target?.result as string })
}
reader.onerror = () => {
toast.error(`读取文件失败: ${file.name}`)
resolve({ name: file.name, url: '' })
}
reader.readAsDataURL(file)
}))
Promise.all(promises).then(atts => {
const validAtts = atts.filter(a => a.url)
if (validAtts.length === 0) return
const merged = [...existingAtts, ...validAtts]
uploadAttachmentMutation.mutate({ contractId, attachmentUrl: JSON.stringify(merged) })
})
e.target.value = ''
}
const addContractMutation = useMutation({
mutationFn: (data: any) => employeeApi.addContract({ ...data, employeeId }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
mutationFn: async (data: any) => {
const res = await employeeApi.addContract({ ...data, employeeId }) as any
const contractId = res?.data?.id
if (data.signMethod === 'ELECTRONIC' && contractId) {
try {
await esignApi.create({
contractId,
employeeId,
documentTitle: `${data.contractType === 'UNFIXED' ? '无固定期限' : '固定期限'}劳动合同`,
remark: '合同创建时自动发起',
scene: 'CONTRACT',
})
toast.success('合同已保存,电子签署记录已创建')
} catch {
toast.success('合同已保存,电子签署记录创建失败(可稍后手动发起)')
}
} else {
toast.success('合同已保存')
}
return res
},
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); queryClient.invalidateQueries({ queryKey: ['esign-records'] }); setShowForm(false) },
})
const deleteContractMutation = useMutation({
mutationFn: (contractId: string) => employeeApi.removeContract(contractId),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已删除') },
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已作废') },
})
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -193,10 +285,12 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
</div>
)}
{form.signMethod === 'ELECTRONIC' && (
<>
<div><Label> *</Label><Input value={form.electronicContractNo} onChange={(e) => setForm({ ...form, electronicContractNo: e.target.value })} placeholder="如 E-2026-001" /></div>
<div><Label> *</Label><Input value={form.electronicContractUrl} onChange={(e) => setForm({ ...form, electronicContractUrl: e.target.value })} placeholder="https://..." /></div>
</>
<div className="md:col-span-2">
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs flex items-start gap-2">
<PenTool className="w-4 h-4 mt-0.5 shrink-0" />
<span></span>
</div>
</div>
)}
<div className="md:col-span-2 flex gap-2">
<Button onClick={() => {
@@ -239,6 +333,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
{(() => {
let atts: { name: string; url: string }[] = []
try {
if (!c.attachmentUrl) throw new Error('empty')
const parsed = JSON.parse(c.attachmentUrl)
atts = Array.isArray(parsed) ? parsed : [{ name: '附件', url: c.attachmentUrl }]
} catch {
@@ -248,42 +343,74 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
atts = [{ name: `附件.${ext}`, url: c.attachmentUrl }]
}
}
if (atts.length === 0) return <span className="text-gray-400 ml-2"></span>
return (
<div className="mt-1 space-y-1">
{atts.length === 0 && <span className="text-gray-400 ml-2"></span>}
{atts.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
<button onClick={() => { setPreviewName(att.name); setPreviewUrl(att.url) }} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<a href={att.url} download={att.name} className="text-gray-400 hover:text-primary ml-2 shrink-0">
<Download className="w-3 h-3" />
</a>
<div className="flex items-center gap-1 ml-2 shrink-0">
<button
type="button"
className="text-gray-400 hover:text-primary"
title="下载附件"
onClick={() => {
const dataToBlobUrl = (dataUrl: string) => {
try {
const arr = dataUrl.split(',')
const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'
const bstr = atob(arr[1])
const u8 = new Uint8Array(bstr.length)
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i)
return URL.createObjectURL(new Blob([u8], { type: mime }))
} catch { return dataUrl }
}
const blobUrl = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
const a = document.createElement('a')
a.href = blobUrl
a.download = att.name
a.click()
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
}}
>
<Download className="w-3 h-3" />
</button>
<button
type="button"
className="text-gray-400 hover:text-danger"
title="删除附件"
disabled={deleteAttachmentMutation.isPending}
onClick={() => handleDeleteAttachment(c.id, atts, idx)}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
<input id={`contract-file-${c.id}`} type="file" multiple className="hidden" onChange={(e) => handleSupplementUpload(e, c.id, atts)} />
<button type="button" onClick={() => document.getElementById(`contract-file-${c.id}`)?.click()} disabled={uploadAttachmentMutation.isPending} className="inline-flex items-center justify-center font-medium rounded-md transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200 px-3 py-1.5 text-xs">
<Paperclip className="w-3 h-3 mr-1" />{atts.length > 0 ? '补充上传' : '上传附件'}
</button>
</div>
)
})()}
</div>
)}
{c.signMethod === 'ELECTRONIC' && (
<>
{c.electronicContractNo && <div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.electronicContractNo}</span></div>}
{c.electronicContractUrl && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
<a href={c.electronicContractUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<FileText className="w-3 h-3" />
</a>
</div>
)}
</>
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
<span className="text-primary flex items-center gap-1 text-xs">
<PenTool className="w-3 h-3" />
</span>
</div>
)}
</div>
<button
onClick={async () => { if (await confirm({ title: '删除合同', message: '确定删除此合同记录?' })) deleteContractMutation.mutate(c.id) }}
onClick={async () => { if (await confirm({ title: '作废合同', message: '确定作废此合同记录?作废后记录将保留但不再生效。' })) deleteContractMutation.mutate(c.id) }}
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
title="删除合同"
title="作废合同"
>
<Trash2 className="w-4 h-4" />
</button>
@@ -308,17 +435,40 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
const isImage = mime.startsWith('image/')
const isPdf = mime === 'application/pdf'
const isWord = mime === 'application/msword' || mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || previewName.endsWith('.doc') || previewName.endsWith('.docx')
// 如果是 Word 文件且尚未转换,异步转换
if (isWord && !wordHtml) {
fetch(blobUrl)
.then(r => r.arrayBuffer())
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
.then(result => setWordHtml(result.value))
.catch(() => setWordHtml('<p style="text-align:center;color:#999;">Word 文件转换失败,请下载查看</p>'))
}
const closePreview = () => {
if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl)
setPreviewUrl(null)
setWordHtml(null)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={closePreview}>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium"> - {previewName}</span>
<div className="flex items-center gap-2">
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
<button type="button" onClick={() => {
const a = document.createElement('a')
a.href = blobUrl
a.download = previewName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}} className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</a>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
</button>
<button onClick={closePreview} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
@@ -328,13 +478,29 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<img src={blobUrl} alt="附件预览" className="max-w-full max-h-full object-contain" />
) : isPdf ? (
<embed src={blobUrl} type="application/pdf" className="w-full h-full" />
) : isWord ? (
wordHtml ? (
<div className="prose prose-sm max-w-none w-full" dangerouslySetInnerHTML={{ __html: wordHtml }} />
) : (
<div className="text-center space-y-3">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
<p className="text-sm text-gray-500"> Word ...</p>
</div>
)
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
<p className="text-sm text-gray-500">线</p>
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<button type="button" onClick={() => {
const a = document.createElement('a')
a.href = blobUrl
a.download = previewName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}} className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Download className="w-4 h-4" />
</a>
</button>
</div>
)}
</div>
+31 -2
View File
@@ -4,7 +4,9 @@ import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import { AlertTriangle, Check } from "lucide-react"
import { AlertTriangle, Check, Download } from "lucide-react"
import { toast } from "sonner"
import { useAuthStore } from '../../store/authStore'
// ========== 违纪记录管理 ==========
@@ -104,7 +106,34 @@ export default function DisciplinaryInfo({ employeeId, records }: { employeeId:
{r.ackMethod && <span className="text-gray-400">{r.ackMethod === 'SIGN' ? '签字' : r.ackMethod === 'ELECTRONIC' ? '电子' : '拒绝'}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
<div className="flex items-center gap-2">
{r.employeeAck && (
<button
onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch(`/api/v1/roster/${employeeId}/disciplinary/${r.id}/certificate`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `违纪确认证明_${r.violationDate?.toString().slice(0, 10)}.doc`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('下载失败')
}
}}
className="text-xs text-primary hover:underline flex items-center gap-1 shrink-0"
>
<Download className="w-3 h-3" />
</button>
)}
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</div>
</Card>
))}
@@ -0,0 +1,316 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Search, Plus, Edit2, Trash2, X, Download } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
import { usePageSize } from '../../hooks/usePageSize'
import { Input, Label, Select } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
const TYPE_LABELS: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const SEVERITY_LABELS: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' }
const SEVERITY_COLORS: Record<string, string> = { WARNING: 'bg-amber-50 text-amber-700', SERIOUS: 'bg-orange-50 text-orange-700', SEVERE: 'bg-red-50 text-red-700' }
const ACTION_LABELS: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
function fmtDate(d: string | Date | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('zh-CN')
}
export default function DisciplinaryRecords() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [keyword, setKeyword] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const { data, isLoading } = useQuery({
queryKey: ['disciplinary-list', page, pageSize, keyword],
queryFn: () => rosterApi.disciplinaryList({ page, pageSize, keyword }),
})
const { data: employees } = useQuery({
queryKey: ['employees-active'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const saveMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
delete data.employeeId
const isEdit = !!data.recordId
const recordId = data.recordId
delete data.recordId
if (isEdit) {
return api.put(`/roster/${empId}/disciplinary/${recordId}`, data)
}
return api.post(`/roster/${empId}/disciplinary`, data)
},
onSuccess: () => {
toast.success('违纪记录已保存')
queryClient.invalidateQueries({ queryKey: ['disciplinary-list'] })
setShowCreate(false)
setEditRecord(null)
},
onError: () => toast.error('保存失败'),
})
const deleteMut = useMutation({
mutationFn: ({ employeeId, recordId }: { employeeId: string; recordId: string }) =>
api.delete(`/roster/${employeeId}/disciplinary/${recordId}`),
onSuccess: () => {
toast.success('记录已删除')
queryClient.invalidateQueries({ queryKey: ['disciplinary-list'] })
},
})
const records = data?.records || []
const total = data?.total || 0
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
placeholder="搜索员工姓名"
className="pl-9"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={10} className="py-8 text-center text-gray-400">...</td></tr>
) : records.length === 0 ? (
<tr><td colSpan={10} className="py-8 text-center text-gray-400"></td></tr>
) : records.map((r: any) => (
<tr key={r.id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4">
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
</td>
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
<td className="py-2 pr-4">{fmtDate(r.violationDate)}</td>
<td className="py-2 pr-4">{TYPE_LABELS[r.violationType] || r.violationType}</td>
<td className="py-2 pr-4 max-w-xs truncate" title={r.description}>{r.description}</td>
<td className="py-2 pr-4">
<span className={`inline-block px-2 py-0.5 rounded text-xs ${SEVERITY_COLORS[r.severity] || 'bg-gray-50 text-gray-600'}`}>
{SEVERITY_LABELS[r.severity] || r.severity}
</span>
</td>
<td className="py-2 pr-4 text-gray-600">{ACTION_LABELS[r.action] || r.action}</td>
<td className="py-2 pr-4 max-w-xs truncate text-gray-500 text-xs" title={r.actionDetail}>{r.actionDetail || '-'}</td>
<td className="py-2 pr-4">
{r.employeeAck ? (
<span className="text-xs text-green-600"></span>
) : (
<span className="text-xs text-amber-600"></span>
)}
</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
{r.employeeAck && (
<button
title="下载违纪确认证明"
onClick={async () => {
try {
const { useAuthStore } = await import('../../store/authStore')
const token = useAuthStore.getState().accessToken
const res = await fetch(`/api/v1/roster/${r.employeeId}/disciplinary/${r.id}/certificate`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const cd = res.headers.get('content-disposition') || ''
const fname = cd.match(/filename\*=UTF-8''(.+)/)?.[1] || cd.match(/filename="(.+?)"/)?.[1] || '违纪确认证明.doc'
a.download = decodeURIComponent(fname)
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch { toast.error('下载失败') }
}}
className="p-1 hover:bg-gray-100 rounded"
>
<Download className="w-3.5 h-3.5 text-primary" />
</button>
)}
<button onClick={() => setEditRecord(r)} className="p-1 hover:bg-gray-100 rounded">
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
</button>
<button
onClick={() => { if (confirm('确认删除?')) deleteMut.mutate({ employeeId: r.employeeId, recordId: r.id }) }}
className="p-1 hover:bg-gray-100 rounded"
>
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> {total} </span>
<div className="flex gap-1">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}></Button>
<span className="px-3 py-1 text-xs text-gray-500">{page} / {totalPages}</span>
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)}></Button>
</div>
</div>
)}
{(showCreate || editRecord) && (
<DisciplinaryForm
employees={employees || []}
record={editRecord}
onSubmit={(data) => saveMut.mutate(data)}
onClose={() => { setShowCreate(false); setEditRecord(null) }}
/>
)}
</div>
)
}
function DisciplinaryForm({ employees, record, onSubmit, onClose }: {
employees: any[]
record: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const [form, setForm] = useState({
employeeId: record?.employeeId || '',
violationDate: record?.violationDate ? new Date(record.violationDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
violationType: record?.violationType || 'OTHER',
description: record?.description || '',
severity: record?.severity || 'WARNING',
action: record?.action || 'ORAL_WARNING',
actionDetail: record?.actionDetail || '',
witness: record?.witness || '',
})
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{record ? '编辑违纪记录' : '新增违纪记录'}</h3>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
<div className="space-y-3">
{!record && (
<div>
<Label></Label>
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })}>
<option value=""></option>
{employees.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department || ''}</option>
))}
</Select>
</div>
)}
<div>
<Label></Label>
<Input type="date" value={form.violationDate} onChange={(e) => setForm({ ...form, violationDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Select value={form.violationType} onChange={(e) => setForm({ ...form, violationType: e.target.value })}>
<option value="LATE"></option>
<option value="ABSENT"></option>
<option value="INSUBORDINATION"></option>
<option value="MISCONDUCT"></option>
<option value="VIOLATE_POLICY"></option>
<option value="OTHER"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="详细描述违纪事实" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })}>
<option value="WARNING"></option>
<option value="SERIOUS"></option>
<option value="SEVERE"></option>
</Select>
</div>
<div>
<Label></Label>
<Select value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value })}>
<option value="ORAL_WARNING"></option>
<option value="WRITTEN_WARNING"></option>
<option value="DEDUCTION"></option>
<option value="DEMOTION"></option>
<option value="TERMINATION"></option>
</Select>
</div>
</div>
<div>
<Label></Label>
<textarea value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} rows={3} className="w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="详细说明处罚执行情况,如扣款金额、书面警告文号、降职后岗位等" />
</div>
<div>
<Label></Label>
<Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} placeholder="见证人(选填)" />
</div>
{record && (
<div>
<Label></Label>
<div className="text-sm text-gray-600">
{record.employeeAck ? (
<span className="text-green-600">{record.ackDate ? new Date(record.ackDate).toLocaleDateString('zh-CN') : ''}</span>
) : (
<span className="text-amber-600"> <span className="text-xs text-gray-400 ml-1"></span></span>
)}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit(form)} disabled={!form.employeeId || !form.description}></Button>
</div>
</div>
</div>
</div>
)
}
@@ -63,7 +63,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
<>
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} employeeId={employeeId} />}
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
{activeTab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
{activeTab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
+69 -2
View File
@@ -1,14 +1,18 @@
import { useState } from "react"
import { toast } from "sonner"
import { useQuery } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import { rosterApi, evidenceApi } from '../../lib/api-services'
import { useAuthStore } from "../../store/authStore"
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { AlertTriangle, Scale } from "lucide-react"
import { AlertTriangle, Scale, ShieldCheck } from "lucide-react"
// ========== 仲裁证据链 ==========
export default function EvidenceChain({ employeeId }: { employeeId: string }) {
const [verifyResult, setVerifyResult] = useState<any>(null)
const [verifying, setVerifying] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
@@ -16,6 +20,24 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
},
})
const handleVerify = async () => {
setVerifying(true)
try {
const records = await evidenceApi.byEmployee(employeeId)
const results: any[] = []
for (const r of records) {
const result = await evidenceApi.verify(r.id)
results.push({ id: r.id, category: r.category, refId: r.refId, ...result })
}
setVerifyResult({ total: records.length, valid: results.filter(r => r.valid).length, invalid: results.filter(r => !r.valid).length, details: results })
toast.success(`验证完成:${results.filter(r => r.valid).length}/${results.length} 条有效`)
} catch {
toast.error('验证失败')
} finally {
setVerifying(false)
}
}
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!data) return <div className="text-center py-8 text-gray-400"></div>
if (!data.evidence || data.evidence.length === 0) return (
@@ -104,6 +126,10 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
</div>
)}
<Button onClick={handleExport}></Button>
<Button variant="secondary" onClick={handleVerify} disabled={verifying}>
<ShieldCheck className="w-4 h-4 mr-1" />
{verifying ? '验证中...' : '验证完整性'}
</Button>
</div>
</div>
</Card>
@@ -129,6 +155,47 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
</Card>
)}
{verifyResult && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-safe" />
</h3>
<div className="flex items-center gap-4 mb-3">
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold">{verifyResult.total}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold text-safe">{verifyResult.valid}</div>
</div>
{verifyResult.invalid > 0 && (
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold text-danger">{verifyResult.invalid}</div>
</div>
)}
</div>
{verifyResult.invalid > 0 && (
<div className="space-y-1">
{verifyResult.details.filter((r: any) => !r.valid).map((r: any, i: number) => (
<div key={i} className="text-xs border rounded p-2 bg-red-50 border-red-200 text-red-700">
<span className="font-medium">{r.category}</span>
{r.refId && <span className="text-xs opacity-70 ml-2">ID: {r.refId}</span>}
<div className="mt-0.5 opacity-90"> {r.expectedHash?.slice(0, 16)}... {r.actualHash?.slice(0, 16)}...</div>
</div>
))}
</div>
)}
{verifyResult.invalid === 0 && (
<div className="text-xs text-safe flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5" />
</div>
)}
</Card>
)}
<div className="space-y-2">
{data.evidence.map((e: any, i: number) => (
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
+25 -14
View File
@@ -1,4 +1,5 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { socialInsuranceApi } from '../../lib/api-services'
@@ -7,26 +8,36 @@ import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { fmt } from "./shared"
import { ExternalLink } from "lucide-react"
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords, employeeId }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[]; employeeId?: string }) {
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
const navigate = useNavigate()
return (
<div className="space-y-3">
<div className="flex gap-1">
<button
onClick={() => setSubTab('payslip')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{payslips?.length || 0}
</button>
<button
onClick={() => setSubTab('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{monthlyProcessRecords?.length || 0}
</button>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<button
onClick={() => setSubTab('payslip')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{payslips?.length || 0}
</button>
<button
onClick={() => setSubTab('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{monthlyProcessRecords?.length || 0}
</button>
</div>
{employeeId && (
<Button variant="secondary" size="sm" onClick={() => navigate(`/money?employeeId=${employeeId}&tab=payslip`)}>
<ExternalLink className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
{subTab === 'payslip' && (
+102 -11
View File
@@ -1,5 +1,5 @@
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
@@ -11,11 +11,17 @@ import { AlertTriangle, Check } from "lucide-react"
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ period: '', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '', templateId: '' })
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>({})
const { data: templates } = useQuery({
queryKey: ['performance-templates'],
queryFn: () => rosterApi.performanceTemplates(),
})
const createMutation = useMutation({
mutationFn: (data: any) => rosterApi.performance(employeeId, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false); setDimensionScores({}) },
})
const deleteMutation = useMutation({
@@ -25,6 +31,45 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
// 根据得分自动计算等级和结果
const scoreToGrade = (score: number): { grade: string; result: string } => {
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
if (score >= 80) return { grade: 'B', result: 'QUALIFIED' }
if (score >= 60) return { grade: 'C', result: 'NEED_IMPROVE' }
return { grade: 'D', result: 'UNQUALIFIED' }
}
const handleScoreChange = (score: number) => {
const { grade, result } = scoreToGrade(score)
setForm({ ...form, score, grade, result })
}
const selectedTemplate = (templates || []).find((t: any) => t.id === form.templateId)
const dimensions: any[] = selectedTemplate?.dimensions || []
const handleDimensionChange = (name: string, score: number) => {
const updated = { ...dimensionScores, [name]: score }
setDimensionScores(updated)
if (dimensions.length > 0) {
const totalScore = dimensions.reduce((sum: number, d: any) => {
const s = updated[d.name] ?? 0
const weight = d.weight || 0
const maxScore = d.maxScore || 100
return sum + (s / maxScore) * weight * 100
}, 0)
const { grade, result } = scoreToGrade(Math.round(totalScore))
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
}
}
const handleSubmit = () => {
const data: any = { ...form }
if (form.templateId) {
data.dimensionScores = dimensionScores
}
createMutation.mutate(data)
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
@@ -35,27 +80,66 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder="如 2026-07 或 2026-Q3" /></div>
<div><Label></Label><Input type="number" value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} /></div>
<div><Label></Label>
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
<div><Label></Label>
<Select value={form.periodType} onChange={(e) => setForm({ ...form, periodType: e.target.value as any })}>
<option value="MONTHLY"></option>
<option value="QUARTERLY"></option>
<option value="YEARLY"></option>
</Select>
</div>
<div><Label></Label>
<div><Label></Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'MONTHLY' ? '如 2026-07' : form.periodType === 'QUARTERLY' ? '如 2026-Q3' : '如 2026'} /></div>
<div className="md:col-span-2"><Label></Label>
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
<option value="">使</option>
{(templates || []).map((t: any) => (
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
))}
</Select>
</div>
{dimensions.length > 0 ? (
<div className="md:col-span-2 border border-gray-200 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
{dimensions.map((d: any) => (
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<span className="text-sm">{d.name}</span>
<span className="text-xs text-gray-400 ml-1">{d.weight}%</span>
</div>
<div className="col-span-4">
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
</div>
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
</div>
))}
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<div><Label></Label><Input type="number" value={form.score} readOnly className="bg-gray-50" /></div>
<div><Label></Label><Input value={form.grade} readOnly className="bg-gray-50" /></div>
</div>
</div>
) : (
<>
<div><Label></Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} /></div>
<div><Label></Label>
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
</Select>
</div>
</>
)}
<div><Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="如:调岗至XX岗位,培训XX技能" /></div>
<div><Label></Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="perfAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<label htmlFor="perfAck" className="text-xs"></label>
</div>
{form.employeeAck && <div><Label></Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={handleSubmit} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
@@ -73,6 +157,13 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs"> {r.score} · {r.grade}</span>
</div>
{r.dimensionScores && Object.keys(r.dimensionScores).length > 0 && (
<div className="flex flex-wrap gap-1">
{Object.entries(r.dimensionScores).map(([name, score]: [string, any]) => (
<span key={name} className="text-xs px-2 py-0.5 rounded bg-gray-50 text-gray-600">{name}: {score}</span>
))}
</div>
)}
{r.summary && <div className="text-xs text-gray-600 leading-relaxed">{r.summary}</div>}
{r.improvementPlan && (
<div className="text-xs bg-amber-50 text-amber-700 px-2 py-1.5 rounded leading-relaxed">
@@ -0,0 +1,595 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Search, Plus, Edit2, Trash2, X, LayoutTemplate } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
import { usePageSize } from '../../hooks/usePageSize'
import { Input, Label, Select } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
const RESULT_LABELS: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
const RESULT_COLORS: Record<string, string> = { EXCELLENT: 'bg-green-50 text-green-700', QUALIFIED: 'bg-blue-50 text-blue-700', NEED_IMPROVE: 'bg-amber-50 text-amber-700', UNQUALIFIED: 'bg-red-50 text-red-700' }
export default function PerformanceRecords() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [keyword, setKeyword] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const [showTemplateModal, setShowTemplateModal] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['performance-list', page, pageSize, keyword],
queryFn: () => rosterApi.performanceList({ page, pageSize, keyword }),
})
const { data: employees } = useQuery({
queryKey: ['employees-active'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const { data: templates } = useQuery({
queryKey: ['performance-templates'],
queryFn: () => rosterApi.performanceTemplates(),
})
const saveMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
delete data.employeeId
const isEdit = !!data.recordId
const recordId = data.recordId
delete data.recordId
if (isEdit) {
return api.put(`/roster/${empId}/performance/${recordId}`, data)
}
return api.post(`/roster/${empId}/performance`, data)
},
onSuccess: () => {
toast.success('绩效记录已保存')
queryClient.invalidateQueries({ queryKey: ['performance-list'] })
setShowCreate(false)
setEditRecord(null)
},
onError: () => toast.error('保存失败'),
})
const deleteMut = useMutation({
mutationFn: ({ employeeId, recordId }: { employeeId: string; recordId: string }) =>
api.delete(`/roster/${employeeId}/performance/${recordId}`),
onSuccess: () => {
toast.success('记录已删除')
queryClient.invalidateQueries({ queryKey: ['performance-list'] })
},
})
const records = data?.records || []
const total = data?.total || 0
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setShowTemplateModal(true)}>
<LayoutTemplate className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
placeholder="搜索员工姓名"
className="pl-9"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={9} className="py-8 text-center text-gray-400">...</td></tr>
) : records.length === 0 ? (
<tr><td colSpan={9} className="py-8 text-center text-gray-400"></td></tr>
) : records.map((r: any) => (
<tr key={r.id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4">
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
</td>
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
<td className="py-2 pr-4">{r.period}</td>
<td className="py-2 pr-4">{r.score}</td>
<td className="py-2 pr-4">{r.grade}</td>
<td className="py-2 pr-4">
<span className={`inline-block px-2 py-0.5 rounded text-xs ${RESULT_COLORS[r.result] || 'bg-gray-50 text-gray-600'}`}>
{RESULT_LABELS[r.result] || r.result}
</span>
</td>
<td className="py-2 pr-4 text-gray-600">{r.reviewer || '-'}</td>
<td className="py-2 pr-4">
{r.employeeAck ? (
<span className="text-xs text-green-600"></span>
) : (
<span className="text-xs text-amber-600"></span>
)}
</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
<button onClick={() => setEditRecord(r)} className="p-1 hover:bg-gray-100 rounded">
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
</button>
<button
onClick={() => { if (confirm('确认删除?')) deleteMut.mutate({ employeeId: r.employeeId, recordId: r.id }) }}
className="p-1 hover:bg-gray-100 rounded"
>
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> {total} </span>
<div className="flex gap-1">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}></Button>
<span className="px-3 py-1 text-xs text-gray-500">{page} / {totalPages}</span>
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)}></Button>
</div>
</div>
)}
{(showCreate || editRecord) && (
<PerformanceForm
employees={employees || []}
templates={templates || []}
record={editRecord}
onSubmit={(data) => saveMut.mutate(data)}
onClose={() => { setShowCreate(false); setEditRecord(null) }}
/>
)}
{showTemplateModal && (
<TemplateModal
templates={templates || []}
onClose={() => setShowTemplateModal(false)}
/>
)}
</div>
)
}
function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
employees: any[]
templates: any[]
record: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const detectPeriodType = (period: string, fallback?: string): string => {
if (/^\d{4}-Q[1-4]$/.test(period)) return 'QUARTERLY'
if (/^\d{4}$/.test(period)) return 'YEARLY'
if (/^\d{4}-\d{2}$/.test(period)) return 'MONTHLY'
return fallback || 'MONTHLY'
}
const initialPeriodType = record ? detectPeriodType(record.period, record.periodType) : 'MONTHLY'
const [form, setForm] = useState({
employeeId: record?.employeeId || '',
period: record?.period || new Date().toISOString().slice(0, 7),
periodType: initialPeriodType,
score: record?.score || 80,
grade: record?.grade || 'B',
result: record?.result || 'QUALIFIED',
summary: record?.summary || '',
improvementPlan: record?.improvementPlan || '',
reviewer: record?.reviewer || '',
templateId: record?.templateId || '',
})
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>(record?.dimensionScores || {})
const selectedTemplate = templates.find((t: any) => t.id === form.templateId)
const dimensions: any[] = selectedTemplate?.dimensions || []
const scoreToGrade = (score: number): { grade: string; result: string } => {
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
if (score >= 80) return { grade: 'B', result: 'QUALIFIED' }
if (score >= 60) return { grade: 'C', result: 'NEED_IMPROVE' }
return { grade: 'D', result: 'UNQUALIFIED' }
}
const handleScoreChange = (score: number) => {
const { grade, result } = scoreToGrade(score)
setForm({ ...form, score, grade, result })
}
const handleDimensionChange = (name: string, score: number) => {
const updated = { ...dimensionScores, [name]: score }
setDimensionScores(updated)
// 按权重计算总分
if (dimensions.length > 0) {
const totalScore = dimensions.reduce((sum: number, d: any) => {
const s = updated[d.name] ?? 0
const weight = d.weight || 0
const maxScore = d.maxScore || 100
return sum + (s / maxScore) * weight * 100
}, 0)
const { grade, result } = scoreToGrade(Math.round(totalScore))
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
}
}
const handleSubmit = () => {
const data: any = { ...form }
if (form.templateId) {
data.templateId = form.templateId
data.dimensionScores = dimensionScores
}
onSubmit(data)
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{record ? '编辑绩效记录' : '新增绩效记录'}</h3>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Select value={form.periodType} onChange={(e) => {
const newType = e.target.value
let newPeriod = form.period
const year = form.period.slice(0, 4) || new Date().toISOString().slice(0, 4)
if (newType === 'MONTHLY') newPeriod = year + '-' + (new Date().getMonth() + 1).toString().padStart(2, '0')
else if (newType === 'QUARTERLY') newPeriod = year + '-Q1'
else if (newType === 'YEARLY') newPeriod = year
setForm({ ...form, periodType: newType, period: newPeriod })
}}>
<option value="MONTHLY"></option>
<option value="QUARTERLY"></option>
<option value="YEARLY"></option>
</Select>
</div>
{!record && (
<div>
<Label></Label>
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })}>
<option value=""></option>
{employees.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department || ''}</option>
))}
</Select>
</div>
)}
<div>
<Label></Label>
<Input
type={form.periodType === 'YEARLY' ? 'number' : form.periodType === 'QUARTERLY' ? 'text' : 'month'}
value={form.period}
onChange={(e) => setForm({ ...form, period: e.target.value })}
placeholder={form.periodType === 'YEARLY' ? '如 2026' : form.periodType === 'QUARTERLY' ? '如 2026-Q1' : undefined}
/>
</div>
<div>
<Label></Label>
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
<option value="">使</option>
{templates.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
))}
</Select>
</div>
{dimensions.length > 0 ? (
<div className="border border-gray-200 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
{dimensions.map((d: any) => (
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<span className="text-sm">{d.name}</span>
{d.description && <span className="text-xs text-gray-400 ml-1">({d.description})</span>}
<span className="text-xs text-gray-400 ml-1">{d.weight}%</span>
</div>
<div className="col-span-4">
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
</div>
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
</div>
))}
<div className="text-xs text-gray-500 pt-1 border-t"></div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} />
</div>
<div>
<Label></Label>
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
<option value="D">D</option>
</Select>
</div>
</div>
)}
{dimensions.length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.score} readOnly className="bg-gray-50" />
</div>
<div>
<Label></Label>
<Input value={form.grade} readOnly className="bg-gray-50" />
</div>
</div>
)}
<div>
<Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
<option value="EXCELLENT"></option>
<option value="QUALIFIED"></option>
<option value="NEED_IMPROVE"></option>
<option value="UNQUALIFIED"></option>
</Select>
</div>
<div>
<Label> *</Label>
<Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} placeholder="请输入考评人姓名" />
</div>
<div>
<Label></Label>
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} placeholder="考核评语" />
</div>
<div>
<Label></Label>
<Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="改进计划(选填)" />
</div>
{record && (
<div>
<Label></Label>
<div className="text-sm text-gray-600">
{record.employeeAck ? (
<span className="text-green-600">{record.ackDate ? new Date(record.ackDate).toLocaleDateString('zh-CN') : ''}</span>
) : (
<span className="text-amber-600"> <span className="text-xs text-gray-400 ml-1"></span></span>
)}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={handleSubmit} disabled={!form.employeeId || !form.period || !form.reviewer}></Button>
</div>
</div>
</div>
</div>
)
}
function TemplateModal({ templates, onClose }: {
templates: any[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<any>(null)
const [showForm, setShowForm] = useState(false)
const createMut = useMutation({
mutationFn: (data: any) => rosterApi.createPerformanceTemplate(data),
onSuccess: () => {
toast.success('模板已创建')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
setShowForm(false)
},
onError: () => toast.error('创建失败'),
})
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => rosterApi.updatePerformanceTemplate(id, data),
onSuccess: () => {
toast.success('模板已更新')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
setShowForm(false)
setEditing(null)
},
onError: () => toast.error('更新失败'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => rosterApi.deletePerformanceTemplate(id),
onSuccess: () => {
toast.success('模板已删除')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
},
})
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
<div className="flex gap-2">
<Button size="sm" onClick={() => { setEditing(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
</div>
{showForm ? (
<TemplateForm
template={editing}
onSubmit={(data) => {
if (editing) {
updateMut.mutate({ id: editing.id, data })
} else {
createMut.mutate(data)
}
}}
onClose={() => { setShowForm(false); setEditing(null) }}
/>
) : (
<div className="space-y-2">
{templates.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
</div>
) : templates.map((t: any) => (
<div key={t.id} className="border border-gray-200 rounded-md p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{t.name}</span>
{t.isDefault && <span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary"></span>}
</div>
<div className="flex gap-1">
<button onClick={() => { setEditing(t); setShowForm(true) }} className="p-1 hover:bg-gray-100 rounded">
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
</button>
<button
onClick={() => { if (confirm('确认删除此模板?')) deleteMut.mutate(t.id) }}
className="p-1 hover:bg-gray-100 rounded"
>
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
</div>
</div>
{t.description && <p className="text-xs text-gray-500 mt-1">{t.description}</p>}
<div className="flex flex-wrap gap-1 mt-2">
{(t.dimensions as any[]).map((d: any) => (
<span key={d.name} className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{d.name}{d.weight}%
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
function TemplateForm({ template, onSubmit, onClose }: {
template: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const [name, setName] = useState(template?.name || '')
const [description, setDescription] = useState(template?.description || '')
const [isDefault, setIsDefault] = useState(template?.isDefault || false)
const [dimensions, setDimensions] = useState<any[]>(
template?.dimensions || [{ name: '', weight: 100, maxScore: 100, description: '' }]
)
const addDimension = () => {
setDimensions([...dimensions, { name: '', weight: 0, maxScore: 100, description: '' }])
}
const removeDimension = (idx: number) => {
setDimensions(dimensions.filter((_, i) => i !== idx))
}
const updateDimension = (idx: number, field: string, value: any) => {
setDimensions(dimensions.map((d, i) => i === idx ? { ...d, [field]: value } : d))
}
const totalWeight = dimensions.reduce((sum, d) => sum + (Number(d.weight) || 0), 0)
const canSubmit = name && dimensions.every(d => d.name) && totalWeight === 100
return (
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:月度绩效考核表" />
</div>
<div>
<Label></Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="模板用途说明(选填)" />
</div>
<div>
<Label> *</Label>
<div className="space-y-2">
{dimensions.map((d, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-center border border-gray-200 rounded p-2">
<div className="col-span-3">
<Input value={d.name} onChange={(e) => updateDimension(idx, 'name', e.target.value)} placeholder="维度名称" className="text-sm" />
</div>
<div className="col-span-2">
<Input type="number" min={0} max={100} value={d.weight} onChange={(e) => updateDimension(idx, 'weight', Number(e.target.value))} placeholder="权重%" className="text-sm" />
</div>
<div className="col-span-2">
<Input type="number" min={1} value={d.maxScore} onChange={(e) => updateDimension(idx, 'maxScore', Number(e.target.value))} placeholder="满分" className="text-sm" />
</div>
<div className="col-span-4">
<Input value={d.description || ''} onChange={(e) => updateDimension(idx, 'description', e.target.value)} placeholder="说明(选填)" className="text-sm" />
</div>
<div className="col-span-1">
{dimensions.length > 1 && (
<button onClick={() => removeDimension(idx)} className="p-1 hover:bg-gray-100 rounded">
<X className="w-3.5 h-3.5 text-red-400" />
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-2">
<button onClick={addDimension} className="text-xs text-primary hover:underline">+ </button>
<span className={`text-xs ${totalWeight === 100 ? 'text-green-600' : 'text-amber-600'}`}>{totalWeight}%</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit({ name, description, dimensions, isDefault })} disabled={!canSubmit}>
</Button>
</div>
{!canSubmit && totalWeight !== 100 && (
<div className="text-xs text-amber-600">100%</div>
)}
</div>
)
}
@@ -0,0 +1,377 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Search, Plus, Edit2, Trash2, X, Bell, Users, Check } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
import { usePageSize } from '../../hooks/usePageSize'
import { Input, Label, Select } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
import Modal from '../../components/ui/Modal'
const ACK_LABELS: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
const ACK_COLORS: Record<string, string> = { PENDING: 'bg-amber-50 text-amber-700', SIGNED: 'bg-green-50 text-green-700', REFUSED: 'bg-red-50 text-red-700' }
function fmtDate(d: string | Date | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('zh-CN')
}
export default function TrainingRecords() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [keyword, setKeyword] = useState('')
const [filterAckStatus, setFilterAckStatus] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const { data, isLoading } = useQuery({
queryKey: ['training-list', page, pageSize, keyword, filterAckStatus],
queryFn: () => rosterApi.trainingList({ page, pageSize, keyword, ackStatus: filterAckStatus }),
})
const { data: employees } = useQuery({
queryKey: ['employees-active'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const createMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
delete data.employeeId
return api.post(`/roster/${empId}/training`, data)
},
onSuccess: () => {
toast.success('培训记录已添加')
queryClient.invalidateQueries({ queryKey: ['training-list'] })
setShowCreate(false)
},
onError: () => toast.error('添加失败'),
})
const batchCreateMut = useMutation({
mutationFn: (data: any) => api.post('/roster/training/batch', data),
onSuccess: (data: any) => {
toast.success(`已为 ${data?.count || 0} 名员工添加培训记录`)
queryClient.invalidateQueries({ queryKey: ['training-list'] })
setShowCreate(false)
},
onError: () => toast.error('批量添加失败'),
})
const updateMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
const recordId = data.recordId
delete data.employeeId
delete data.recordId
return api.put(`/roster/${empId}/training/${recordId}`, data)
},
onSuccess: () => {
toast.success('培训记录已更新')
queryClient.invalidateQueries({ queryKey: ['training-list'] })
setEditRecord(null)
},
onError: () => toast.error('更新失败'),
})
const deleteMut = useMutation({
mutationFn: ({ employeeId, recordId }: { employeeId: string; recordId: string }) =>
api.delete(`/roster/${employeeId}/training/${recordId}`),
onSuccess: () => {
toast.success('记录已删除')
queryClient.invalidateQueries({ queryKey: ['training-list'] })
},
})
const remindMut = useMutation({
mutationFn: (recordId: string) => rosterApi.trainingRemind(recordId),
onSuccess: (data: any) => {
toast.success(data?.message || '催办已发送')
},
onError: () => toast.error('催办失败'),
})
const records = data?.records || []
const total = data?.total || 0
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
placeholder="搜索员工姓名"
className="pl-9"
/>
</div>
<Select
value={filterAckStatus}
onChange={(e) => { setFilterAckStatus(e.target.value); setPage(1) }}
className="w-32"
>
<option value=""></option>
<option value="PENDING"></option>
<option value="SIGNED"></option>
<option value="REFUSED"></option>
</Select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium">()</th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={8} className="py-8 text-center text-gray-400">...</td></tr>
) : records.length === 0 ? (
<tr><td colSpan={8} className="py-8 text-center text-gray-400"></td></tr>
) : records.map((r: any) => (
<tr key={r.id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4">
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
</td>
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
<td className="py-2 pr-4">{fmtDate(r.trainingDate)}</td>
<td className="py-2 pr-4">{r.topic}</td>
<td className="py-2 pr-4 text-gray-600">{r.trainer || '-'}</td>
<td className="py-2 pr-4">{r.duration}</td>
<td className="py-2 pr-4">
<span className={`inline-block px-2 py-0.5 rounded text-xs ${ACK_COLORS[r.ackStatus] || 'bg-gray-50 text-gray-600'}`}>
{ACK_LABELS[r.ackStatus] || r.ackStatus}
</span>
</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
{r.ackStatus === 'PENDING' && (
<button
onClick={() => remindMut.mutate(r.id)}
disabled={remindMut.isPending}
className="p-1 hover:bg-gray-100 rounded"
title="催办签收"
>
<Bell className="w-3.5 h-3.5 text-amber-500" />
</button>
)}
<button onClick={() => setEditRecord(r)} className="p-1 hover:bg-gray-100 rounded">
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
</button>
<button
onClick={() => { if (confirm('确认删除?')) deleteMut.mutate({ employeeId: r.employeeId, recordId: r.id }) }}
className="p-1 hover:bg-gray-100 rounded"
>
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> {total} </span>
<div className="flex gap-1">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}></Button>
<span className="px-3 py-1 text-xs text-gray-500">{page} / {totalPages}</span>
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)}></Button>
</div>
</div>
)}
{(showCreate || editRecord) && (
<TrainingForm
employees={employees || []}
record={editRecord}
onSubmit={(data) => {
if (editRecord) {
updateMut.mutate({ ...data, employeeId: editRecord.employeeId, recordId: editRecord.id })
} else if (data.employeeIds) {
batchCreateMut.mutate(data)
} else {
createMut.mutate(data)
}
}}
onClose={() => { setShowCreate(false); setEditRecord(null) }}
/>
)}
</div>
)
}
function TrainingForm({ employees, record, onSubmit, onClose }: {
employees: any[]
record: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const [batchMode, setBatchMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchSearch, setBatchSearch] = useState('')
const [form, setForm] = useState({
employeeId: record?.employeeId || '',
trainingDate: record?.trainingDate ? new Date(record.trainingDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
topic: record?.topic || '',
content: record?.content || '',
trainer: record?.trainer || '',
duration: record?.duration || 0,
remark: record?.remark || '',
})
const filteredEmployees = batchSearch
? employees.filter((e: any) => e.name.includes(batchSearch) || (e.department || '').includes(batchSearch))
: employees
const toggleEmployee = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
const handleSubmit = () => {
if (batchMode) {
onSubmit({ ...form, employeeIds: selectedIds })
} else {
onSubmit(form)
}
}
const canSubmit = batchMode ? selectedIds.length > 0 && form.topic : form.employeeId && form.topic
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{record ? '编辑培训记录' : '新增培训记录'}</h3>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
<div className="space-y-3">
{!record && (
<div>
<div className="flex items-center justify-between mb-1">
<Label>{batchMode ? '批量选择员工' : '员工'}</Label>
<button
className="text-xs text-primary hover:underline flex items-center gap-1"
onClick={() => { setBatchMode(!batchMode); setSelectedIds([]) }}
>
<Users className="w-3.5 h-3.5" />
{batchMode ? '切换为单选' : '切换为批量'}
</button>
</div>
{batchMode ? (
<div className="border border-gray-200 rounded-md">
<div className="p-2 border-b border-gray-100">
<input
type="text"
placeholder="搜索姓名/部门"
value={batchSearch}
onChange={(e) => setBatchSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="max-h-[180px] overflow-y-auto">
{filteredEmployees.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400"></div>
) : filteredEmployees.map((emp: any) => (
<label
key={emp.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-pointer text-sm"
>
<input
type="checkbox"
checked={selectedIds.includes(emp.id)}
onChange={() => toggleEmployee(emp.id)}
className="rounded"
/>
<span>{emp.name}</span>
<span className="text-gray-400 text-xs">{emp.department || ''}</span>
</label>
))}
</div>
{selectedIds.length > 0 && (
<div className="px-3 py-1.5 border-t border-gray-100 text-xs text-primary">
{selectedIds.length}
</div>
)}
</div>
) : (
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })}>
<option value=""></option>
{employees.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department || ''}</option>
))}
</Select>
)}
</div>
)}
<div>
<Label></Label>
<Input type="date" value={form.trainingDate} onChange={(e) => setForm({ ...form, trainingDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={form.topic} onChange={(e) => setForm({ ...form, topic: e.target.value })} placeholder="培训主题" />
</div>
<div>
<Label></Label>
<Input value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} placeholder="培训内容" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={form.trainer} onChange={(e) => setForm({ ...form, trainer: e.target.value })} placeholder="讲师姓名" />
</div>
<div>
<Label>()</Label>
<Input type="number" min={0} step={0.5} value={form.duration} onChange={(e) => setForm({ ...form, duration: Number(e.target.value) })} />
</div>
</div>
{record && (
<div>
<Label></Label>
<div className="text-sm text-gray-600">
<span className={`inline-block px-2 py-0.5 rounded text-xs ${ACK_COLORS[record.ackStatus] || 'bg-gray-50 text-gray-600'}`}>
{ACK_LABELS[record.ackStatus] || record.ackStatus}
</span>
<span className="ml-2 text-xs text-gray-400"></span>
</div>
</div>
)}
<div>
<Label></Label>
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="备注" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}></Button>
</div>
</div>
</div>
</div>
)
}
+77 -19
View File
@@ -1,6 +1,6 @@
import { useState } from "react"
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { rosterApi, socialInsuranceApi } from '../../lib/api-services'
import { rosterApi, socialInsuranceApi, employeeApi } from '../../lib/api-services'
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
@@ -395,7 +395,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -403,7 +403,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -510,17 +510,35 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
name: '', department: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京', education: '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
const [form, setForm] = useState(() => {
try {
const saved = localStorage.getItem('add-employee-draft')
if (saved) return JSON.parse(saved)
} catch {}
return {
name: '', department: '', position: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京', education: '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
}
})
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
if (isDirty) {
localStorage.setItem('add-employee-draft', JSON.stringify(form))
} else {
localStorage.removeItem('add-employee-draft')
}
} catch {}
}, [form])
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
@@ -536,7 +554,8 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
}
}
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)+ 查重
const [idCardDuplicate, setIdCardDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
const handleIdCardChange = (idCard: string) => {
let gender = form.gender
if (idCard.length >= 17) {
@@ -544,6 +563,12 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
}
setForm({ ...form, idCardNumber: idCard, gender })
setIdCardDuplicate(null)
if (idCard.length === 18) {
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
setIdCardDuplicate(data)
}).catch(() => {})
}
}
// 计算合同月数
@@ -610,6 +635,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
const handleSubmit = () => {
const data: any = {
name: form.name, department: form.department,
position: form.position || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary, gender: form.gender,
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
@@ -642,18 +668,29 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
useUnsavedChanges(isDirty)
return (
<Modal open onClose={onClose} title="添加员工" size="xl">
<Modal open onClose={onClose} title="添加员工" size="xl" closeOnOverlayClick={false}>
<div className="space-y-4">
{error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
{error.response?.data?.error?.message || '操作失败'}
{error.response?.data?.error?.details?.length > 0
? error.response.data.error.details.map((d: any, i: number) => (
<div key={i}> {d.path}: {d.message}</div>
))
: (error.response?.data?.error?.message || '操作失败')}
</div>
)}
{/* 基本信息 */}
<div className="grid grid-cols-4 gap-4">
<div><Label> *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
<div><Label> *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
<div><Label>/</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
<div><Label> *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
{idCardDuplicate?.exists && (
<div className="col-span-4 px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{idCardDuplicate.employee?.name}{idCardDuplicate.employee?.department}</span>
</div>
)}
<div><Label></Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
{form.gender === '女' && (
<div><Label></Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value=""></option><option value="CADRE">/</option><option value="WORKER">/</option></Select></div>
@@ -665,7 +702,28 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
</div>
<div className="grid grid-cols-4 gap-4">
<div><Label></Label><Select value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
<div><Label></Label><Select value={form.city} onChange={async (e) => {
const city = e.target.value
setForm({ ...form, city })
const salary = Number(form.socialInsBase === '' ? form.monthlySalary : form.socialInsBase) || 0
const hfBase = Number(form.housingFundBase === '' ? form.monthlySalary : form.housingFundBase) || 0
if (salary > 0) {
try {
const res = await socialInsuranceApi.calculate(salary, city)
if (res?.capped || res?.floored) {
setForm((prev: any) => ({ ...prev, socialInsBase: String(res.actualBase) }))
}
} catch {}
}
if (hfBase > 0) {
try {
const res = await socialInsuranceApi.housingCalculate(hfBase, city)
if (res?.capped || res?.floored) {
setForm((prev: any) => ({ ...prev, housingFundBase: String(res.actualBase) }))
}
} catch {}
}
}}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
</div>
{/* 社保公积金 */}
@@ -678,7 +736,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || form.monthlySalary} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -686,7 +744,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || form.monthlySalary} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -2,12 +2,13 @@ import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
import { Plus, Settings as SettingsIcon, X, Shield } from 'lucide-react'
import { commercialInsuranceApi } from '../../lib/api-services'
import { Plus, Settings as SettingsIcon, X, Shield, UserPlus } from 'lucide-react'
import { commercialInsuranceApi, employeeApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
import { InlineAlert } from '../../components/ui/InlineAlert'
import Modal from '../../components/ui/Modal'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -36,6 +37,9 @@ export default function CommercialInsuranceTab() {
const [editingPlan, setEditingPlan] = useState<any>(null)
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
const [newPlan, setNewPlan] = useState<any>({ ...DEFAULT_PLAN })
const [showEnrollModal, setShowEnrollModal] = useState(false)
const [enrollEmployeeIds, setEnrollEmployeeIds] = useState<string[]>([])
const [enrollEffectiveFrom, setEnrollEffectiveFrom] = useState(new Date().toISOString().slice(0, 10))
const { data: plans = [], isLoading } = useQuery<any[]>({
queryKey: ['commercial-insurance-plans'],
@@ -79,6 +83,27 @@ export default function CommercialInsuranceTab() {
},
})
const { data: rosterData } = useQuery<any[]>({
queryKey: ['employees-for-commercial-insurance'],
queryFn: async () => {
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showEnrollModal,
})
const enrollMutation = useMutation({
mutationFn: async (data: { employeeIds: string[]; effectiveFrom?: string }) =>
commercialInsuranceApi.enroll(selectedPlanId!, data) as any,
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-enrollments'] })
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-employee-summary'] })
setShowEnrollModal(false)
setEnrollEmployeeIds([])
toast.success(`已添加 ${res.data?.enrolled || 0} 名员工`)
},
onError: () => toast.error('参保失败'),
})
const handleEdit = (plan: any) => {
setEditingPlan(plan)
setNewPlan({ ...plan })
@@ -157,7 +182,12 @@ export default function CommercialInsuranceTab() {
{selectedPlanId && (
<Card>
<h3 className="text-sm font-medium mb-3">{enrollments.length}</h3>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">{enrollments.length}</h3>
<Button size="sm" onClick={() => { setEnrollEmployeeIds([]); setEnrollEffectiveFrom(new Date().toISOString().slice(0, 10)); setShowEnrollModal(true) }}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
</div>
{enrollLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">...</div>
) : enrollments.length === 0 ? (
@@ -197,6 +227,70 @@ export default function CommercialInsuranceTab() {
</Card>
)}
{/* 批量参保 Modal */}
{showEnrollModal && (
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="批量参保" size="lg">
<div className="space-y-3">
<InlineAlert type="info">
</InlineAlert>
<div className="flex items-center gap-2">
<Label className="shrink-0"></Label>
<Input type="date" value={enrollEffectiveFrom} onChange={(e) => setEnrollEffectiveFrom(e.target.value)} className="!w-40" />
</div>
<div className="max-h-80 overflow-y-auto border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left w-8">
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.length || 0) && enrollEmployeeIds.length > 0}
onChange={(e) => {
if (e.target.checked) {
setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || [])
} else {
setEnrollEmployeeIds([])
}
}} />
</th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
</tr>
</thead>
<tbody>
{rosterData?.map((emp: any) => (
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<input type="checkbox" checked={enrollEmployeeIds.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) setEnrollEmployeeIds([...enrollEmployeeIds, emp.id])
else setEnrollEmployeeIds(enrollEmployeeIds.filter((id) => id !== emp.id))
}} />
</td>
<td className="py-2 px-3 font-medium">{emp.name}</td>
<td className="py-2 px-3 text-gray-500">{emp.department}</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"> {enrollEmployeeIds.length} </span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowEnrollModal(false)}></Button>
<Button size="sm" onClick={() => enrollMutation.mutate({ employeeIds: enrollEmployeeIds, effectiveFrom: enrollEffectiveFrom })}
disabled={enrollEmployeeIds.length === 0 || enrollMutation.isPending}>
{enrollMutation.isPending ? '参保中...' : '确认参保'}
</Button>
</div>
</div>
</div>
</Modal>
)}
{showAddPlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
<Card className="w-full max-w-lg mx-4" >
@@ -0,0 +1,103 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Search, CheckCircle, XCircle } from 'lucide-react'
import { socialInsuranceApi } from '../../lib/api-services'
import { Input } from '../../components/ui/Input'
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function EmployeeEnrollmentTab() {
const [keyword, setKeyword] = useState('')
const debouncedSearch = useDebouncedValue(keyword, 300)
const { data: list = [], isLoading } = useQuery<any[]>({
queryKey: ['social-employee-enrollment', debouncedSearch],
queryFn: () => socialInsuranceApi.employeeEnrollment(debouncedSearch || undefined),
})
const insuredCount = list.filter(e => e.socialInsStatus === 'INSURED').length
const housingCount = list.filter(e => e.housingFundStatus === 'INSURED').length
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500"> {list.length} </span>
<span className="text-gray-500"> <span className="font-medium text-primary">{insuredCount}</span></span>
<span className="text-gray-500"> <span className="font-medium text-primary">{housingCount}</span></span>
</div>
<div className="relative w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索员工姓名"
className="pl-9"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={11} className="py-8 text-center text-gray-400">...</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={11} className="py-8 text-center text-gray-400"></td></tr>
) : list.map((emp: any) => (
<tr key={emp.id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4 font-medium">{emp.name}</td>
<td className="py-2 pr-4 text-gray-600">{emp.department || '-'}</td>
<td className="py-2 pr-4 text-gray-600">{emp.position || '-'}</td>
<td className="py-2 pr-4">
{emp.socialInsStatus === 'INSURED' ? (
<span className="inline-flex items-center gap-1 text-xs text-green-600">
<CheckCircle className="w-3.5 h-3.5" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
<XCircle className="w-3.5 h-3.5" />
</span>
)}
</td>
<td className="py-2 pr-4">{emp.socialInsBase ? `¥${fmt(emp.socialInsBase)}` : '-'}</td>
<td className="py-2 pr-4 text-gray-600">{emp.socialInsCity || '-'}</td>
<td className="py-2 pr-4 text-gray-500 text-xs">{emp.socialInsStart || '-'}</td>
<td className="py-2 pr-4">
{emp.housingFundStatus === 'INSURED' ? (
<span className="inline-flex items-center gap-1 text-xs text-green-600">
<CheckCircle className="w-3.5 h-3.5" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
<XCircle className="w-3.5 h-3.5" />
</span>
)}
</td>
<td className="py-2 pr-4">{emp.housingFundBase ? `¥${fmt(emp.housingFundBase)}` : '-'}</td>
<td className="py-2 pr-4 text-gray-600">{emp.housingFundCity || '-'}</td>
<td className="py-2 pr-4 text-gray-500 text-xs">{emp.housingFundStart || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -1,14 +1,29 @@
/**
*
*
* [1994]479
* [2015]40
*
*
*/
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Calculator, HeartPulse, Info } from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { settingsApi } from '../../lib/api-services'
interface PolicyRule {
maxYears: number
months: number
cycleMonths: number
}
interface MedicalPeriodPolicy {
id: string
region: string
legalBasis: string
rules: PolicyRule[]
isDefault: boolean
}
interface MedicalPeriodResult {
totalMonths: number
@@ -19,76 +34,24 @@ interface MedicalPeriodResult {
notes: string[]
}
/**
*
* @param workYears
* @param region /
* @param sickDays
* @param startDate
*/
function calculateMedicalPeriod(
workYears: number,
region: 'shanghai' | 'national',
policy: MedicalPeriodPolicy,
sickDays: number,
startDate: string,
): MedicalPeriodResult | null {
if (!startDate || workYears < 0) return null
if (!startDate || workYears < 0 || !policy.rules.length) return null
let totalMonths: number
let cumulativeDays: number
let legalBasis: string
const rule = policy.rules.find(r => workYears < r.maxYears) || policy.rules[policy.rules.length - 1]
const totalMonths = rule.months
const cumulativeDays = rule.cycleMonths * 30
const legalBasis = policy.legalBasis
const notes: string[] = []
if (region === 'shanghai') {
// 上海特殊规定:直接按工龄分档
if (workYears < 1) {
totalMonths = 3
cumulativeDays = 6 * 30 // 6个月周期
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else if (workYears < 4) {
totalMonths = 3
cumulativeDays = 6 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else if (workYears < 10) {
totalMonths = 6
cumulativeDays = 12 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else {
totalMonths = 9
cumulativeDays = 18 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
}
notes.push('上海地区适用特殊规定,医疗期不按累计病休天数折算')
} else {
// 全国通用规定:劳部发[1994]479号
if (workYears < 5) {
totalMonths = 3
cumulativeDays = 6 * 30 // 6个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 10) {
totalMonths = 6
cumulativeDays = 12 * 30 // 12个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 15) {
totalMonths = 9
cumulativeDays = 15 * 30 // 15个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 20) {
totalMonths = 12
cumulativeDays = 18 * 30 // 18个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else {
totalMonths = 24
cumulativeDays = 30 * 30 // 30个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
}
notes.push(`${cumulativeDays / 30} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`)
}
notes.push(`${rule.cycleMonths} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`)
// 计算实际可用天数
const actualDays = Math.max(0, totalMonths * 30 - sickDays)
// 计算医疗期结束日期
const start = new Date(startDate)
const endDate = new Date(start)
endDate.setMonth(endDate.getMonth() + totalMonths)
@@ -110,21 +73,33 @@ function calculateMedicalPeriod(
*
*/
export default function MedicalPeriodCalculator() {
const [region, setRegion] = useState<'national' | 'shanghai'>('national')
const [selectedPolicyId, setSelectedPolicyId] = useState('')
const [workYears, setWorkYears] = useState('')
const [sickDays, setSickDays] = useState('0')
const [startDate, setStartDate] = useState('')
const [result, setResult] = useState<MedicalPeriodResult | null>(null)
const { data: policies = [] } = useQuery<MedicalPeriodPolicy[]>({
queryKey: ['medical-period-policies'],
queryFn: () => settingsApi.medicalPeriodPolicies(),
})
const selectedPolicy = useMemo(() => {
if (!policies.length) return null
if (selectedPolicyId) return policies.find(p => p.id === selectedPolicyId) || null
return policies.find(p => p.isDefault) || policies[0]
}, [policies, selectedPolicyId])
const handleCalculate = () => {
const years = parseFloat(workYears) || 0
const days = parseInt(sickDays) || 0
const r = calculateMedicalPeriod(years, region, days, startDate)
if (!selectedPolicy) return
const r = calculateMedicalPeriod(years, selectedPolicy, days, startDate)
setResult(r)
}
const handleReset = () => {
setRegion('national')
setSelectedPolicyId('')
setWorkYears('')
setSickDays('0')
setStartDate('')
@@ -144,12 +119,13 @@ export default function MedicalPeriodCalculator() {
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={region}
onChange={(e) => setRegion(e.target.value as 'national' | 'shanghai')}
value={selectedPolicy?.id || ''}
onChange={(e) => setSelectedPolicyId(e.target.value)}
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="national"></option>
<option value="shanghai"></option>
{policies.map(p => (
<option key={p.id} value={p.id}>{p.region}{p.isDefault ? '(默认)' : ''}</option>
))}
</select>
</div>
@@ -252,28 +228,36 @@ export default function MedicalPeriodCalculator() {
</Card>
)}
{/* 工龄分档表 */}
<Card>
<h2 className="text-sm font-medium mb-2"></h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
</tr>
</thead>
<tbody className="divide-y">
<tr><td className="py-2 pr-3"> 5 </td><td className="py-2 pr-3">3 </td><td className="py-2 pr-3">6 </td></tr>
<tr><td className="py-2 pr-3">5-10 </td><td className="py-2 pr-3">6 </td><td className="py-2 pr-3">12 </td></tr>
<tr><td className="py-2 pr-3">10-15 </td><td className="py-2 pr-3">9 </td><td className="py-2 pr-3">15 </td></tr>
<tr><td className="py-2 pr-3">15-20 </td><td className="py-2 pr-3">12 </td><td className="py-2 pr-3">18 </td></tr>
<tr><td className="py-2 pr-3">20 </td><td className="py-2 pr-3">24 </td><td className="py-2 pr-3">30 </td></tr>
</tbody>
</table>
</div>
</Card>
{/* 当前政策分档表 */}
{selectedPolicy && (
<Card>
<h2 className="text-sm font-medium mb-2">{selectedPolicy.region}</h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
</tr>
</thead>
<tbody className="divide-y">
{selectedPolicy.rules.map((rule, idx) => {
const prevMax = idx > 0 ? selectedPolicy.rules[idx - 1].maxYears : 0
const isLast = idx === selectedPolicy.rules.length - 1
return (
<tr key={idx}>
<td className="py-2 pr-3">{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears}`}</td>
<td className="py-2 pr-3">{rule.months} </td>
<td className="py-2 pr-3">{rule.cycleMonths} </td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
)}
</div>
)
}