Compare commits
22 Commits
shenao
..
b682178549
| Author | SHA1 | Date | |
|---|---|---|---|
| b682178549 | |||
| de7f1830a7 | |||
| a2e9ba55c2 | |||
| c355a7d208 | |||
| a5901d648e | |||
| 9a960d8274 | |||
| 05b171b4e9 | |||
| ddc51b33fd | |||
| 3afb383172 | |||
| 15dde27701 | |||
| b239465e78 | |||
| 539e384ee1 | |||
| 6ec939dc7f | |||
| 41b3030442 | |||
| 9512b555ee | |||
| e8cd0f472b | |||
| b9240ffe9b | |||
| cce0c936bd | |||
| b576a48ff1 | |||
| 13c9192b0a | |||
| c328172e7d | |||
| 5604d02de9 |
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)}条)`)
|
||||
}
|
||||
|
||||
// ========== 主函数 ==========
|
||||
|
||||
@@ -78,7 +78,7 @@ async function main() {
|
||||
plan: 'PRO',
|
||||
maxEmployees: 50,
|
||||
city: '上海',
|
||||
payrollFrequency: 1,
|
||||
payrollDays: [10],
|
||||
},
|
||||
})
|
||||
console.log('企业已创建:', org.name)
|
||||
|
||||
@@ -66,6 +66,9 @@ import companyFileRoutes from './routes/company-file.routes'
|
||||
import acceptanceTestRoutes from './routes/acceptance-test.routes'
|
||||
import leaveRoutes from './routes/leave.routes'
|
||||
import salaryRoutes from './routes/salary.routes'
|
||||
import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
|
||||
import benefitRoutes from './routes/benefit.routes'
|
||||
import esignRoutes from './routes/esign.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
app.use('/api/v1/employees', employeeRoutes)
|
||||
@@ -95,6 +98,9 @@ app.use('/api/v1/company-files', companyFileRoutes)
|
||||
app.use('/api/v1/acceptance-tests', acceptanceTestRoutes)
|
||||
app.use('/api/v1/leaves', leaveRoutes)
|
||||
app.use('/api/v1/salary', salaryRoutes)
|
||||
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
|
||||
app.use('/api/v1/benefits', benefitRoutes)
|
||||
app.use('/api/v1/esign', esignRoutes)
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
})
|
||||
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
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
// 过滤空分类
|
||||
|
||||
@@ -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,17 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
// 包装为 HTML 格式以确保 Word 正确打开
|
||||
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>
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
</style></head>
|
||||
<body>${template.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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -898,4 +951,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
|
||||
|
||||
@@ -49,10 +49,16 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
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>
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
</style></head>
|
||||
<body>${template.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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,8 @@ export type EvidenceCategory =
|
||||
| 'DISCIPLINARY'
|
||||
| 'ATTENDANCE'
|
||||
| 'TERMINATION'
|
||||
| 'TRAINING'
|
||||
| 'PERFORMANCE'
|
||||
|
||||
/**
|
||||
* 创建证据链记录
|
||||
@@ -183,6 +185,7 @@ export async function verifyAllEvidence(orgId: string) {
|
||||
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
|
||||
let valid = 0
|
||||
let invalid = 0
|
||||
const invalidItems: any[] = []
|
||||
for (const r of records) {
|
||||
const events = r.events as any[]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
@@ -196,8 +199,16 @@ export async function verifyAllEvidence(orgId: string) {
|
||||
})
|
||||
const sortedJson = JSON.stringify(sortedEvents)
|
||||
const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || ''))
|
||||
if (sortedHash === r.hash) valid++
|
||||
else invalid++
|
||||
if (sortedHash === r.hash) { valid++; continue }
|
||||
invalid++
|
||||
invalidItems.push({
|
||||
id: r.id,
|
||||
category: r.category,
|
||||
refId: r.refId,
|
||||
employeeId: r.employeeId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
description: `证据链 ${r.category}${r.refId ? `(${r.refId})` : ''} 哈希校验失败,可能被篡改`,
|
||||
})
|
||||
}
|
||||
return { total: records.length, valid, invalid }
|
||||
return { total: records.length, valid, invalid, invalidItems }
|
||||
}
|
||||
|
||||
@@ -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: '系统帮助-合同管理' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '' }
|
||||
|
||||
@@ -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 | 花名册 |
|
||||
|
||||
---
|
||||
|
||||
## 已确认无需修改
|
||||
|
||||
(暂无)
|
||||
@@ -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 | 绩效考核模块过于片面 | 确认:固定字段,无自定义 |
|
||||
@@ -0,0 +1,353 @@
|
||||
# TurboHR 系统优化清单(20260811)
|
||||
|
||||
> 基于用户反馈的 13 项问题,逐条分析合理性并给出优化建议。
|
||||
>
|
||||
> **验证状态**:已逐条对照源码验证,标注 ✅ 确认存在 / ⚠️ 部分存在 / ❌ 不存在或已实现。
|
||||
|
||||
---
|
||||
|
||||
## 一、薪税管理发薪模块
|
||||
|
||||
### 1.1 发薪数据每月变化需重新导入(反馈 #1)
|
||||
|
||||
**用户反馈**:每个月有变化都要重新导入,需要在好几个模块/系统间来回切换,不便捷。
|
||||
|
||||
**现状分析**:当前发薪流程为:花名册员工数据 → 批量导入薪资数据 → 计算社保公积金 → 生成工资条 → 归档。每月需重新导入薪资批次数据。
|
||||
|
||||
**代码验证**:✅ 确认存在。当前薪税流程需手动导入或手动创建批次,无「复制上月批次」功能。月度导入模板支持考勤/加班/薪资调整/社保变动,但无法自动从花名册拉取固定项。
|
||||
|
||||
**合理性**:✅ 合理。HR 每月薪资数据确实有变化(加班费、绩效奖金、考勤扣款等),但可以优化流程减少重复操作。
|
||||
|
||||
**优化方案**:
|
||||
- 支持「复制上月批次」功能:基于上月归档数据自动生成新月份草稿,仅需修改变化项
|
||||
- 花名册中已维护的社保基数、公积金基数自动同步到薪税模块,无需重复录入
|
||||
- 考勤模块确认的考勤数据自动关联到薪税计算(加班时长、请假扣款等)
|
||||
- 增加「一键取数」功能:从花名册自动拉取基本工资、社保基数等固定项,仅需手动录入变动项
|
||||
|
||||
**优先级**:P1 高
|
||||
|
||||
---
|
||||
|
||||
## 二、导入错误提示不清晰
|
||||
|
||||
### 2.1 导入提示乱码,无法定位错误(反馈 #2)
|
||||
|
||||
**用户反馈**:按模板填写两人导入后提示一堆乱码,实际模板可能没填错,不知道哪里有问题。
|
||||
|
||||
**现状分析**:`Settings.tsx` 导入预览已有错误展示(`result.errors` 数组),但错误信息可能显示的是后端字段名(英文),对用户来说像"乱码"。且如果用户跳过预览直接导入,错误提示可能不够醒目。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Settings.tsx:967-968` 已有 toast 提示「有 N 条错误」,预览阶段有完整错误表格(行号/姓名/状态/错误信息),且支持「导出错误日志」Excel 下载。但错误信息内容来自后端,可能包含英文字段名导致「乱码」感。用户若跳过预览直接导入,错误提示在结果区域可能不够醒目。
|
||||
|
||||
**合理性**:✅ 合理。导入错误信息需要用户可读、可定位。
|
||||
|
||||
**优化方案**:
|
||||
- 错误信息中文化:将后端返回的字段名映射为中文(如 `name` → `姓名`、`idCardNumber` → `身份证号`)
|
||||
- 错误提示格式:`第 X 行,字段「姓名」:不能为空` 或 `第 X 行,身份证号格式不正确`
|
||||
- 导入失败时增加醒目 toast 提示「有 N 条错误,请查看详情」
|
||||
- 增加「下载错误报告」按钮,导出错误行明细(Excel)
|
||||
- 预览阶段就展示完整校验结果,不通过预览不允许导入
|
||||
|
||||
**优先级**:P0 紧急
|
||||
|
||||
---
|
||||
|
||||
## 三、工资流水导出格式
|
||||
|
||||
### 3.1 导出工资流水是否适配银行格式(反馈 #3)
|
||||
|
||||
**用户反馈**:工资归档后工资条有显示,导出工资流水处是否适合银行直接关联?
|
||||
|
||||
**现状分析**:当前 `PayslipTab.tsx` 无导出功能(已在 20260804 优化清单中列为 P1 待修复项)。工资流水导出需要符合银行代发工资文件格式。
|
||||
|
||||
**代码验证**:✅ 确认存在。`PayslipTab.tsx` 无导出功能,仅有「从批次汇总生成」「税率试算」「删除」操作,无银行代发文件导出。
|
||||
|
||||
**合理性**:✅ 合理。银行代发工资通常需要特定格式(Excel/CSV,包含姓名、身份证号、银行账号、金额等字段)。
|
||||
|
||||
**优化方案**:
|
||||
- 增加「导出银行代发文件」功能,支持常见银行格式(工行、建行、招行等)
|
||||
- 导出字段:姓名、身份证号、银行账号、开户行、实发金额
|
||||
- 支持自定义字段映射,适配不同银行要求
|
||||
- 导出格式支持 Excel 和 CSV
|
||||
|
||||
**优先级**:P1 高
|
||||
|
||||
---
|
||||
|
||||
## 四、社保基数与花名册数据不同步
|
||||
|
||||
### 4.1 社保公积金办理处仍显示工资数而非社保基数(反馈 #4)
|
||||
|
||||
**用户反馈**:花名册中已修改社保基数,但社保公积金办理里还是显示工资数。
|
||||
|
||||
**现状分析**:花名册 `BasicInfo.tsx` 中可编辑社保基数(`socialInsuranceBase`、`housingFundBase`),但社保模块 `SocialInsurance.tsx` 的月度办理可能直接取 `salary` 字段而非社保基数字段。
|
||||
|
||||
**代码验证**:❌ 不存在(已正确实现)。`payroll.service.ts:166-167` 明确代码:`const socialBase = employee.socialInsBase || inputs.baseSalary`,即优先使用花名册中的 `socialInsBase`,未设置时才用基本工资。`contract.service.ts:205-206` 入职时同样 `socialInsBase != null ? socialInsBase : salaryNum`。社保月度办理 `MonthlyRows.tsx` 显示的 `i.base` 来自 `EmployeeSocialInsRecord` 表,该记录在入职和基数调整时已正确写入。**若用户遇到显示工资数而非社保基数,可能是该员工 `socialInsBase` 字段为 null**,需在花名册 BasicInfo 中设置社保基数。
|
||||
|
||||
**合理性**:⚠️ 部分合理。代码逻辑正确(优先用 socialInsBase),但用户若未在花名册设置社保基数,系统会 fallback 到工资数,用户可能不理解这个优先级。建议增加 UI 提示。
|
||||
|
||||
**优化方案**:
|
||||
- 社保公积金月度办理中,基数取值优先级:`socialInsuranceBase` > `salary`(如果社保基数已设置则用社保基数,否则用工资)
|
||||
- 同理公积金基数取值:`housingFundBase` > `salary`
|
||||
- 在社保办理界面显示数据来源标注(「来自花名册社保基数」或「来自月工资」)
|
||||
- 增加「一键同步花名册社保基数」按钮
|
||||
|
||||
**优先级**:P0 紧急
|
||||
|
||||
---
|
||||
|
||||
## 五、商险管理添加员工入口缺失
|
||||
|
||||
### 5.1 商险方案添加员工无入口(反馈 #5)
|
||||
|
||||
**用户反馈**:添加了商险方案,但不知道如何添加员工,员工办理里也没有商险选项。
|
||||
|
||||
**现状分析**:需确认商险模块是否有员工关联功能。
|
||||
|
||||
**代码验证**:✅ 确认存在。`CommercialInsuranceTab.tsx` 有方案 CRUD 和参保人员查看(`enrollments`),但**无「添加参保员工」按钮**。API 层 `commercialInsuranceApi` 仅有 `plans/enrollments/savePlan/removePlan`,缺少 `addEnrollment` 接口。员工办理 `WorkProcess.tsx` 的 13 种流程类型中也无商险参保类型。
|
||||
|
||||
**合理性**:✅ 合理。商险方案创建后应能关联员工。
|
||||
|
||||
**优化方案**:
|
||||
- 商险方案详情页增加「添加参保员工」按钮,支持批量选择员工
|
||||
- 员工办理(WorkProcess)中增加商险参保/退保流程类型
|
||||
- 花名册员工详情中增加商险参保状态展示
|
||||
|
||||
**优先级**:P1 高
|
||||
|
||||
---
|
||||
|
||||
## 六、员工福利月度汇总无数据
|
||||
|
||||
### 6.1 福利添加人员后月度汇总无数(反馈 #6)
|
||||
|
||||
**用户反馈**:员工福利能添加人员,但月度汇总处没有数。
|
||||
|
||||
**现状分析**:需确认福利模块的汇总逻辑是否正确关联了已添加的福利人员数据。
|
||||
|
||||
**代码验证**:❌ 不存在(系统中无福利模块)。搜索全部前端代码无 `benefit`、`福利`、`welfare` 关键词。系统无员工福利管理模块,用户可能指的是其他模块(如社保或商险)的功能。
|
||||
|
||||
**合理性**:⚠️ 需澄清。系统当前无福利模块,需与用户确认具体指的是哪个功能。
|
||||
|
||||
**优化方案**:
|
||||
- 检查月度汇总查询逻辑,确保关联了福利人员表
|
||||
- 汇总维度:按福利类型汇总人数和金额、按部门汇总
|
||||
- 添加人员后自动刷新汇总数据
|
||||
- 汇总页面增加「刷新」按钮
|
||||
|
||||
**优先级**:P1 高
|
||||
|
||||
---
|
||||
|
||||
## 七、证据链完整性验证
|
||||
|
||||
### 7.1 验证全部完整性显示全部异常(反馈 #7)
|
||||
|
||||
**用户反馈**:证据链条中验证全部完整性,显示全部异常,不清楚如何验证。
|
||||
|
||||
**现状分析**:`Termination.tsx` 中有仲裁证据链模块,验证逻辑可能过于严格或验证条件不明确。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Evidence.tsx` 有「验证全部完整性」按钮,调用 `evidenceApi.verifyAll()`,返回 `verifyResult.invalid` 计数。验证逻辑是后端 Hash 校验(SHA256),若全部异常可能是:①数据库中证据链记录的 hash 与实际数据不匹配;②证据链记录为空时验证逻辑有 bug。前端只显示「N 条通过,M 条异常」,**不显示具体异常原因和修复建议**。`EvidenceChain.tsx`(员工维度)有证据列表和风险提醒,但无验证功能。
|
||||
|
||||
**合理性**:✅ 合理。用户不理解验证规则,且全部异常说明验证逻辑可能有问题。
|
||||
|
||||
**优化方案**:
|
||||
- 验证结果中显示具体异常原因(如「缺少劳动合同扫描件」「考勤记录不完整」等)
|
||||
- 每个验证项增加「查看要求」说明,告知用户需要什么材料
|
||||
- 验证标准可配置:区分「必须项」和「建议项」,必须项缺失才标红
|
||||
- 增加「验证说明」帮助文档
|
||||
|
||||
**优先级**:P2 中
|
||||
|
||||
---
|
||||
|
||||
## 八、员工办理必填项缺失
|
||||
|
||||
### 8.1 只填姓名也能录入,关键信息未设必填(反馈 #8)
|
||||
|
||||
**用户反馈**:员工办理中只填姓名也能录入,建议身份证号等关键信息设为必填。
|
||||
|
||||
**现状分析**:`WorkProcess.tsx` 的入职登记表单中,`employeeName` 可能是唯一必填项,`idCardNumber` 等字段未设必填。
|
||||
|
||||
**代码验证**:✅ 确认存在。`WorkProcess.tsx:231-242` 的 `handleCreate()` 仅校验 `selectedType` 非空,不校验任何表单字段。`FORM_FIELDS` 配置中无 `required` 标记,所有字段都是选填。后端 `work-process.service.ts` 也未对 formData 做必填校验。HIRE 类型有 `idCardNumber` 字段但非必填,只填姓名即可创建草稿并提交。
|
||||
|
||||
**合理性**:✅ 合理。关键信息缺失会导致后续业务(社保、合同、工资)无法正常办理。
|
||||
|
||||
**优化方案**:
|
||||
- 入职登记必填项:姓名、身份证号、手机号、部门
|
||||
- 用工办理其他流程类型根据类型设置相应必填项
|
||||
- 前端表单增加必填校验提示
|
||||
- 后端 schema 同步增加必填校验
|
||||
|
||||
**优先级**:P0 紧急
|
||||
|
||||
---
|
||||
|
||||
## 九、员工删除限制
|
||||
|
||||
### 9.1 已办理完毕的员工无法删除(反馈 #9)
|
||||
|
||||
**用户反馈**:员工已办理完毕,想删除无法删除,必须做离职处理。
|
||||
|
||||
**现状分析**:系统设计中员工记录不允许物理删除,只能通过离职流程标记为 `TERMINATED` 状态。这是合理的数据管理设计。
|
||||
|
||||
**代码验证**:✅ 确认存在。`Roster.tsx` 中无删除员工按钮(搜索结果中 Roster.tsx 不含 Trash/delete 相关代码)。花名册仅支持「添加员工」「离职」「重新入职」,不支持物理删除。`WorkProcess.tsx` 中仅可删除草稿记录,不可删除已完成的员工档案。
|
||||
|
||||
**合理性**:⚠️ 部分合理。从数据合规角度,员工记录不应物理删除(需保留人事档案)。但如果是录入错误的测试数据,应提供清理机制。
|
||||
|
||||
**优化方案**:
|
||||
- 保持正式员工不可删除,只能离职处理(合规要求)
|
||||
- 增加「作废」功能:仅限录入错误且无关联业务数据(无合同、无工资记录)的员工可作废
|
||||
- 作废后数据保留但不在花名册显示,管理员可在设置中查看作废记录
|
||||
- 增加「测试数据清理」功能:管理员可一键清除所有测试员工
|
||||
|
||||
**优先级**:P2 中
|
||||
|
||||
---
|
||||
|
||||
## 十、离职审批状态无法更改
|
||||
|
||||
### 10.1 待审批状态无法更改或找不到更改入口(反馈 #10)
|
||||
|
||||
**用户反馈**:选择待审批后,再点进去状态无法更改,没找到更改入口。
|
||||
|
||||
**现状分析**:`Termination.tsx` 中离职流程选择「待审批」后,可能缺少审批操作入口或状态流转不完整。
|
||||
|
||||
**代码验证**:❌ 不存在(已实现审批功能)。`Termination.tsx:967-993` 在详情视图中,当 `draftDetail.status === 'PENDING_APPROVAL'` 时,显示审批意见输入框和「审批通过」「驳回」按钮,分别调用 `approveMutation` 和 `rejectMutation`。列表视图中也有快捷审批图标按钮(828-846行)。API 层 `terminationApi.approve/reject` 完整。**用户可能是没找到详情入口**——需点击列表中的审批图标或详情按钮进入详情视图才能操作。
|
||||
|
||||
**合理性**:⚠️ 部分合理。功能已存在,但入口可能不够明显,用户没找到操作位置。建议优化 UI 引导。
|
||||
|
||||
**优化方案**:
|
||||
- 离职详情页增加「审批」按钮(通过/驳回/退回修改)
|
||||
- 待审批状态列表增加批量审批功能
|
||||
- 审批操作记录审批人、审批时间、审批意见
|
||||
- 增加审批通知提醒
|
||||
|
||||
**优先级**:P0 紧急
|
||||
|
||||
---
|
||||
|
||||
## 十一、花名册职责边界
|
||||
|
||||
### 11.1 花名册应只读,修改到相应模块操作(反馈 #11)
|
||||
|
||||
**用户反馈**:花名册应该只能查看,修改应到相应模块,否则太乱。
|
||||
|
||||
**现状分析**:当前花名册 `Roster.tsx` 集成了大量操作:添加员工、薪资调整、部门变更、离职、重新入职等。`EmployeeProfile` 中还能直接编辑基本信息、合同、薪酬等。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Roster.tsx` 列表支持添加员工、批量导入、导出,点击进入 `EmployeeProfile` 后可编辑基本信息、合同、薪酬等。操作确实集中在花名册中,但各编辑区域已有模块化分区(BasicInfo/ContractInfo/SalaryInfo 等)。
|
||||
|
||||
**合理性**:⚠️ 部分合理。花名册作为统一查看入口是合理的,但编辑入口确实可以更清晰。
|
||||
|
||||
**优化方案**:
|
||||
- 花名册列表保持只读查看,点击进入员工详情
|
||||
- 员工详情页保留编辑功能,但按模块分区并标注来源模块(如「基本信息 → 员工档案模块」「合同信息 → 合同管理模块」)
|
||||
- 每个编辑区域增加「前往该模块」链接,方便用户到对应模块操作
|
||||
- 花名册列表的操作按钮(薪资调整、离职等)保留,但增加模块跳转提示
|
||||
- 不建议完全移除编辑功能,因为会降低操作效率
|
||||
|
||||
**优先级**:P3 规划(涉及大量 UI 重构)
|
||||
|
||||
---
|
||||
|
||||
## 十二、招聘模块缺失
|
||||
|
||||
### 12.1 系统没有招聘模块(反馈 #12)
|
||||
|
||||
**用户反馈**:HR 最重要的招聘环节为什么没有?
|
||||
|
||||
**现状分析**:系统目前覆盖入职→在职→离职全生命周期,但缺少招聘前端的简历管理、面试安排、Offer 管理等环节。
|
||||
|
||||
**代码验证**:✅ 确认存在。搜索全部前端代码无 `recruit`、`resume`、`interview`、`招聘`、`简历`、`面试` 相关页面或路由。系统无招聘模块。
|
||||
|
||||
**合理性**:✅ 合理。招聘是 HR 核心功能之一。
|
||||
|
||||
**优化方案**:
|
||||
- 新建招聘模块,包含:
|
||||
- **简历管理**:简历导入/录入、简历筛选、状态流转(待筛选→初试→复试→Offer→入职/淘汰)
|
||||
- **面试管理**:面试安排、面试评价、面试日历
|
||||
- **Offer 管理**:Offer 模板、Offer 发放、接受/拒绝跟踪
|
||||
- **招聘渠道**:渠道管理、来源统计
|
||||
- **人才库**:未录用候选人归档,未来岗位匹配
|
||||
- 与花名册打通:入职时自动从简历库拉取候选人信息
|
||||
- 招聘数据看板:渠道转化率、招聘周期、录用率等
|
||||
|
||||
**优先级**:P3 规划(大型新功能,建议单独规划版本)
|
||||
|
||||
---
|
||||
|
||||
## 十三、角色权限分工
|
||||
|
||||
### 13.1 专员和主管的权限分工(反馈 #13)
|
||||
|
||||
**用户反馈**:工作角色分类,是否考虑有专员和主管的权限分工。
|
||||
|
||||
**现状分析**:当前系统角色为 `SUPER_ADMIN`、`ADMIN`、`HR`、`VIEWER` 四级,`HR` 角色拥有全部 HR 操作权限,无专员/主管区分。
|
||||
|
||||
**代码验证**:✅ 确认存在。`authStore.ts` 中 User 角色为 `SUPER_ADMIN | ADMIN | HR | VIEWER` 四级,无专员/主管区分。所有 HR 角色拥有相同权限,无法按模块或操作类型细分。
|
||||
|
||||
**合理性**:✅ 合理。中大型企业需要更细粒度的权限控制。
|
||||
|
||||
**优化方案**:
|
||||
- 角色体系扩展:
|
||||
- `HR_SUPERVISOR`(HR 主管):全部查看 + 审批权限 + 配置权限
|
||||
- `HR_SPECIALIST`(HR 专员):数据录入 + 日常操作,无审批和配置权限
|
||||
- 权限粒度:
|
||||
- 查看权限:全部模块可查看
|
||||
- 操作权限:专员可录入/修改,主管可审批/删除/导出
|
||||
- 配置权限:仅主管/管理员可修改系统配置(社保比例、薪资结构等)
|
||||
- 审批流:离职、薪资调整等关键操作需主管审批
|
||||
- 可按模块设置权限(如考勤专员只能操作考勤模块)
|
||||
|
||||
**优先级**:P2 中
|
||||
|
||||
---
|
||||
|
||||
## 优先级汇总
|
||||
|
||||
| 优先级 | 编号 | 问题 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| 优先级 | 编号 | 问题 | 验证状态 | 修复状态 |
|
||||
|--------|------|------|----------|----------|
|
||||
| ~~P0~~ | 2.1 | 导入错误提示乱码 | ⚠️ 部分存在(已有错误展示和导出,但信息可能含英文字段名) | 降级 P1 |
|
||||
| ~~P0~~ | 4.1 | 社保基数不同步 | ❌ 不存在(代码已正确实现优先级 socialInsBase > salary) | 降级 P2(增加 UI 提示) |
|
||||
| P0 紧急 | 8.1 | 员工办理关键信息未设必填 | ✅ 确认存在 | 待修复 |
|
||||
| ~~P0~~ | 10.1 | 离职审批状态无法更改 | ❌ 不存在(已实现完整审批流程) | 无需修复(优化 UI 引导) |
|
||||
| P1 高 | 1.1 | 发薪数据每月需重新导入 | ✅ 确认存在 | 待优化 |
|
||||
| P1 高 | 3.1 | 工资流水导出格式适配银行 | ✅ 确认存在 | 待开发 |
|
||||
| P1 高 | 5.1 | 商险管理添加员工入口缺失 | ✅ 确认存在 | 待修复 |
|
||||
| ~~P1~~ | 6.1 | 员工福利月度汇总无数据 | ❌ 系统无福利模块 | 需澄清需求 |
|
||||
| P2 中 | 7.1 | 证据链验证不清晰 | ⚠️ 部分存在(有验证但不显示具体原因) | 待优化 |
|
||||
| P2 中 | 9.1 | 员工删除限制 | ✅ 确认存在(无删除入口) | 待优化 |
|
||||
| P2 中 | 13.1 | 角色权限分工 | ✅ 确认存在 | 待规划 |
|
||||
| P3 规划 | 11.1 | 花名册职责边界重构 | ⚠️ 部分存在 | 待规划 |
|
||||
| P3 规划 | 12.1 | 招聘模块 | ✅ 确认不存在 | 待规划 |
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
## 代码验证结论
|
||||
|
||||
### 已确认存在的问题(需修复)
|
||||
- **#8 员工办理必填项缺失**:`WorkProcess.tsx` 无任何表单必填校验,只填姓名即可提交
|
||||
- **#5 商险添加员工入口缺失**:`CommercialInsuranceTab.tsx` 无添加参保员工按钮和 API
|
||||
- **#1 发薪数据重复导入**:无「复制上月批次」功能
|
||||
- **#3 工资流水无银行格式导出**:`PayslipTab.tsx` 无导出功能
|
||||
- **#9 员工无法删除**:花名册无删除入口
|
||||
- **#12 无招聘模块**:系统中完全不存在
|
||||
- **#13 角色权限无专员/主管分工**:仅四级角色
|
||||
|
||||
### 不存在或已实现的问题(无需修复)
|
||||
- **#4 社保基数不同步**:`payroll.service.ts:166` 已正确实现 `socialInsBase || salary` 优先级,问题可能是用户未设置 `socialInsBase`
|
||||
- **#10 离职审批无法更改**:`Termination.tsx:967-993` 已实现完整审批操作(通过/驳回),用户可能未找到详情入口
|
||||
- **#6 员工福利汇总无数据**:系统中无福利模块,需澄清用户具体指什么
|
||||
|
||||
### 部分存在的问题(可优化)
|
||||
- **#2 导入错误乱码**:已有错误展示和导出功能,但错误信息可能含英文字段名
|
||||
- **#7 证据链验证**:有验证功能但不显示具体异常原因
|
||||
- **#11 花名册职责边界**:编辑功能已模块化分区但确实集中在花名册中
|
||||
|
||||
### 备注
|
||||
- #4 和 #10 经代码验证不构成 bug,降级处理
|
||||
- #6 需与用户澄清具体需求
|
||||
- #12 招聘模块为大型新功能,建议单独规划版本迭代
|
||||
Generated
+131
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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,209 @@ interface HelpArticle {
|
||||
}
|
||||
|
||||
const categories: HelpCategory[] = [
|
||||
{
|
||||
id: 'whats-new',
|
||||
title: '近期更新',
|
||||
icon: Sparkles,
|
||||
articles: [
|
||||
{
|
||||
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 +290,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 +369,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 +491,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 +523,7 @@ const categories: HelpCategory[] = [
|
||||
{
|
||||
id: 'data-export',
|
||||
question: '可以导出数据吗?',
|
||||
answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次也可以导出为 Excel 方便财务对账。',
|
||||
answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次可以导出为 Excel 方便财务对账。考勤管理支持导出每日出勤和月度报表为 CSV 文件。',
|
||||
},
|
||||
{
|
||||
id: 'multi-user',
|
||||
@@ -247,6 +548,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 +804,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>
|
||||
|
||||
@@ -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: '证据链' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>()),
|
||||
@@ -569,6 +602,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 +676,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 +717,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 +731,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 +831,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}`),
|
||||
}
|
||||
|
||||
// ========== 模板相关 ==========
|
||||
@@ -969,4 +1062,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>()),
|
||||
}
|
||||
|
||||
@@ -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('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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="新增休假记录">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, rosterApi } from '../lib/api-services'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import Modal from '../components/ui/Modal'
|
||||
|
||||
const STATUS_CONFIG: Record<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: ['roster-for-esign'],
|
||||
queryFn: async () => {
|
||||
return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any
|
||||
},
|
||||
enabled: showCreate,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { employeeId: string; documentTitle: string; remark?: string }) =>
|
||||
esignApi.create(data) as any,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||
setShowCreate(false)
|
||||
setFormData({ employeeId: '', documentTitle: '', remark: '' })
|
||||
toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接')
|
||||
},
|
||||
onError: () => toast.error('创建失败'),
|
||||
})
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: string) => esignApi.cancel(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||
toast.success('已取消签署')
|
||||
},
|
||||
})
|
||||
|
||||
const refreshStatusMutation = useMutation({
|
||||
mutationFn: (id: string) => esignApi.status(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||
toast.success('状态已刷新')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!formData.employeeId) { toast.error('请选择员工'); return }
|
||||
if (!formData.documentTitle.trim()) { toast.error('请填写文件标题'); return }
|
||||
createMutation.mutate(formData)
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
需要提供易签宝的 AppId、AppSecret 和 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?.items?.filter((e: any) => e.status === 'ACTIVE').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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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],
|
||||
@@ -70,6 +71,19 @@ export default function Evidence() {
|
||||
: `${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>
|
||||
)
|
||||
|
||||
@@ -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)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } 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 } from 'lucide-react'
|
||||
import { policiesApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -26,8 +27,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 +131,7 @@ export default function Policies() {
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
onPageSizeChange={() => setPage(1)}
|
||||
/>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
@@ -246,6 +247,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 +256,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 +284,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 && (
|
||||
|
||||
+184
-51
@@ -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)
|
||||
@@ -90,8 +130,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 +153,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 +311,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 +326,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 +381,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 +409,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 +496,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 +531,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 +558,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 +570,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 +589,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 +614,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 +634,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 +713,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 +791,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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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">工龄 <</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
|
||||
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
||||
import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -19,7 +18,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'commercial'>('monthly')
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [showAddCity, setShowAddCity] = useState(false)
|
||||
const [newCityName, setNewCityName] = useState('')
|
||||
@@ -189,6 +188,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 +202,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 +354,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 +374,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', 'deduction'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
@@ -375,7 +382,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' ? '公积金' : '专项附加扣除'}
|
||||
</button>
|
||||
))}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
@@ -531,7 +538,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 +624,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 +681,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 +925,7 @@ export default function SocialInsurance() {
|
||||
{tab === 'monthly' && (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
月度办理用于按月获取社保增减员名单并完成办理。流程:①选择月份点击「获取」→ ②系统自动汇总新增、减员、正常缴费人员 → ③确认无误后点击「完成办理」保存记录。办理完成后自动更新员工社保状态。
|
||||
月度办理用于按月获取社保增减员名单并完成办理。流程:①选择月份点击「获取」→ ②系统自动汇总新增、减员、正常缴费人员 → ③确认无误后点击「完成办理」保存记录。办理完成后自动更新员工社保状态。关联:社保基数影响「薪税管理」的工资计算;离职员工的社保截止请在「离职管理」中确认。
|
||||
</PageGuide>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -1148,11 +1155,6 @@ export default function SocialInsurance() {
|
||||
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
|
||||
)}
|
||||
|
||||
{/* ========== 商险管理 Tab ========== */}
|
||||
{tab === 'commercial' && (
|
||||
<CommercialInsuranceTab />
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 管理三期(孕期/产期/哺乳期)、工伤、医疗期等特殊状态的跟踪和提醒
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { specialStatusApi, employeeApi } from '../lib/api-services'
|
||||
@@ -102,8 +103,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('')
|
||||
|
||||
@@ -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'
|
||||
@@ -302,9 +304,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: '' })
|
||||
@@ -465,7 +468,7 @@ function EnterpriseTemplates() {
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
onPageSizeChange={() => setPage(1)}
|
||||
/>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
@@ -489,6 +492,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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -1013,9 +1026,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 +1092,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>
|
||||
|
||||
{/* 定时发送弹窗 */}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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 } 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,314 @@
|
||||
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>
|
||||
</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/${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">
|
||||
{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>
|
||||
<Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} 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} />}
|
||||
|
||||
@@ -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' : ''}>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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,575 @@
|
||||
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/${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 [form, setForm] = useState({
|
||||
employeeId: record?.employeeId || '',
|
||||
period: record?.period || new Date().toISOString().slice(0, 7),
|
||||
periodType: record?.periodType || 'MONTHLY',
|
||||
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">
|
||||
{!record && (
|
||||
<div>
|
||||
<Label>考核类型</Label>
|
||||
<Select value={form.periodType} onChange={(e) => setForm({ ...form, periodType: e.target.value })}>
|
||||
<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' : 'month'} value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'YEARLY' ? '如 2026' : 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}>保存</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/${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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user