feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强

- 新增工作日历页面(月历视图、事件管理、自定义事件)
- 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录)
- AI顾问新增人力报告Tab,支持流式生成+Word导出
- 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分
- 花名册/合同/解聘补偿新增部门和状态筛选
- 薪税管理新增工资表导入模板下载、银行代发CSV导出
- 社保公积金支持多公积金账户类型显示
- 数据导出新增花名册/解聘记录导出,中文文件名编码修复
- 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出
- 移除工作台日历卡片(已迁移至独立工作日历页面)
- 新增20260728/20260729更新测试指导文档
This commit is contained in:
freedakgmail
2026-07-29 08:35:29 +08:00
parent d020d04a8a
commit fb36b10402
45 changed files with 3756 additions and 169 deletions
+38 -24
View File
@@ -34,24 +34,30 @@
### 1. 模板下载 401 错误(对应第19项)
**根因**前端 `Settings.tsx` 使用 `window.open()` 下载模板,新窗口不携带 JWT token,后端 `authMiddleware` 返回 401。
**现状**`Settings.tsx` 中的 `GET /import/template``GET /import/monthly-template` 已修复(使用 `fetch` + Authorization header)。但 `Money.tsx:657` 中下载工资表模板仍使用 `window.open('/api/v1/import/payroll-template', '_blank')`,新窗口不携带 JWT token,后端 `authMiddleware` 返回 401。
**修复方案**前端改为 `fetch` + `Blob` 方式下载,携带 Authorization header。
**修复方案**`Money.tsx` 中的 `window.open()` 改为 `fetch` + `Blob` 方式下载,携带 Authorization header。
**涉及文件**
- `frontend/src/pages/Settings.tsx` — 修改下载模板按钮的 onClick 逻辑
- 涉及3个端点:`GET /import/template``GET /import/payroll-template``GET /import/monthly-template`
- `frontend/src/pages/Money.tsx:657` — 修改工资表模板下载按钮的 onClick 逻辑
- 涉及端点:`GET /import/payroll-template`
**实现要点**
```typescript
// 替换 window.open(url) 为:
const res = await api.get('/import/template', { responseType: 'blob' })
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = 'import-template.xlsx'
link.click()
URL.revokeObjectURL(link.href)
// Money.tsx 中替换 window.open() 为:
const handleDownloadPayrollTemplate = async () => {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/payroll-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 = 'payroll-import-template.xlsx'
a.click()
URL.revokeObjectURL(url)
}
```
---
@@ -72,13 +78,15 @@ URL.revokeObjectURL(link.href)
### 3. 分页上限50条过少(对应第17项)
**现状**:前端 `Pagination.tsx` 默认 `pageSizeOptions = [10, 20, 50]`,最大选项仅50条。后端 `roster.routes.ts` 已支持 `Math.min(pageSize, 999)`,但其他路由(`audit.routes.ts``notification.routes.ts` 等)默认20条且无上限校验。
**修复方案**
- 前端 Pagination 组件 pageSize 选项增加 100、200 选项
- 后端各列表接口 pageSize 上限从 50 调整为 200
- 前端 Pagination 组件 `pageSizeOptions` 增加 100、200 选项
- 后端各列表接口统一增加 `Math.min(pageSize, 200)` 上限校验
**涉及文件**
- `frontend/src/components/ui/Pagination.tsx` — pageSizeOptions 增加 100/200
- 后端各路由文件`pageSize` 限制调整
- `frontend/src/components/ui/Pagination.tsx:19``pageSizeOptions` 默认值改为 `[10, 20, 50, 100, 200]`
- 后端各路由文件 — 统一增加 pageSize 上限校验(`roster.routes.ts` 已支持,其余需补充)
---
@@ -230,16 +238,22 @@ URL.revokeObjectURL(link.href)
## P2:增强优化
### 10. 工作日历功能(对应第12项)
### 10. 自定义工作日历功能(对应第12项)
**现状**Dashboard 已有只读的"本月关键日期"列表(展示合同到期、试用期到期等系统自动生成的事件),但不支持自定义工作日历,HR 无法手动添加、编辑或管理日历事件。
**实施方案**
- Dashboard 页面增加日历组件
- 日历事件来源:合同到期、试用期到期、社保公积金调基月、员工生日、考勤确认截止日
- 点击日期查看当日事项列表
- 新增 `CalendarEvent` 模型:标题、日期、类型(自定义/系统)、提醒、关联人
- HR 可手动创建/编辑/删除日历事件(如会议、团建、培训、面试等)
- 日历视图从列表升级为月历网格视图,支持点击日期添加事件
- 系统自动事件与自定义事件合并展示,用颜色区分
- 支持按类型筛选
**涉及文件**
- `frontend/src/pages/Dashboard.tsx` — 增加日历组件
- `backend/src/routes/dashboard.routes.ts` — 新`GET /dashboard/calendar-events?month=YYYY-MM`
- `backend/prisma/schema.prisma` — 新增 CalendarEvent 模型
- `backend/src/routes/calendar.routes.ts` — 新建 CRUD 端点
- `frontend/src/pages/Dashboard.tsx` — 日历列表升级为月历网格 + 自定义事件管理
- `frontend/src/components/Calendar.tsx` — 新建日历组件
---
@@ -335,7 +349,7 @@ URL.revokeObjectURL(link.href)
| 优先级 | 编号 | 项目 | 预估工作量 |
|--------|------|------|-----------|
| P0 | 1 | 模板下载 401 修复 | 0.5天 |
| P0 | 1 | 工资表模板下载 401 修复(仅剩 Money.tsx | 0.5天 |
| P0 | 2 | 花名册身份证号显示+筛选 | 1天 |
| P0 | 3 | 分页上限扩展 | 0.5天 |
| P0 | 7 | 批量导入参保城市 | 0.5天 |
@@ -344,7 +358,7 @@ URL.revokeObjectURL(link.href)
| P1 | 5 | 人事审批流程 | 3-5天 |
| P1 | 6 | 薪资调薪审核+报表 | 3-5天 |
| P1 | 9 | 多公积金比例账户 | 2天 |
| P2 | 10 | 工作日历 | 1-2天 |
| P2 | 10 | 自定义工作日历 | 2-3天 |
| P2 | 11 | 人力信息总览增强 | 2天 |
| P2 | 12 | 人力成本分析维度 | 2-3天 |
| P2 | 13 | 年度价值报告简化 | 1天 |
@@ -0,0 +1,11 @@
-- P1-9: 多公积金比例账户
-- 给 HousingFundConfig 增加 accountType 字段,支持基本公积金/补充公积金
-- 1. 添加列,默认值 BASIC
ALTER TABLE "HousingFundConfig" ADD COLUMN "accountType" TEXT NOT NULL DEFAULT 'BASIC';
-- 2. 删除旧唯一约束
ALTER TABLE "HousingFundConfig" DROP CONSTRAINT "HousingFundConfig_orgId_city_effectiveFrom_key";
-- 3. 添加新唯一约束(包含 accountType
ALTER TABLE "HousingFundConfig" ADD CONSTRAINT "HousingFundConfig_orgId_city_accountType_effectiveFrom_key" UNIQUE ("orgId", "city", "accountType", "effectiveFrom");
@@ -0,0 +1,27 @@
-- P2-10: 自定义日历事件
CREATE TABLE "CalendarEvent" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"type" TEXT NOT NULL DEFAULT 'CUSTOM',
"priority" TEXT NOT NULL DEFAULT 'medium',
"location" TEXT,
"description" TEXT,
"employeeId" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CalendarEvent_orgId_date_idx" ON "CalendarEvent"("orgId", "date");
CREATE INDEX "CalendarEvent_orgId_type_idx" ON "CalendarEvent"("orgId", "type");
ALTER TABLE "CalendarEvent" ADD CONSTRAINT "CalendarEvent_orgId_fkey"
FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE;
ALTER TABLE "CalendarEvent" ADD CONSTRAINT "CalendarEvent_employeeId_fkey"
FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL;
@@ -0,0 +1,25 @@
-- P2-15: 人工咨询服务 Consultation 表
CREATE TABLE "Consultation" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"contactName" TEXT NOT NULL,
"contactPhone" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"aiConversationId" TEXT,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Consultation_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Consultation_orgId_status_idx" ON "Consultation"("orgId", "status");
CREATE INDEX "Consultation_orgId_type_idx" ON "Consultation"("orgId", "type");
-- 外键
ALTER TABLE "Consultation" ADD CONSTRAINT "Consultation_orgId_fkey"
FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE;
+126 -1
View File
@@ -166,6 +166,11 @@ model Organization {
healthCheckReports HealthCheckReport[]
annualValueReports AnnualValueReport[]
specialDeductionRecords SpecialDeductionRecord[]
shifts Shift[]
shiftAssignments ShiftAssignment[]
leaveRecords LeaveRecord[]
calendarEvents CalendarEvent[]
consultations Consultation[]
}
model User {
@@ -216,6 +221,7 @@ model Employee {
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
city String? // 员工社保参保城市
birthDate DateTime? // 出生日期(从身份证号提取)
education String? // 学历(博士/硕士/本科/大专/高中/其他)
femaleWorkerType FemaleWorkerType? // 女性岗位类型(CADRE=干部/WORKER=工人,仅女性需要区分)
retirementDaysLeft Int? // 距退休天数(便捷字段,定期计算)
createdBy String
@@ -242,6 +248,9 @@ model Employee {
attendanceConfirmations AttendanceConfirmation[]
policyReadRecords PolicyReadRecord[]
specialDeductionRecords SpecialDeductionRecord[]
shiftAssignments ShiftAssignment[]
leaveRecords LeaveRecord[]
calendarEvents CalendarEvent[]
@@unique([orgId, idCardHash])
}
@@ -410,6 +419,7 @@ model HousingFundConfig {
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
city String @default("北京")
accountType String @default("BASIC") // BASIC=基本公积金, SUPPLEMENTARY=补充公积金
housingOrg Float @default(12) // 公积金 企业比例 %
housingEmp Float @default(12) // 公积金 个人比例 %
baseMin Float @default(6326) // 公积金缴费基数下限
@@ -422,7 +432,7 @@ model HousingFundConfig {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, city, effectiveFrom])
@@unique([orgId, city, accountType, effectiveFrom])
@@index([orgId, isCurrent])
}
@@ -668,6 +678,15 @@ model BatchEntry {
allowance Float @default(0)
deduction Float @default(0)
bonus Float @default(0)
// 细化薪资项
positionSalary Float @default(0) // 岗位工资
performanceSalary Float @default(0) // 绩效工资
senioritySalary Float @default(0) // 工龄工资
transportAllowance Float @default(0) // 交通补贴
mealAllowance Float @default(0) // 餐补
housingAllowance Float @default(0) // 住房补贴
communicationAllowance Float @default(0) // 通讯补贴
otherDeduction Float @default(0) // 其他扣款
// 自动计算项
socialEmp Float @default(0)
socialOrg Float @default(0)
@@ -1045,3 +1064,109 @@ model SpecialDeductionRecord {
@@unique([employeeId, month])
@@index([orgId, month])
}
// ========== 班次管理 ==========
model Shift {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String // 班次名称,如"早班"、"白班"、"夜班"
startTime String // 上班时间 HH:mm
endTime String // 下班时间 HH:mm
flexibleMinutes Int @default(0) // 弹性时长(分钟)
restMinutes Int @default(0) // 休息时长(分钟)
color String @default("#3b82f6") // 日历显示颜色
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments ShiftAssignment[]
@@index([orgId])
}
// ========== 排班记录 ==========
model ShiftAssignment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
shiftId String
shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade)
date DateTime // 排班日期
createdBy String
createdAt DateTime @default(now())
@@unique([employeeId, date])
@@index([orgId, date])
@@index([orgId, employeeId])
}
// ========== 休假记录(直接记录,无审批流程) ==========
model LeaveRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
leaveType String // SICK=病假 / PERSONAL=事假 / ANNUAL=年假 / MATERNITY=产假 / OTHER=其他
startDate DateTime
endDate DateTime
days Float // 请假天数
reason String?
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, employeeId])
@@index([orgId, startDate])
}
// ========== 自定义日历事件 ==========
model CalendarEvent {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
title String
date DateTime
endDate DateTime?
type String @default("CUSTOM") // CUSTOM=自定义, MEETING=会议, TEAM_BUILDING=团建, TRAINING=培训, INTERVIEW=面试
priority String @default("medium") // high/medium/low
location String?
description String?
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, date])
@@index([orgId, type])
}
// ========== 人工咨询服务 ==========
model Consultation {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
type String // LEGAL=法律咨询, ARBITRATION=仲裁代理, COURT=出庭服务
title String
description String
contactName String
contactPhone String
status String @default("PENDING") // PENDING=待处理, CONTACTED=已联系, COMPLETED=已完成, CANCELLED=已取消
aiConversationId String? // 关联 AI 会话
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, type])
}
+2
View File
@@ -56,6 +56,7 @@ import policyRoutes from './routes/policy.routes'
import attendanceRoutes from './routes/attendance.routes'
import templateRoutes from './routes/template.routes'
import auditRoutes from './routes/audit.routes'
import calendarRoutes from './routes/calendar.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -76,6 +77,7 @@ app.use('/api/v1/policies', policyRoutes)
app.use('/api/v1/attendance', attendanceRoutes)
app.use('/api/v1/templates', templateRoutes)
app.use('/api/v1/audit', auditRoutes)
app.use('/api/v1/calendar', calendarRoutes)
app.use(errorHandler)
+212 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream } from '../services/ai.service'
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream, generateHRReportStream } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable, searchHelp, seedHelpKnowledge } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
@@ -803,4 +803,215 @@ router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res,
}
})
// ========== AI 人力分析报告 ==========
router.post('/hr-report-stream', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const month = new Date().toISOString().slice(0, 7)
// 聚合企业数据
const [employees, risks, batches] = await Promise.all([
prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: {
name: true, department: true, gender: true, hireDate: true,
birthDate: true, education: true, city: true,
isPregnant: true, isInMedicalPeriod: true, isWorkInjured: true,
contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true, endDate: true, startDate: true } },
},
}),
prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
select: { title: true, level: true, type: true, description: true, employee: { select: { name: true } } },
}),
prisma.payrollBatch.findMany({
where: { orgId, month, status: 'ARCHIVED' },
select: { totalPay: true, totalSocialOrg: true, totalHousingOrg: true, totalTax: true, employeeCount: true },
}),
])
const now = new Date()
// 员工概况
const genderDist: Record<string, number> = {}
const eduDist: Record<string, number> = {}
const deptDist: Record<string, number> = {}
let totalAge = 0, ageCount = 0
let totalTenure = 0
for (const e of employees) {
const g = e.gender || '未知'
genderDist[g] = (genderDist[g] || 0) + 1
const edu = e.education || '未知'
eduDist[edu] = (eduDist[edu] || 0) + 1
deptDist[e.department] = (deptDist[e.department] || 0) + 1
if (e.birthDate) {
totalAge += now.getFullYear() - e.birthDate.getFullYear()
ageCount++
}
totalTenure += (now.getTime() - e.hireDate.getTime()) / (365.25 * 24 * 3600 * 1000)
}
const avgAge = ageCount > 0 ? (totalAge / ageCount).toFixed(1) : '未知'
const avgTenure = employees.length > 0 ? (totalTenure / employees.length).toFixed(1) : '0'
// 成本数据
const monthCost = batches.reduce((acc, b) => ({
totalPay: acc.totalPay + b.totalPay,
totalSocialOrg: acc.totalSocialOrg + b.totalSocialOrg,
totalHousingOrg: acc.totalHousingOrg + b.totalHousingOrg,
totalTax: acc.totalTax + b.totalTax,
employeeCount: acc.employeeCount + b.employeeCount,
}), { totalPay: 0, totalSocialOrg: 0, totalHousingOrg: 0, totalTax: 0, employeeCount: 0 })
const totalCost = monthCost.totalPay + monthCost.totalSocialOrg + monthCost.totalHousingOrg
const perCapita = monthCost.employeeCount > 0 ? totalCost / monthCost.employeeCount : 0
// 特殊状态员工
const specialEmployees = employees
.filter(e => e.isPregnant || e.isInMedicalPeriod || e.isWorkInjured)
.map(e => {
const tags: string[] = []
if (e.isPregnant) tags.push('孕期/哺乳期')
if (e.isInMedicalPeriod) tags.push('医疗期')
if (e.isWorkInjured) tags.push('工伤')
return `${e.name}${e.department}):${tags.join('、')}`
})
// 合同即将到期(30天内)
const expiringContracts = employees
.filter(e => {
const c = e.contracts[0]
if (!c?.endDate) return false
const days = Math.floor((c.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return days >= 0 && days <= 30
})
.map(e => `${e.name}${e.department}),到期日:${e.contracts[0].endDate?.toISOString().slice(0, 10)}`)
const orgData = `企业人力数据概览(截至 ${now.toISOString().slice(0, 10)}):
【员工概况】
- 在职员工总数:${employees.length}
- 性别分布:${Object.entries(genderDist).map(([k, v]) => `${k} ${v}`).join('、')}
- 学历分布:${Object.entries(eduDist).map(([k, v]) => `${k} ${v}`).join('、')}
- 平均年龄:${avgAge}
- 平均司龄:${avgTenure}
- 部门分布:${Object.entries(deptDist).map(([k, v]) => `${k} ${v}`).join('、')}
【本月人力成本】
- 工资总额:¥${monthCost.totalPay.toFixed(2)}
- 企业社保:¥${monthCost.totalSocialOrg.toFixed(2)}
- 企业公积金:¥${monthCost.totalHousingOrg.toFixed(2)}
- 个人所得税:¥${monthCost.totalTax.toFixed(2)}
- 企业总成本:¥${totalCost.toFixed(2)}
- 人均成本:¥${perCapita.toFixed(2)}
- 覆盖人数:${monthCost.employeeCount}
【当前风险项】(${risks.length} 项)
${risks.map(r => `- [${r.level}] ${r.title}${r.employee?.name || '通用'}):${r.description || '无描述'}`).join('\n')}
【特殊状态员工】(${specialEmployees.length} 人)
${specialEmployees.length > 0 ? specialEmployees.join('\n') : '无'}
【合同即将到期】(30天内,${expiringContracts.length} 人)
${expiringContracts.length > 0 ? expiringContracts.join('\n') : '无'}`
await checkUsageLimit(orgId, 'chat')
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
let usageRecorded = false
try {
for await (const delta of generateHRReportStream(orgData)) {
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
if (typeof (res as any).flush === 'function') (res as any).flush()
}
res.write('data: [DONE]\n\n')
} catch (streamErr: any) {
res.write(`data: ${JSON.stringify({ error: streamErr.message || 'AI 服务异常' })}\n\n`)
res.write('data: [DONE]\n\n')
} finally {
if (!usageRecorded) {
await recordUsage(orgId, req.user!.id, 'chat')
usageRecorded = true
}
}
res.end()
} catch (err) {
if (!res.headersSent) next(err)
else res.end()
}
})
// ========== 人工咨询服务 ==========
router.post('/consultation', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
type: z.enum(['LEGAL', 'ARBITRATION', 'COURT']),
title: z.string().min(1, '标题不能为空'),
description: z.string().min(1, '描述不能为空'),
contactName: z.string().min(1, '联系人不能为空'),
contactPhone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
aiConversationId: z.string().optional(),
remark: z.string().optional(),
})
const data = schema.parse(req.body)
const consultation = await (prisma as any).consultation.create({
data: {
orgId: req.user!.orgId,
type: data.type,
title: data.title,
description: data.description,
contactName: data.contactName,
contactPhone: data.contactPhone,
aiConversationId: data.aiConversationId || null,
remark: data.remark || null,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: consultation })
} catch (err) {
next(err)
}
})
router.get('/consultations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const consultations = await (prisma as any).consultation.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
take: 50,
})
res.json({ success: true, data: consultations })
} catch (err) {
next(err)
}
})
router.patch('/consultations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
status: z.enum(['PENDING', 'CONTACTED', 'COMPLETED', 'CANCELLED']),
remark: z.string().optional(),
})
const data = schema.parse(req.body)
const result = await (prisma as any).consultation.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId },
data: {
status: data.status,
...(data.remark !== undefined ? { remark: data.remark } : {}),
},
})
if (result.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '咨询记录不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+148 -1
View File
@@ -7,6 +7,18 @@ import {
getAttendanceConfirmations,
confirmAttendance,
getAttendanceStats,
getShifts,
createShift,
updateShift,
deleteShift,
getShiftAssignments,
batchAssignShifts,
deleteShiftAssignment,
getDailyAttendance,
getMonthlyReport,
getLeaveRecords,
createLeaveRecord,
deleteLeaveRecord,
} from '../services/attendance.service'
import { createEvidence } from '../services/evidence.service'
@@ -20,7 +32,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: Ne
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
}
const status = req.query.status as string | undefined
const data = await getAttendanceConfirmations(req.user!.orgId, month, status)
const department = req.query.department as string | undefined
const data = await getAttendanceConfirmations(req.user!.orgId, month, status, department)
res.json({ success: true, data })
} catch (err) {
next(err)
@@ -91,4 +104,138 @@ router.post('/confirm', authMiddleware, async (req: AuthRequest, res: Response,
}
})
// ========== 班次管理 ==========
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getShifts(req.user!.orgId)
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.post('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
name: z.string().min(1),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
flexibleMinutes: z.number().int().min(0).optional(),
restMinutes: z.number().int().min(0).optional(),
color: z.string().optional(),
})
const data = await createShift(req.user!.orgId, req.user!.id, schema.parse(req.body))
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.put('/shifts/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
name: z.string().min(1).optional(),
startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
flexibleMinutes: z.number().int().min(0).optional(),
restMinutes: z.number().int().min(0).optional(),
color: z.string().optional(),
})
const data = await updateShift(req.user!.orgId, req.params.id, schema.parse(req.body))
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.delete('/shifts/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await deleteShift(req.user!.orgId, req.params.id)
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 排班管理 ==========
router.get('/shift-assignments', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const date = req.query.date as string
if (!date) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 date 参数' } })
const data = await getShiftAssignments(req.user!.orgId, date)
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.post('/shift-assignments/batch', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
items: z.array(z.object({
employeeId: z.string(),
shiftId: z.string(),
date: z.string(),
})),
})
const { items } = schema.parse(req.body)
const result = await batchAssignShifts(req.user!.orgId, req.user!.id, items)
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
router.delete('/shift-assignments/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await deleteShiftAssignment(req.user!.orgId, req.params.id)
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 每日出勤 ==========
router.get('/daily', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const date = req.query.date as string
if (!date) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 date 参数' } })
const data = await getDailyAttendance(req.user!.orgId, date)
res.json({ success: true, data })
} catch (err) { next(err) }
})
// ========== 月度出勤报表 ==========
router.get('/monthly-report', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = req.query.month as string
if (!month) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
const data = await getMonthlyReport(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) { next(err) }
})
// ========== 休假记录 ==========
router.get('/leaves', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const employeeId = req.query.employeeId as string | undefined
const data = await getLeaveRecords(req.user!.orgId, employeeId)
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.post('/leaves', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
employeeId: z.string(),
leaveType: z.enum(['SICK', 'PERSONAL', 'ANNUAL', 'MATERNITY', 'OTHER']),
startDate: z.string(),
endDate: z.string(),
days: z.number().min(0),
reason: z.string().optional(),
remark: z.string().optional(),
})
const data = await createLeaveRecord(req.user!.orgId, req.user!.id, schema.parse(req.body))
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.delete('/leaves/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await deleteLeaveRecord(req.user!.orgId, req.params.id)
res.json({ success: true })
} catch (err) { next(err) }
})
export default router
+1 -1
View File
@@ -12,7 +12,7 @@ router.use(authMiddleware)
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const action = req.query.action as string | undefined
const entity = req.query.entity as string | undefined
const userId = req.query.userId as string | undefined
+120
View File
@@ -0,0 +1,120 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
const router = Router()
const createEventSchema = z.object({
title: z.string().min(1).max(100),
date: z.string(), // ISO date string
endDate: z.string().optional(),
type: z.enum(['CUSTOM', 'MEETING', 'TEAM_BUILDING', 'TRAINING', 'INTERVIEW']).default('CUSTOM'),
priority: z.enum(['high', 'medium', 'low']).default('medium'),
location: z.string().optional(),
description: z.string().optional(),
employeeId: z.string().optional(),
})
// 获取当月日历事件(自定义 + 系统自动)
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const typeFilter = req.query.type as string | undefined
const [year, mon] = month.split('-').map(Number)
const monthStart = new Date(year, mon - 1, 1)
const monthEnd = new Date(year, mon, 0, 23, 59, 59)
const where: any = {
orgId,
date: { gte: monthStart, lte: monthEnd },
}
if (typeFilter) where.type = typeFilter
const events = await prisma.calendarEvent.findMany({
where,
include: { employee: { select: { id: true, name: true } } },
orderBy: { date: 'asc' },
})
res.json({ success: true, data: events })
} catch (err) {
next(err)
}
})
// 获取事件详情
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const event = await prisma.calendarEvent.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: { employee: { select: { id: true, name: true } } },
})
if (!event) return res.status(404).json({ success: false, message: '事件不存在' })
res.json({ success: true, data: event })
} catch (err) {
next(err)
}
})
// 创建日历事件
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createEventSchema.parse(req.body)
const event = await prisma.calendarEvent.create({
data: {
orgId: req.user!.orgId,
title: data.title,
date: new Date(data.date),
endDate: data.endDate ? new Date(data.endDate) : null,
type: data.type,
priority: data.priority,
location: data.location || null,
description: data.description || null,
employeeId: data.employeeId || null,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: event })
} catch (err) {
next(err)
}
})
// 更新日历事件
router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const updateSchema = createEventSchema.partial()
const data = updateSchema.parse(req.body)
const updateData: any = { ...data }
if (data.date) updateData.date = new Date(data.date)
if (data.endDate) updateData.endDate = new Date(data.endDate)
if (data.endDate === undefined) delete updateData.endDate
const event = await prisma.calendarEvent.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId },
data: updateData,
})
if (event.count === 0) return res.status(404).json({ success: false, message: '事件不存在' })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 删除日历事件
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const result = await prisma.calendarEvent.deleteMany({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (result.count === 0) return res.status(404).json({ success: false, message: '事件不存在' })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+68
View File
@@ -171,4 +171,72 @@ router.get('/annual-value/history', authMiddleware, async (req: AuthRequest, res
}
})
// 人力信息总览 — 员工分布统计
router.get('/workforce-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { gender: true, birthDate: true, hireDate: true, education: true },
})
const now = new Date()
// 性别分布
const genderDist: Record<string, number> = {}
for (const e of employees) {
const g = e.gender || '未知'
genderDist[g] = (genderDist[g] || 0) + 1
}
// 年龄段分布
const ageRanges = ['<25', '25-30', '31-35', '36-40', '41-50', '>50']
const ageDist: Record<string, number> = {}
for (const r of ageRanges) ageDist[r] = 0
for (const e of employees) {
if (!e.birthDate) continue
const age = now.getFullYear() - e.birthDate.getFullYear()
if (age < 25) ageDist['<25']++
else if (age <= 30) ageDist['25-30']++
else if (age <= 35) ageDist['31-35']++
else if (age <= 40) ageDist['36-40']++
else if (age <= 50) ageDist['41-50']++
else ageDist['>50']++
}
// 学历分布
const eduDist: Record<string, number> = {}
for (const e of employees) {
const edu = e.education || '未知'
eduDist[edu] = (eduDist[edu] || 0) + 1
}
// 司龄分布
const tenureRanges = ['<1年', '1-3年', '3-5年', '5-10年', '>10年']
const tenureDist: Record<string, number> = {}
for (const r of tenureRanges) tenureDist[r] = 0
for (const e of employees) {
const years = (now.getTime() - e.hireDate.getTime()) / (365.25 * 24 * 3600 * 1000)
if (years < 1) tenureDist['<1年']++
else if (years < 3) tenureDist['1-3年']++
else if (years < 5) tenureDist['3-5年']++
else if (years < 10) tenureDist['5-10年']++
else tenureDist['>10年']++
}
res.json({
success: true,
data: {
total: employees.length,
gender: Object.entries(genderDist).map(([name, value]) => ({ name, value })),
age: Object.entries(ageDist).map(([name, value]) => ({ name, value })),
education: Object.entries(eduDist).map(([name, value]) => ({ name, value })),
tenure: Object.entries(tenureDist).map(([name, value]) => ({ name, value })),
},
})
} catch (err) {
next(err)
}
})
export default router
+1 -1
View File
@@ -26,7 +26,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await getEmployees(req.user!.orgId, {
page: parseInt(req.query.page as string) || 1,
pageSize: parseInt(req.query.pageSize as string) || 20,
pageSize: Math.min(parseInt(req.query.pageSize as string) || 20, 200),
search: req.query.search as string,
department: req.query.department as string,
})
+1 -1
View File
@@ -12,7 +12,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: Ne
try {
const category = (req.query.category as string) || 'ALL'
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const data = await getEvidenceList(req.user!.orgId, category, page, pageSize)
res.json({ success: true, data })
} catch (err) {
+148 -4
View File
@@ -9,6 +9,12 @@ import { Writable } from 'stream'
const router = Router()
// RFC 5987 编码中文文件名,兼容所有浏览器
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
// 敏感字段脱敏
function maskIdCard(idCard: string | null): string | null {
if (!idCard) return null
@@ -87,7 +93,7 @@ router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: R
}
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.xlsx"`)
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} else {
@@ -95,10 +101,10 @@ router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: R
if (useGzip) {
res.setHeader('Content-Encoding', 'gzip')
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`)
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.json.gz`))
} else {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.json`))
}
const gzip = useGzip ? createGzip() : null
@@ -234,7 +240,145 @@ router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, n
totalRow.font = { bold: true }
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${month}.xlsx"`)
res.setHeader('Content-Disposition', contentDisposition(`薪税汇总-${month}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
// 导出花名册 Excel(支持筛选)
router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const search = req.query.search as string | undefined
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const contractStatus = req.query.contractStatus as string | undefined
const where: any = { orgId }
if (department) where.department = department
if (status === 'RESIGNED') {
where.status = 'RESIGNED'
} else if (status === 'ACTIVE') {
where.status = 'ACTIVE'
}
if (search) {
where.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
const employees = await prisma.employee.findMany({
where,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('花名册')
ws.columns = [
{ header: '姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '状态', key: 'status', width: 10 },
{ header: '入职日期', key: 'hireDate', width: 12 },
{ header: '合同起始', key: 'contractStart', width: 12 },
{ header: '合同结束', key: 'contractEnd', width: 12 },
{ header: '联系方式', key: 'phone', width: 15 },
]
ws.getRow(1).font = { bold: true }
for (const e of employees) {
const contract = e.contracts[0]
ws.addRow({
name: e.name,
department: e.department,
status: e.status === 'ACTIVE' ? '在职' : e.status === 'RESIGNED' ? '离职' : '预入职',
hireDate: e.hireDate?.toISOString().slice(0, 10) || '',
contractStart: contract?.startDate?.toISOString().slice(0, 10) || '',
contractEnd: contract?.endDate?.toISOString().slice(0, 10) || '',
phone: e.phone || '',
})
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`花名册-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
// 导出解聘记录 Excel(支持筛选)
router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
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 where: any = { orgId }
if (status) where.status = status
if (department || search) {
where.employee = {}
if (department) where.employee.department = department
if (search) {
where.employee.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
}
const records = await prisma.terminationRecord.findMany({
where,
include: { employee: true },
orderBy: { updatedAt: 'desc' },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('解聘记录')
ws.columns = [
{ header: '员工姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '解聘类型', key: 'type', width: 12 },
{ header: '解聘原因', key: 'reason', width: 20 },
{ header: '解聘日期', key: 'terminationDate', width: 12 },
{ header: '补偿金', key: 'compensation', width: 12 },
{ header: '状态', key: 'status', width: 10 },
{ header: '创建日期', key: 'createdAt', width: 12 },
]
ws.getRow(1).font = { bold: true }
const reasonLabels: Record<string, string> = {
NEGOTIATED: '协商解除', FAULT: '过错解除', NONFAULT: '非过错解除',
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除', RESIGNATION: '员工离职',
}
const statusLabels: Record<string, string> = {
DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批',
REJECTED: '已驳回', EXECUTING: '执行中', COMPLETED: '已完成', CANCELLED: '已撤销',
}
for (const r of records) {
ws.addRow({
name: r.employee.name,
department: r.employee.department,
type: r.type === 'TERMINATION' ? '解聘' : '离职',
reason: reasonLabels[r.reason] || r.reason,
terminationDate: r.terminationDate?.toISOString().slice(0, 10) || '',
compensation: r.compensation || 0,
status: statusLabels[r.status] || r.status,
createdAt: r.createdAt?.toISOString().slice(0, 10) || '',
})
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`解聘记录-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
+13 -6
View File
@@ -9,6 +9,12 @@ import { extractBirthDateFromIdCard, extractGenderFromIdCard } from '../services
import { calcBatchEntry } from '../services/payroll.service'
import { createEvidence } from '../services/evidence.service'
// RFC 5987 编码中文文件名
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
@@ -101,7 +107,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), status: 'normal', errors: [] as string[], warnings: [] as string[] }
const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), city: val(r['参保城市']) || '北京', status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
@@ -206,7 +212,7 @@ router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Re
XLSX.utils.book_append_sheet(wb, ws, '错误日志')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="import-errors-${Date.now()}.xlsx"`)
res.setHeader('Content-Disposition', contentDisposition('导入错误日志.xlsx'))
res.send(buf)
} catch (err) {
next(err)
@@ -262,6 +268,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
socialInsBase: num(r['社保基数']) || num(salary),
housingFundBase: num(r['公积金基数']) || num(salary),
specialDeduction: num(r['专项附加扣除']) || 0,
city: val(r['参保城市']) || '北京',
isPregnant: val(r['孕期']) === '是',
isInMedicalPeriod: val(r['医疗期']) === '是',
isWorkInjured: val(r['工伤']) === '是',
@@ -443,7 +450,7 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
@@ -469,7 +476,7 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="import-template.xlsx"')
res.setHeader('Content-Disposition', contentDisposition('员工导入模板.xlsx'))
res.send(buf)
})
@@ -656,7 +663,7 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="monthly-import-template.xlsx"')
res.setHeader('Content-Disposition', contentDisposition('月度增减员导入模板.xlsx'))
res.send(buf)
})
@@ -756,7 +763,7 @@ router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Respons
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="payroll-import-template.xlsx"')
res.setHeader('Content-Disposition', contentDisposition('工资表导入模板.xlsx'))
res.send(buf)
})
+1 -1
View File
@@ -57,7 +57,7 @@ router.put('/settings', async (req: AuthRequest, res: Response, next: NextFuncti
router.get('/logs', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const [logs, total] = await Promise.all([
prisma.notificationLog.findMany({
where: { orgId: req.user!.orgId },
+92
View File
@@ -576,4 +576,96 @@ router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFu
}
})
// ========== 薪资汇总表 & 明细表 ==========
// 薪资汇总表(按部门维度统计)
router.get('/batch/:id/summary', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
const entries = await prisma.batchEntry.findMany({
where: { batchId: req.params.id },
include: { employee: { select: { id: true, name: true, department: true } } },
})
// 按部门汇总
const deptMap = new Map<string, any>()
for (const e of entries) {
const dept = e.employee.department || '未分配'
if (!deptMap.has(dept)) {
deptMap.set(dept, { department: dept, headcount: 0, totalPay: 0, totalNetPay: 0, totalSocialEmp: 0, totalSocialOrg: 0, totalHousingEmp: 0, totalHousingOrg: 0, totalTax: 0 })
}
const d = deptMap.get(dept)
d.headcount++
d.totalPay += e.totalPay
d.totalNetPay += e.netPay
d.totalSocialEmp += e.socialEmp
d.totalSocialOrg += e.socialOrg
d.totalHousingEmp += e.housingEmp
d.totalHousingOrg += e.housingOrg
d.totalTax += e.tax
}
const departments = Array.from(deptMap.values())
const grandTotal = {
headcount: entries.length,
totalPay: entries.reduce((s, e) => s + e.totalPay, 0),
totalNetPay: entries.reduce((s, e) => s + e.netPay, 0),
totalSocialEmp: entries.reduce((s, e) => s + e.socialEmp, 0),
totalSocialOrg: entries.reduce((s, e) => s + e.socialOrg, 0),
totalHousingEmp: entries.reduce((s, e) => s + e.housingEmp, 0),
totalHousingOrg: entries.reduce((s, e) => s + e.housingOrg, 0),
totalTax: entries.reduce((s, e) => s + e.tax, 0),
}
res.json({ success: true, data: { batch, departments, grandTotal } })
} catch (err) { next(err) }
})
// 薪资明细表(全员明细)
router.get('/batch/:id/detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
const entries = await prisma.batchEntry.findMany({
where: { batchId: req.params.id },
include: { employee: { select: { id: true, name: true, department: true, phone: true } } },
orderBy: { employee: { department: 'asc' } },
})
const details = entries.map(e => ({
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
phone: e.employee.phone,
baseSalary: e.baseSalary,
positionSalary: e.positionSalary,
performanceSalary: e.performanceSalary,
senioritySalary: e.senioritySalary,
overtimePay: e.overtimePay,
transportAllowance: e.transportAllowance,
mealAllowance: e.mealAllowance,
housingAllowance: e.housingAllowance,
communicationAllowance: e.communicationAllowance,
allowance: e.allowance,
bonus: e.bonus,
deduction: e.deduction,
otherDeduction: e.otherDeduction,
socialEmp: e.socialEmp,
housingEmp: e.housingEmp,
tax: e.tax,
totalPay: e.totalPay,
netPay: e.netPay,
}))
res.json({ success: true, data: { batch, details } })
} catch (err) { next(err) }
})
export default router
+7 -1
View File
@@ -11,6 +11,12 @@ import {
prePayrollCheck,
} from '../services/payroll.service'
// RFC 5987 编码中文文件名
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
const router = Router()
router.use(authMiddleware)
@@ -781,7 +787,7 @@ router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, n
const header = '姓名,银行账号,开户行,实发金额\n'
const rows = batch.entries.map(e => `${e.employee.name},${e.employee.bankAccount || ''},${e.employee.bankName || ''},${e.netPay}`).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${batch.month}-batch${batch.batchNo}.csv"`)
res.setHeader('Content-Disposition', contentDisposition(`银行代发文件-${batch.month}-批次${batch.batchNo}.csv`))
return res.send('\ufeff' + header + rows)
}
+48 -6
View File
@@ -19,14 +19,30 @@ function safeDecrypt(encrypted: string): number {
// ========== 花名册聚合 API ==========
// 获取部门列表(去重)
router.get('/departments', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
select: { department: true },
distinct: 'department',
})
const departments = employees.map((e) => e.department).filter(Boolean).sort()
res.json({ success: true, data: departments })
} catch (err) {
next(err)
}
})
// 花名册列表(含汇总信息,支持分页和过滤)
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 999)
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const search = req.query.search as string
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
const department = req.query.department as string
const skip = (page - 1) * pageSize
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
@@ -38,8 +54,12 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
todayEnd.setDate(todayEnd.getDate() + 1)
// 先查询满足 orgId 和搜索条件的员工
const isIdCardSearch = search && /^\d{4}$/.test(search)
const whereBase: any = { orgId: req.user!.orgId }
if (search) {
if (department) {
whereBase.department = department
}
if (search && !isIdCardSearch) {
whereBase.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
@@ -62,8 +82,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
whereBase.contracts = { none: {} }
}
// 当有 contractStatus(非 unsigned)筛选时,需要先查全部再过滤后分页
const needPostFilter = !!contractStatus && contractStatus !== 'unsigned'
// 当有 contractStatus(非 unsigned)筛选或身份证号搜索时,需要先查全部再过滤后分页
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
const [dbTotal, employees] = await Promise.all([
prisma.employee.count({ where: whereBase }),
@@ -109,6 +129,18 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
const isPreHire = !isResigned && e.hireDate > todayEnd
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
// 身份证号脱敏显示
let idCardMasked: string | null = null
if (e.idCardNumber) {
try {
const idCard = decrypt(e.idCardNumber)
if (idCard.length >= 11) {
idCardMasked = idCard.slice(0, 3) + '****' + idCard.slice(-4)
} else {
idCardMasked = '****'
}
} catch {}
}
return {
id: e.id,
name: e.name,
@@ -123,6 +155,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
hireDate: e.hireDate,
gender: e.gender,
phone: e.phone,
idCardMasked,
monthlySalary: safeDecrypt(e.monthlySalary),
isPregnant: e.isPregnant,
isInMedicalPeriod: e.isInMedicalPeriod,
@@ -140,9 +173,18 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
result = result.filter((e) => e.contractStatus === contractStatus)
}
// 身份证号后4位搜索:在内存中过滤
if (isIdCardSearch) {
result = result.filter((e: any) => {
if (!e.idCardMasked) return false
return e.idCardMasked.endsWith(search!)
})
}
// 计算过滤后的总数和分页
const filteredTotal = needPostFilter ? result.length : dbTotal
if (needPostFilter) {
const needMemoryPaging = needPostFilter || isIdCardSearch
const filteredTotal = needMemoryPaging ? result.length : dbTotal
if (needMemoryPaging) {
result = result.slice(skip, skip + pageSize)
}
+16 -14
View File
@@ -27,6 +27,7 @@ const socialConfigFields = {
const housingConfigFields = {
city: z.string().optional(),
accountType: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
baseMin: z.number().optional(),
@@ -458,23 +459,20 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
// ========== 公积金配置 ==========
// 获取当前公积金配置(支持按城市筛选)
// 获取当前公积金配置(支持按城市、账户类型筛选)
router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const accountType = req.query.accountType as string | undefined
const where: any = { orgId: req.user!.orgId, isCurrent: true }
if (city) where.city = city
let config = await prisma.housingFundConfig.findFirst({
if (accountType) where.accountType = accountType
const configs = await prisma.housingFundConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
})
// 未指定城市时,返回任意当前配置
if (!config && !city) {
config = await prisma.housingFundConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
// 兼容旧接口:无 accountType 参数时返回第一条
const config = accountType ? configs.find(c => c.accountType === accountType) || configs[0] : configs[0]
if (!config) {
return res.json({ success: true, data: null })
}
@@ -484,15 +482,17 @@ router.get('/housing-config', async (req: AuthRequest, res: Response, next: Next
}
})
// 公积金配置版本列表(支持按城市筛选)
// 公积金配置版本列表(支持按城市、账户类型筛选)
router.get('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const accountType = req.query.accountType as string | undefined
const where: any = { orgId: req.user!.orgId }
if (city) where.city = city
if (accountType) where.accountType = accountType
const versions = await prisma.housingFundConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
orderBy: [{ accountType: 'asc' }, { effectiveFrom: 'desc' }],
})
res.json({ success: true, data: versions })
} catch (err) {
@@ -511,15 +511,16 @@ router.post('/housing-config/versions', async (req: AuthRequest, res: Response,
const data = createHousingVersionSchema.parse(req.body)
const orgId = req.user!.orgId
const acctType = data.accountType || 'BASIC'
const existing = await prisma.housingFundConfig.findFirst({
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
where: { orgId, city: data.city, accountType: acctType, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有${acctType === 'SUPPLEMENTARY' ? '补充' : '基本'}公积金配置版本` })
}
const prevCurrent = await prisma.housingFundConfig.findFirst({
where: { orgId, isCurrent: true },
where: { orgId, city: data.city, accountType: acctType, isCurrent: true },
})
if (prevCurrent) {
const [year, mon] = data.effectiveFrom.split('-').map(Number)
@@ -535,6 +536,7 @@ router.post('/housing-config/versions', async (req: AuthRequest, res: Response,
const version = await prisma.housingFundConfig.create({
data: {
orgId,
accountType: acctType,
...data,
isCurrent: true,
createdBy: req.user!.id,
+4 -2
View File
@@ -11,7 +11,7 @@ const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const result = await getTerminations(req.user!.orgId, page, pageSize)
res.json({ success: true, data: result })
} catch (err) {
@@ -160,7 +160,9 @@ router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
router.get('/drafts', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const status = req.query.status as string | undefined
const result = await getDrafts(req.user!.orgId, status)
const search = req.query.search as string | undefined
const department = req.query.department as string | undefined
const result = await getDrafts(req.user!.orgId, status, search, department)
res.json({ success: true, data: result })
} catch (err) {
next(err)
+2
View File
@@ -12,6 +12,7 @@ export const createEmployeeSchema = z.object({
isInMedicalPeriod: z.boolean().default(false),
isWorkInjured: z.boolean().default(false),
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
contract: z.object({
signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(),
@@ -44,6 +45,7 @@ export const updateEmployeeSchema = z.object({
housingFundBase: z.number().min(0).nullable().optional(),
specialDeduction: z.number().min(0).optional(),
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
})
export const batchRenewSchema = z.object({
+56
View File
@@ -347,3 +347,59 @@ ${orgContext}
}
}
/**
* AI 人力分析报告:基于企业数据自动生成结构化报告
*/
export async function* generateHRReportStream(orgData: string) {
const prompt = `请基于以下企业人力数据,生成一份结构化的 HR 人力分析报告。请使用 Markdown 格式输出,包含以下部分:
## 一、人力概况
- 员工总数、部门分布、性别比例、年龄段分布、学历分布、司龄分布
## 二、风险提示
- 当前存在的用工风险(合同到期、试用期、特殊状态员工等)
- 风险等级和紧急程度
## 三、成本分析
- 人力成本概况(工资、社保、公积金等)
- 人均成本、部门成本差异
- 成本趋势分析
## 四、合规建议
- 合同管理建议
- 社保公积金合规建议
- 规章制度完善建议
## 五、改进方向
- 人才结构优化建议
- 成本控制建议
- 管理流程改进建议
报告要求:
- 数据驱动的分析,引用具体数字
- 每个部分给出 2-3 条具体可操作的建议
- 语言简洁专业,避免空话套话
企业数据:
${orgData}`
const stream = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{
role: 'system',
content: '你是一个专业的人力资源分析师,精通中国劳动法规和人力资源管理。请基于企业实际数据生成专业、客观、可操作的人力分析报告。使用 Markdown 格式输出。',
},
{ role: 'user', content: prompt },
],
temperature: 0.5,
max_tokens: 8000,
stream: true,
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content
if (delta) yield delta
}
}
+259 -1
View File
@@ -96,9 +96,10 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
/**
* 获取月度考勤确认列表
*/
export async function getAttendanceConfirmations(orgId: string, month: string, status?: string) {
export async function getAttendanceConfirmations(orgId: string, month: string, status?: string, department?: string) {
const where: any = { orgId, month }
if (status) where.status = status
if (department) where.employee = { department }
return prisma.attendanceConfirmation.findMany({
where,
@@ -148,3 +149,260 @@ export async function getAttendanceStats(orgId: string, month: string) {
disputed: records.filter(r => r.status === 'DISPUTED').length,
}
}
// ========== 班次管理 ==========
export async function getShifts(orgId: string) {
return prisma.shift.findMany({
where: { orgId },
orderBy: { startTime: 'asc' },
})
}
export async function createShift(orgId: string, userId: string, data: {
name: string
startTime: string
endTime: string
flexibleMinutes?: number
restMinutes?: number
color?: string
}) {
return prisma.shift.create({
data: {
orgId,
name: data.name,
startTime: data.startTime,
endTime: data.endTime,
flexibleMinutes: data.flexibleMinutes || 0,
restMinutes: data.restMinutes || 0,
color: data.color || '#3b82f6',
createdBy: userId,
},
})
}
export async function updateShift(orgId: string, id: string, data: {
name?: string
startTime?: string
endTime?: string
flexibleMinutes?: number
restMinutes?: number
color?: string
}) {
return prisma.shift.update({ where: { id }, data })
}
export async function deleteShift(orgId: string, id: string) {
return prisma.shift.delete({ where: { id } })
}
// ========== 排班管理 ==========
export async function getShiftAssignments(orgId: string, date: string) {
const day = new Date(date)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
return prisma.shiftAssignment.findMany({
where: { orgId, date: { gte: day, lt: nextDay } },
include: {
employee: { select: { id: true, name: true, department: true } },
shift: true,
},
orderBy: { employee: { name: 'asc' } },
})
}
export async function batchAssignShifts(orgId: string, userId: string, items: Array<{
employeeId: string
shiftId: string
date: string
}>) {
const results: Array<{ employeeId: string; date: string; success: boolean; error?: string }> = []
for (const item of items) {
try {
const date = new Date(item.date)
date.setHours(0, 0, 0, 0)
const existing = await prisma.shiftAssignment.findUnique({
where: { employeeId_date: { employeeId: item.employeeId, date } },
})
if (existing) {
await prisma.shiftAssignment.update({
where: { id: existing.id },
data: { shiftId: item.shiftId },
})
} else {
await prisma.shiftAssignment.create({
data: {
orgId,
employeeId: item.employeeId,
shiftId: item.shiftId,
date,
createdBy: userId,
},
})
}
results.push({ employeeId: item.employeeId, date: item.date, success: true })
} catch (err: any) {
results.push({ employeeId: item.employeeId, date: item.date, success: false, error: err.message })
}
}
return { total: items.length, success: results.filter(r => r.success).length, results }
}
export async function deleteShiftAssignment(orgId: string, id: string) {
return prisma.shiftAssignment.delete({ where: { id } })
}
// ========== 每日出勤 ==========
export async function getDailyAttendance(orgId: string, date: string) {
const day = new Date(date)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
const [records, assignments, employees] = await Promise.all([
prisma.attendanceRecord.findMany({
where: { orgId, date: { gte: day, lt: nextDay } },
}),
prisma.shiftAssignment.findMany({
where: { orgId, date: { gte: day, lt: nextDay } },
include: { shift: true },
}),
prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true, name: true, department: true },
orderBy: { name: 'asc' },
}),
])
const recordMap = new Map(records.map(r => [r.employeeId, r]))
const shiftMap = new Map(assignments.map(a => [a.employeeId, a.shift]))
return employees.map(emp => {
const record = recordMap.get(emp.id)
const shift = shiftMap.get(emp.id)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
shift: shift ? { name: shift.name, startTime: shift.startTime, endTime: shift.endTime, color: shift.color } : null,
checkInTime: record?.checkInTime || null,
checkOutTime: record?.checkOutTime || null,
status: record?.status || 'UNREGISTERED',
lateMinutes: record?.lateMinutes || 0,
earlyMinutes: record?.earlyMinutes || 0,
workHours: record?.workHours || 0,
overtimeHours: record?.overtimeHours || 0,
remark: record?.remark || null,
}
})
}
// ========== 月度出勤报表 ==========
export async function getMonthlyReport(orgId: string, month: string) {
const monthStart = new Date(month + '-01')
const monthEnd = new Date(monthStart)
monthEnd.setMonth(monthEnd.getMonth() + 1)
const [records, confirmations, overtimes, leaves] = await Promise.all([
prisma.attendanceRecord.findMany({
where: { orgId, date: { gte: monthStart, lt: monthEnd } },
}),
prisma.attendanceConfirmation.findMany({
where: { orgId, month },
include: { employee: { select: { id: true, name: true, department: true } } },
}),
prisma.overtimeRecord.findMany({
where: { orgId, month },
}),
prisma.leaveRecord.findMany({
where: { orgId, startDate: { lt: monthEnd }, endDate: { gte: monthStart } },
}),
])
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true, name: true, department: true },
orderBy: { name: 'asc' },
})
const otMap = new Map<string, 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 leaveMap = new Map<string, number>()
for (const lv of leaves) {
leaveMap.set(lv.employeeId, (leaveMap.get(lv.employeeId) || 0) + lv.days)
}
return employees.map(emp => {
const empRecords = records.filter(r => r.employeeId === emp.id)
const confirmation = confirmations.find(c => c.employeeId === emp.id)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
workDays: confirmation?.workDays || empRecords.filter(r => r.status === 'NORMAL').length,
lateCount: empRecords.filter(r => r.status === 'LATE').length,
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,
confirmationStatus: confirmation?.status || null,
}
})
}
// ========== 休假记录 ==========
export async function getLeaveRecords(orgId: string, employeeId?: string) {
const where: any = { orgId }
if (employeeId) where.employeeId = employeeId
return prisma.leaveRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { startDate: 'desc' },
})
}
export async function createLeaveRecord(orgId: string, userId: string, data: {
employeeId: string
leaveType: string
startDate: string
endDate: string
days: number
reason?: string
remark?: string
}) {
return prisma.leaveRecord.create({
data: {
orgId,
employeeId: data.employeeId,
leaveType: data.leaveType,
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
days: data.days,
reason: data.reason || null,
remark: data.remark || null,
createdBy: userId,
},
include: { employee: { select: { id: true, name: true, department: true } } },
})
}
export async function deleteLeaveRecord(orgId: string, id: string) {
return prisma.leaveRecord.delete({ where: { id } })
}
+2
View File
@@ -213,6 +213,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
housingFundStartMonth,
createdBy: userId,
city: data.city || '北京',
education: data.education || null,
},
})
@@ -521,6 +522,7 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
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.city !== undefined && data.city !== employee.city) {
+42 -11
View File
@@ -4,15 +4,23 @@ import prisma from '../lib/prisma'
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true },
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false },
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true },
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false },
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false },
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false },
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false },
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false },
{ name: '岗位工资', code: 'positionSalary', type: 'INPUT', formula: null, order: 2, isDefault: true, isEditable: true },
{ name: '绩效工资', code: 'performanceSalary', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
{ name: '工龄工资', code: 'senioritySalary', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 5, isDefault: true, isEditable: false },
{ name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, order: 6, isDefault: true, isEditable: true },
{ name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, order: 7, isDefault: true, isEditable: true },
{ name: '住房补贴', code: 'housingAllowance', type: 'INPUT', formula: null, order: 8, isDefault: true, isEditable: true },
{ name: '通讯补贴', code: 'communicationAllowance', type: 'INPUT', formula: null, order: 9, isDefault: true, isEditable: true },
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 10, isDefault: true, isEditable: true },
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 11, isDefault: true, isEditable: true },
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 12, isDefault: true, isEditable: true },
{ name: '其他扣款', code: 'otherDeduction', type: 'INPUT', formula: null, order: 13, isDefault: true, isEditable: true },
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + positionSalary + performanceSalary + senioritySalary + overtimePay + transportAllowance + mealAllowance + housingAllowance + communicationAllowance + allowance + bonus - deduction - otherDeduction', order: 14, isDefault: true, isEditable: false },
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 15, isDefault: true, isEditable: false },
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 16, isDefault: true, isEditable: false },
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 17, isDefault: true, isEditable: false },
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 18, isDefault: true, isEditable: false },
]
export async function ensureDefaultTemplate(orgId: string) {
@@ -127,7 +135,7 @@ export async function calcBatchEntry(
orgId: string,
employeeId: string,
month: string,
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number },
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number },
batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
) {
@@ -205,7 +213,19 @@ export async function calcBatchEntry(
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
}
const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction
const totalPay = inputs.baseSalary
+ (inputs.positionSalary || 0)
+ (inputs.performanceSalary || 0)
+ (inputs.senioritySalary || 0)
+ inputs.overtimePay
+ (inputs.transportAllowance || 0)
+ (inputs.mealAllowance || 0)
+ (inputs.housingAllowance || 0)
+ (inputs.communicationAllowance || 0)
+ inputs.allowance
+ inputs.bonus
- inputs.deduction
- (inputs.otherDeduction || 0)
// 个税计算
let tax = 0
@@ -313,6 +333,9 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
for (const entry of batch.entries) {
const existing = employeeMap.get(entry.employeeId) || {
baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0,
positionSalary: 0, performanceSalary: 0, senioritySalary: 0,
transportAllowance: 0, mealAllowance: 0, housingAllowance: 0, communicationAllowance: 0,
otherDeduction: 0,
socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0,
totalPay: 0, netPay: 0,
}
@@ -321,6 +344,14 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
existing.allowance += entry.allowance
existing.deduction += entry.deduction
existing.bonus += entry.bonus
existing.positionSalary += entry.positionSalary || 0
existing.performanceSalary += entry.performanceSalary || 0
existing.senioritySalary += entry.senioritySalary || 0
existing.transportAllowance += entry.transportAllowance || 0
existing.mealAllowance += entry.mealAllowance || 0
existing.housingAllowance += entry.housingAllowance || 0
existing.communicationAllowance += entry.communicationAllowance || 0
existing.otherDeduction += entry.otherDeduction || 0
existing.socialEmp += entry.socialEmp
existing.socialOrg += entry.socialOrg
existing.housingEmp += entry.housingEmp
+49
View File
@@ -906,6 +906,25 @@ export async function getMonthlyCalendar(orgId: string, month: string) {
}
}
// 7. 自定义日历事件
const customEvents = await prisma.calendarEvent.findMany({
where: {
orgId,
date: { gte: monthStart, lte: monthEnd },
},
include: { employee: { select: { name: true } } },
})
for (const ev of customEvents) {
events.push({
date: ev.date.toISOString().slice(0, 10),
type: ev.type,
title: ev.title + (ev.employee ? `${ev.employee.name}` : ''),
employeeName: ev.employee?.name,
actionUrl: '/dashboard',
priority: ev.priority as 'high' | 'medium' | 'low',
})
}
// 按日期排序
events.sort((a, b) => a.date.localeCompare(b.date))
@@ -1000,6 +1019,35 @@ export async function getCostAnalysis(orgId: string, month: string) {
})
}
// 按部门拆分成本
const deptEntries = await prisma.batchEntry.findMany({
where: {
orgId,
batch: { month, status: 'ARCHIVED' },
},
include: { employee: { select: { department: true } } },
})
const deptMap: Record<string, { totalPay: number; socialOrg: number; housingOrg: number; headcount: number }> = {}
for (const e of deptEntries) {
const dept = e.employee?.department || '未分配'
if (!deptMap[dept]) deptMap[dept] = { totalPay: 0, socialOrg: 0, housingOrg: 0, headcount: 0 }
deptMap[dept].totalPay += e.totalPay
deptMap[dept].socialOrg += e.socialOrg
deptMap[dept].housingOrg += e.housingOrg
deptMap[dept].headcount += 1
}
const departmentCost = Object.entries(deptMap)
.map(([dept, v]) => ({
department: dept,
totalCost: v.totalPay + v.socialOrg + v.housingOrg,
totalPay: v.totalPay,
socialOrg: v.socialOrg,
housingOrg: v.housingOrg,
headcount: v.headcount,
perCapita: v.headcount > 0 ? (v.totalPay + v.socialOrg + v.housingOrg) / v.headcount : 0,
}))
.sort((a, b) => b.totalCost - a.totalCost)
return {
month,
current: {
@@ -1026,6 +1074,7 @@ export async function getCostAnalysis(orgId: string, month: string) {
changePercent: yoyChange,
},
factors,
departmentCost,
}
}
+13 -1
View File
@@ -738,13 +738,25 @@ export async function cancelTermination(orgId: string, recordId: string, userId:
}
/** 获取草稿列表 */
export async function getDrafts(orgId: string, status?: string) {
export async function getDrafts(orgId: string, status?: string, search?: string, department?: string) {
const where: any = { orgId }
if (status) {
where.status = status
} else {
where.status = { in: ['DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'REJECTED', 'EXECUTING', 'COMPLETED', 'CANCELLED'] }
}
if (department) {
where.employee = { department }
}
if (search) {
where.employee = {
...where.employee,
OR: [
{ name: { contains: search } },
{ department: { contains: search } },
],
}
}
const records = await prisma.terminationRecord.findMany({
where,
+121
View File
@@ -0,0 +1,121 @@
# 企业用工专家 — 2026年7月28日更新测试指导
> 部署地址:https://on.hr8ai.top/
> 更新日期:2026-07-28
---
## 一、功能调查问卷(新增)
### 功能说明
系统新增「功能调查问卷」模块,覆盖 **41 个页面、218 项功能**,用户可对每项功能进行打分(1-5 星)、标注有用性、填写备注。问卷支持搜索、分组折叠、暂存恢复。
### 入口位置
- **顶部导航栏右侧** — 点击 📋(剪贴板图标)打开问卷弹窗
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 登录系统,点击顶部导航栏 📋 图标 | 弹出功能调查问卷弹窗,显示分组列表 |
| 2 | 查看分组结构 | 按页面分组(如"设置页面"、"花名册"等),每组显示功能数量 |
| 3 | 展开某一分组 | 显示该页面下所有功能项,每项有名称、描述、星级评分、有用性选择、备注框 |
| 4 | 为某项功能打 4 星评分 | 星星高亮显示,评分即时记录 |
| 5 | 选择有用性为"有用" | 选项被选中记录 |
| 6 | 在备注框输入文字 | 文字正常输入 |
| 7 | 在搜索框输入关键词(如"合同") | 列表过滤显示包含关键词的功能项 |
| 8 | 点击「暂存」按钮 | 提示"暂存成功",显示保存时间 |
| 9 | 关闭弹窗后重新打开 | 自动恢复上次填写的评分、有用性、备注 |
| 10 | 点击「提交」按钮 | 问卷结果提交,提示成功 |
| 11 | 点击「重置」按钮 | 清空所有填写内容 |
### 重点关注
- 暂存数据存储在浏览器 `localStorage`,关闭浏览器不会丢失
- 每项功能显示了所属菜单路径(如"系统 > 设置"),方便定位功能位置
---
## 二、审计日志优化(改进)
### 功能说明
操作日志页面优化:补充关键操作详情字段,前端以中文可读格式展示,不再显示原始 JSON。
### 入口位置
- **侧边栏 → 系统 → 操作日志**(路径:`/audit`
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入「操作日志」页面 | 显示日志列表,顶部有操作类型分布统计 |
| 2 | 查看日志条目 | 每条日志显示:操作名称(中文)、实体类型(中文标签)、操作详情(可读格式)、时间、IP |
| 3 | 执行一次操作(如编辑员工、生成工资条等) | 返回操作日志页面,新日志出现在列表顶部 |
| 4 | 查看新日志的详情字段 | 显示如"员工: 张三 \| 部门: 技术部 \| 补偿金: ¥5000",而非原始 JSON |
| 5 | 使用筛选功能 — 按操作类型筛选 | 列表仅显示对应类型的日志 |
| 6 | 使用筛选功能 — 按实体类型筛选 | 列表仅显示对应实体的日志 |
| 7 | 使用筛选功能 — 按日期范围筛选 | 列表仅显示指定时间段的日志 |
| 8 | 点击「清除筛选」 | 恢复显示全部日志 |
### 重点关注
- 解聘相关操作详情是否完整(离职日期、补偿金、离职原因等)
- 日期字段显示为 `YYYY-MM-DD` 格式,非时间戳
- 补偿金字段显示 ¥ 符号前缀
---
## 三、问卷结果导出(新增)
### 功能说明
在设置页面的「数据导出」区域新增问卷结果分析导出功能,将功能调查问卷的填写结果导出为 Markdown 文档。
### 入口位置
- **侧边栏 → 系统 → 设置 → 数据导出 Tab**
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 先填写部分问卷内容并暂存(见第一节) | 问卷数据保存到浏览器 |
| 2 | 进入「设置」页面,切换到「数据导出」Tab | 页面底部显示"问卷结果分析导出"区域 |
| 3 | 点击「导出问卷结果(MD)」按钮 | 浏览器下载 `survey-results-YYYY-MM-DD.md` 文件 |
| 4 | 打开下载的 MD 文件 | 内容包含:每项功能的评分、有用性、备注,按页面分组展示 |
| 5 | 未填写问卷时点击导出 | 提示"暂无问卷数据" |
### 重点关注
- 导出文件为 Markdown 格式,可直接用编辑器或 Typora 打开
- 导出内容包含所有已填写的功能项,未填写的不包含
---
## 四、问卷菜单路径与 Tab 信息(改进)
### 功能说明
问卷中每项功能新增了菜单路径和 Tab 信息,方便用户在系统中定位对应功能。
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 打开功能调查问卷 | 展开任一分组 |
| 2 | 查看功能项描述 | 每项功能下方显示菜单路径,如"菜单:员工管理 > 花名册",部分还显示 Tab 信息 |
| 3 | 按菜单路径在系统中导航 | 能找到对应的功能页面和 Tab |
---
## 本次更新涉及的菜单入口汇总
| 功能 | 菜单路径 | 备注 |
|------|----------|------|
| 功能调查问卷 | 顶部导航栏 📋 图标 | 全局可用 |
| 操作日志 | 侧边栏 → 系统 → 操作日志 | 已有功能,本次优化展示 |
| 问卷结果导出 | 设置 → 数据导出 Tab | 新增导出按钮 |
---
## 浏览器缓存提示
更新后首次访问请 **强制刷新**Mac: `Cmd + Shift + R`Windows: `Ctrl + Shift + R`),确保加载最新前端代码。
---
*如有问题或反馈,请联系开发团队。*
+239
View File
@@ -0,0 +1,239 @@
# 企业用工专家 — 2026年7月29日更新测试指导
> 部署地址:https://on.hr8ai.top/
> 更新日期:2026-07-29
---
## 更新内容概览
| 序号 | 模块 | 更新类型 | 说明 |
|------|------|----------|------|
| 1 | 工作日历 | 新增 | 独立日历页面,支持月历视图、事件管理 |
| 2 | 考勤管理 | 重构 | 新增班次管理、排班、每日出勤、月度报表、休假记录 5 个 Tab |
| 3 | AI 顾问 | 新增 | 新增「人力报告」Tab,AI 自动生成结构化人力分析报告并支持 Word 导出 |
| 4 | 工作台总览 | 优化 | 新增员工分布统计(性别/年龄/学历/司龄),部门成本拆分,移除日历卡片 |
| 5 | 花名册 | 优化 | 新增部门筛选、合同状态筛选 |
| 6 | 合同管理 | 优化 | 新增部门筛选、合同状态筛选 |
| 7 | 解聘补偿 | 优化 | 新增状态筛选、部门筛选、关键词搜索 |
| 8 | 薪税管理 | 优化 | 新增工资表导入模板下载、银行代发文件 CSV 导出 |
| 9 | 社保公积金 | 优化 | 支持多公积金账户类型(基本/补充)显示 |
| 10 | 数据导出 | 优化 | 新增花名册导出、解聘记录导出,中文文件名编码修复 |
| 11 | 数据导入 | 优化 | 导入模板下载(员工/月度增减员/工资表),错误日志导出 |
---
## 一、工作日历(新增页面)
### 入口位置
- **侧边栏 → 工作台 → 工作日历**(路径:`/calendar`
### 功能说明
独立的月历页面,展示合同到期、试用期到期、离职解聘、入职周年等关键日期,支持自定义事件(会议、团建、培训、面试)。
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 点击侧边栏「工作日历」 | 进入日历页面,显示当月月历网格 |
| 2 | 查看月历网格 | 日期格内显示当日事件标签(如"合同到期"),高优先级红色圆点 |
| 3 | 点击「←」/「→」按钮 | 切换上/下个月 |
| 4 | 点击「今天」按钮 | 回到当前月份 |
| 5 | 点击类型筛选标签(如"合同到期") | 仅显示该类型事件 |
| 6 | 点击日历中某一天 | 弹出新建事件弹窗,日期已自动填入 |
| 7 | 填写标题、类型、优先级,点击「创建」 | 事件创建成功,日历刷新显示新事件 |
| 8 | 查看右侧事件列表 | 按日期排序列出本月所有事件 |
| 9 | 鼠标悬停自定义事件,点击删除图标 | 事件删除成功 |
---
## 二、考勤管理(重构升级)
### 入口位置
- **侧边栏 → 员工管理 → 考勤确认**(路径:`/attendance`
### 功能说明
原考勤确认页面升级为完整考勤管理模块,新增 5 个 Tab:班次管理、排班、每日出勤、月度报表、休假记录。
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入「考勤确认」页面 | 显示 6 个 Tab:考勤确认、班次管理、排班、每日出勤、月度报表、休假记录 |
| 2 | **考勤确认 Tab** — 选择月份,查看列表 | 显示员工考勤确认状态(待确认/已确认/有异议),支持按部门筛选 |
| 3 | 点击「确认」按钮 | 员工考勤状态变为已确认 |
| 4 | **班次管理 Tab** — 点击「新增班次」 | 弹出表单,可设置班次名称、上下班时间 |
| 5 | 创建班次后查看列表 | 新班次出现在列表中 |
| 6 | 编辑班次时间 | 班次信息更新成功 |
| 7 | 删除班次 | 班次从列表移除 |
| 8 | **排班 Tab** — 选择日期和员工,分配班次 | 排班记录创建成功 |
| 9 | **每日出勤 Tab** — 选择日期查询 | 显示当日所有员工出勤状态(正常/迟到/早退/缺勤/请假/出差) |
| 10 | **月度报表 Tab** — 选择月份 | 显示月度出勤统计汇总 |
| 11 | **休假记录 Tab** — 点击「新增休假」 | 弹出表单,可选择员工、休假类型(病假/事假/年假/产假/其他)、日期范围 |
| 12 | 创建休假记录后查看列表 | 休假记录显示在列表中,可删除 |
---
## 三、AI 人力报告(新增 Tab)
### 入口位置
- **侧边栏 → AI 辅助 → AI 顾问**(路径:`/ai-assistant`),切换到「人力报告」Tab
### 功能说明
AI 基于企业当前数据(员工人数、部门分布、薪资成本、合同状态等)自动生成结构化人力分析报告,支持导出为 Word 文档。
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入 AI 顾问页面,点击「人力报告」Tab | 显示人力报告生成界面 |
| 2 | 点击「生成报告」按钮 | 流式输出 AI 生成的人力分析报告(Markdown 格式) |
| 3 | 等待报告生成完成 | 报告包含:人员概况、部门分析、成本分析、风险提示等结构化内容 |
| 4 | 点击「导出 Word」按钮 | 浏览器下载 `人力分析报告_YYYY-MM-DD.docx` 文件 |
| 5 | 打开下载的 Word 文件 | 内容包含标题、段落、表格、列表,格式正确 |
---
## 四、工作台总览优化
### 入口位置
- **侧边栏 → 工作台 → 总览**(路径:`/`
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入总览页面 | 显示概览 Tab 内容 |
| 2 | 查看人力成本分析区域 | 显示环比/同比数据,下方新增**部门成本拆分**进度条(按部门展示工资/社保/公积金/人均) |
| 3 | 查看员工分布统计区域 | 显示 4 个饼图卡片:性别分布、年龄段分布、学历分布、司龄分布 |
| 4 | 检查性别分布饼图 | 显示男/女人数及图例 |
| 5 | 检查年龄段分布饼图 | 按年龄段(如 20-25、26-30、31-35 等)展示人数 |
| 6 | 检查学历分布饼图 | 按学历(如大专、本科、硕士等)展示人数 |
| 7 | 检查司龄分布饼图 | 按司龄段(如 <1年、1-3年、3-5年 等)展示人数 |
| 8 | 确认原"本月关键日期"日历卡片 | **已移除**,不再显示 |
---
## 五、花名册 / 合同管理 — 筛选优化
### 入口位置
- **侧边栏 → 员工管理 → 花名册**(路径:`/roster`
- 合同管理在花名册详情中
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入花名册页面 | 搜索栏右侧新增「全部部门」和「全部状态」下拉筛选 |
| 2 | 选择某个部门筛选 | 列表仅显示该部门员工 |
| 3 | 选择合同状态筛选(如"在职") | 列表仅显示对应合同状态的员工 |
| 4 | 同时使用搜索 + 部门 + 状态筛选 | 三种筛选条件叠加生效 |
| 5 | 点击清除筛选条件 | 恢复显示全部员工 |
---
## 六、解聘补偿 — 筛选优化
### 入口位置
- **侧边栏 → 员工管理 → 解聘补偿**(路径:`/termination`
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入解聘补偿页面 | 新增状态筛选、部门筛选、搜索框 |
| 2 | 按状态筛选(如"草稿"/"待审批"/"已执行" | 列表仅显示对应状态的记录 |
| 3 | 按部门筛选 | 列表仅显示该部门的解聘记录 |
| 4 | 在搜索框输入员工姓名 | 列表过滤显示匹配的记录 |
---
## 七、薪税管理 — 导出功能增强
### 入口位置
- **侧边栏 → 薪税社保 → 薪税管理**(路径:`/money`
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入薪税管理页面 | 工具栏新增「下载模板」和「银行代发文件」按钮 |
| 2 | 点击「下载模板」 | 浏览器下载 `工资表导入模板.xlsx` |
| 3 | 打开模板文件 | 包含姓名、部门、月工资等列标题 |
| 4 | 选择某批次,点击「银行代发文件」 | 浏览器下载 `银行代发文件-YYYY-MM-批次N.csv` |
| 5 | 打开 CSV 文件 | 包含银行账号、姓名、金额等代发信息 |
---
## 八、社保公积金 — 多账户支持
### 入口位置
- **侧边栏 → 薪税社保 → 社保公积金**(路径:`/social`
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入社保公积金页面,选择某城市 | 公积金配置区域显示当前生效的账户类型标签 |
| 2 | 查看多账户情况 | 若有基本公积金 + 补充公积金,分别显示蓝色/紫色标签及各自比例 |
| 3 | 查看单账户情况 | 仅显示基本公积金标签 |
---
## 九、数据导出 — 新增导出类型
### 入口位置
- **侧边栏 → 系统 → 设置 → 数据导出 Tab**
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入设置页面,切换到「数据导出」Tab | 导出选项中新增花名册导出、解聘记录导出 |
| 2 | 勾选「花名册」,点击导出 | 下载 Excel 文件,包含员工基本信息 |
| 3 | 勾选「解聘记录」,点击导出 | 下载 Excel 文件,包含解聘记录详情 |
| 4 | 检查导出文件名 | 中文文件名正常显示,无乱码 |
---
## 十、数据导入 — 模板下载与错误日志
### 入口位置
- **侧边栏 → 系统 → 设置 → 数据导入 Tab**
### 测试步骤
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 1 | 进入数据导入页面 | 显示可下载的模板类型 |
| 2 | 下载「员工导入模板」 | 下载 `员工导入模板.xlsx`,包含姓名/部门/身份证号/入职日期等列 |
| 3 | 下载「月度增减员导入模板」 | 下载 `月度增减员导入模板.xlsx` |
| 4 | 下载「工资表导入模板」 | 下载 `工资表导入模板.xlsx` |
| 5 | 上传含错误数据的文件 | 导入完成后提示错误,可下载错误日志 Excel |
---
## 本次更新涉及的菜单入口汇总
| 功能 | 菜单路径 | 备注 |
|------|----------|------|
| 工作日历 | 工作台 → 工作日历 | 新增页面 |
| 考勤管理 | 员工管理 → 考勤确认 | 重构,新增 5 个 Tab |
| AI 人力报告 | AI 辅助 → AI 顾问 → 人力报告 Tab | 新增 Tab |
| 员工分布统计 | 工作台 → 总览 | 新增 4 个分布图表 |
| 花名册筛选 | 员工管理 → 花名册 | 新增筛选下拉 |
| 解聘补偿筛选 | 员工管理 → 解聘补偿 | 新增筛选和搜索 |
| 薪税导出 | 薪税社保 → 薪税管理 | 新增模板下载和银行代发 |
| 社保多账户 | 薪税社保 → 社保公积金 | 显示多公积金账户 |
| 数据导出 | 系统 → 设置 → 数据导出 | 新增导出类型 |
| 数据导入 | 系统 → 设置 → 数据导入 | 新增模板下载 |
---
## 浏览器缓存提示
更新后首次访问请 **强制刷新**Mac: `Cmd + Shift + R`Windows: `Ctrl + Shift + R`),确保加载最新前端代码。
---
*如有问题或反馈,请联系开发团队。*
+2
View File
@@ -35,6 +35,7 @@ const AutoLogin = lazy(() => import('./pages/portal/AutoLogin'))
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator'))
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
const CalendarPage = lazy(() => import('./pages/Calendar'))
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
@@ -103,6 +104,7 @@ export default function App() {
<Route path="/evidence" element={<ProtectedRoute><AdminLayout><Evidence /></AdminLayout></ProtectedRoute>} />
<Route path="/policies" element={<ProtectedRoute><AdminLayout><Policies /></AdminLayout></ProtectedRoute>} />
<Route path="/attendance" element={<ProtectedRoute><AdminLayout><Attendance /></AdminLayout></ProtectedRoute>} />
<Route path="/calendar" element={<ProtectedRoute><AdminLayout><CalendarPage /></AdminLayout></ProtectedRoute>} />
<Route path="/templates" element={<ProtectedRoute><AdminLayout><Templates /></AdminLayout></ProtectedRoute>} />
<Route path="/audit" element={<ProtectedRoute><AdminLayout><AuditLog /></AdminLayout></ProtectedRoute>} />
<Route path="/notifications" element={<ProtectedRoute><AdminLayout><Notifications /></AdminLayout></ProtectedRoute>} />
@@ -13,7 +13,7 @@ import {
Bot, BookMarked,
Bell, ScrollText, Settings,
ChevronDown, ChevronRight,
Building2,
Building2, CalendarDays,
} from 'lucide-react'
import Logo from '../ui/Logo'
@@ -33,6 +33,7 @@ const navGroups: NavGroup[] = [
title: '工作台',
items: [
{ path: '/', label: '总览', icon: LayoutDashboard },
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
],
},
{
+1 -1
View File
@@ -16,7 +16,7 @@ export default function Pagination({
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
pageSizeOptions = [10, 20, 50, 100, 200],
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
+323 -2
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History, Database, User, AlertTriangle, FileText, Shield, Download } from 'lucide-react'
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History, Database, User, AlertTriangle, FileText, Shield, Download, TrendingUp, UserCheck, Phone } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
@@ -14,7 +14,89 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge'
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report'
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
function parseInlineBold(text: string): TextRun[] {
const runs: TextRun[] = []
const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g
let lastIndex = 0
let match
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
runs.push(new TextRun({ text: text.slice(lastIndex, match.index) }))
}
if (match[2]) {
runs.push(new TextRun({ text: match[2], bold: true }))
} else if (match[3]) {
runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 }))
}
lastIndex = regex.lastIndex
}
if (lastIndex < text.length) {
runs.push(new TextRun({ text: text.slice(lastIndex) }))
}
return runs.length ? runs : [new TextRun({ text })]
}
/** 导出 Markdown 文本为 Word 文档 */
async function exportMarkdownToWord(markdown: string, fileName: string) {
const lines = markdown.split('\n')
const children: (Paragraph | Table)[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (!line.trim()) { i++; continue }
if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) {
const headerCells = line.split('|').map(c => c.trim()).filter(Boolean)
i += 2
const rows: TableRow[] = []
rows.push(new TableRow({
children: headerCells.map(text => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })],
shading: { fill: 'F3F4F6' },
})),
}))
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean)
rows.push(new TableRow({
children: cells.map(text => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text })] })],
})),
}))
i++
}
children.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } }))
continue
}
if (line.startsWith('### ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] }))
} else if (line.startsWith('## ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] }))
} else if (line.startsWith('# ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] }))
} else if (line.startsWith('> ')) {
children.push(new Paragraph({ children: [new TextRun({ text: line.slice(2), italics: true })], indent: { left: 720 } }))
} else if (line.startsWith('- ') || line.startsWith('* ')) {
children.push(new Paragraph({ children: parseInlineBold(line.slice(2)), bullet: { level: 0 } }))
} else if (/^\d+\.\s/.test(line)) {
children.push(new Paragraph({ children: parseInlineBold(line.replace(/^\d+\.\s/, '')), numbering: { reference: 'default-numbering', level: 0 } }))
} else if (line === '---' || line === '***') {
children.push(new Paragraph({ children: [], border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } } }))
} else {
children.push(new Paragraph({ children: parseInlineBold(line) }))
}
i++
}
const doc = new Document({
numbering: { config: [{ reference: 'default-numbering', levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }] }] },
sections: [{ children }],
})
const blob = await Packer.toBlob(doc)
saveAs(blob, fileName)
}
// 通用 AI 历史记录 hook
function useAIHistory(type: 'predict' | 'review' | 'case') {
@@ -94,6 +176,7 @@ export default function AIAssistant() {
{ key: 'predict', label: '风险预测', icon: Sparkles },
{ key: 'review', label: '合同审查', icon: FileSearch },
{ key: 'case', label: '案例匹配', icon: Scale },
{ key: 'hr-report', label: '人力报告', icon: TrendingUp },
{ key: 'knowledge', label: '知识库', icon: BookOpen },
]
@@ -129,6 +212,7 @@ export default function AIAssistant() {
{tab === 'predict' && <PredictTab />}
{tab === 'review' && <ReviewTab />}
{tab === 'case' && <CaseTab />}
{tab === 'hr-report' && <HRReportTab />}
{tab === 'knowledge' && <KnowledgeTab />}
</div>
)
@@ -144,6 +228,8 @@ function ChatTab() {
const [recording, setRecording] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [currentConvId, setCurrentConvId] = useState<string | null>(null)
const [showConsultModal, setShowConsultModal] = useState(false)
const [consultForm, setConsultForm] = useState({ type: 'LEGAL' as string, title: '', description: '', contactName: '', contactPhone: '', remark: '' })
const scrollRef = useRef<HTMLDivElement>(null)
const recognitionRef = useRef<any>(null)
const saveTimerRef = useRef<any>(null)
@@ -161,6 +247,21 @@ function ChatTab() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
})
const consultMutation = useMutation({
mutationFn: async (data: typeof consultForm) => {
const res = await api.post('/ai/consultation', data) as any
return res.data
},
onSuccess: () => {
toast.success('已提交咨询请求,专业律师将尽快与您联系')
setShowConsultModal(false)
setConsultForm({ type: 'LEGAL', title: '', description: '', contactName: '', contactPhone: '', remark: '' })
},
onError: (err: any) => {
toast.error(err?.message || '提交失败,请稍后重试')
},
})
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}, [messages])
@@ -331,6 +432,7 @@ function ChatTab() {
<div className="flex items-center gap-2 pb-2 border-b">
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowHistory(!showHistory)}><MessageSquare className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowConsultModal(true)}><UserCheck className="w-4 h-4 mr-1" /></Button>
{conversations && conversations.length > 0 && (
<span className="text-xs text-gray-400">{conversations.length} </span>
)}
@@ -422,6 +524,66 @@ function ChatTab() {
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</Button>
</div>
{/* 转人工咨询 Modal */}
{showConsultModal && (
<Modal open={true} title="联系专业律师" onClose={() => setShowConsultModal(false)}>
<div className="space-y-3">
<div className="rounded-md bg-blue-50 border border-blue-200 p-3 text-xs text-blue-700">
<p className="font-medium mb-1"></p>
<p>· <strong></strong>线</p>
<p>· <strong></strong></p>
<p>· <strong></strong></p>
<p className="mt-1"> 24 </p>
</div>
<div>
<Label></Label>
<Select value={consultForm.type} onChange={(e) => setConsultForm({ ...consultForm, type: e.target.value })}>
<option value="LEGAL"></option>
<option value="ARBITRATION"></option>
<option value="COURT"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={consultForm.title} onChange={(e) => setConsultForm({ ...consultForm, title: e.target.value })} placeholder="简要描述您的问题" />
</div>
<div>
<Label></Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm min-h-[80px] resize-y"
value={consultForm.description}
onChange={(e) => setConsultForm({ ...consultForm, description: e.target.value })}
placeholder="请详细描述您遇到的法律问题、涉及的员工情况等"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={consultForm.contactName} onChange={(e) => setConsultForm({ ...consultForm, contactName: e.target.value })} placeholder="您的姓名" />
</div>
<div>
<Label></Label>
<Input value={consultForm.contactPhone} onChange={(e) => setConsultForm({ ...consultForm, contactPhone: e.target.value })} placeholder="手机号码" maxLength={11} />
</div>
</div>
<div>
<Label></Label>
<Input value={consultForm.remark} onChange={(e) => setConsultForm({ ...consultForm, remark: e.target.value })} placeholder="其他需要说明的信息" />
</div>
<div className="flex gap-2 justify-end pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowConsultModal(false)}></Button>
<Button
size="sm"
onClick={() => consultMutation.mutate(consultForm)}
disabled={consultMutation.isPending || !consultForm.title || !consultForm.description || !consultForm.contactName || !consultForm.contactPhone}
>
{consultMutation.isPending ? '提交中...' : '提交咨询'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
@@ -1672,3 +1834,162 @@ function KnowledgeTab() {
</div>
)
}
function HRReportTab() {
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const handleGenerate = async () => {
if (loading) return
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
setResult('')
try {
const token = useAuthStore.getState().accessToken
const url = import.meta.env.DEV
? `http://localhost:3000/api/v1/ai/hr-report-stream`
: `/api/v1/ai/hr-report-stream`
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
})
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error?.message || '请求失败')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let accumulated = ''
let buffer = ''
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.delta) {
accumulated += parsed.delta
setResult(accumulated)
}
if (parsed.error) {
throw new Error(parsed.error)
}
} catch (parseErr: any) {
if (parseErr instanceof SyntaxError) continue
throw parseErr
}
}
}
}
setResult(accumulated)
}
} catch (err: any) {
if (err.name !== 'AbortError') {
toast.error(err.message || '生成报告失败')
}
} finally {
setLoading(false)
}
}
const handleExport = async () => {
if (!result) return
try {
await exportMarkdownToWord(result, `人力分析报告_${new Date().toISOString().slice(0, 10)}.docx`)
toast.success('Word 文档已导出')
} catch {
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 flex items-center gap-1.5">
<TrendingUp className="w-4 h-4 text-primary" />
AI
</h2>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
<div className="flex items-center gap-2">
{result && !loading && (
<button
onClick={handleExport}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
>
<Download className="w-3.5 h-3.5" />
Word
</button>
)}
<Button size="sm" onClick={handleGenerate} disabled={loading}>
{loading ? (
<><Loader2 className="w-4 h-4 mr-1 animate-spin" />...</>
) : (
<><Sparkles className="w-4 h-4 mr-1" /></>
)}
</Button>
</div>
</div>
{!result && !loading && (
<div className="text-center py-12 text-gray-400">
<TrendingUp className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm">"生成报告"AI </p>
</div>
)}
{loading && !result && (
<div className="text-center py-12">
<Loader2 className="w-8 h-8 mx-auto mb-3 text-primary animate-spin" />
<p className="text-sm text-gray-500">AI ...</p>
</div>
)}
{result && (
<div className="prose prose-sm max-w-none
prose-headings:text-gray-800 prose-headings:font-semibold
prose-h1:text-lg prose-h1:border-b prose-h1:pb-2 prose-h1:border-gray-200
prose-h2:text-base prose-h2:mt-4
prose-h3:text-sm prose-h3:mt-3
prose-p:text-gray-600 prose-p:leading-relaxed
prose-li:text-gray-600 prose-li:leading-relaxed
prose-strong:text-gray-800
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
prose-table:text-xs prose-table:border-collapse
prose-th:bg-gray-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{result}
</ReactMarkdown>
</div>
)}
</Card>
</div>
)
}
+674 -16
View File
@@ -1,10 +1,12 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Upload } from 'lucide-react'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane } from 'lucide-react'
import api from '../lib/api'
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 EmptyState from '../components/ui/EmptyState'
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
@@ -13,17 +15,88 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string;
DISPUTED: { label: '有异议', color: 'text-red-700', bg: 'bg-red-100', icon: AlertCircle },
}
/**
* 考勤确认管理页面
*/
const ATTENDANCE_STATUS: Record<string, string> = {
NORMAL: '正常',
LATE: '迟到',
EARLY_LEAVE: '早退',
ABSENT: '缺勤',
LEAVE: '请假',
BUSINESS_TRIP: '出差',
UNREGISTERED: '未打卡',
}
const LEAVE_TYPES: Record<string, string> = {
SICK: '病假',
PERSONAL: '事假',
ANNUAL: '年假',
MATERNITY: '产假',
OTHER: '其他',
}
const TABS = [
{ key: 'confirm', label: '考勤确认', icon: CalendarCheck },
{ key: 'shifts', label: '班次管理', icon: Clock },
{ key: 'schedule', label: '排班', icon: Calendar },
{ key: 'daily', label: '每日出勤', icon: Users },
{ key: 'monthly', label: '月度报表', icon: BarChart3 },
{ key: 'leaves', label: '休假记录', icon: Plane },
]
export default function Attendance() {
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState('confirm')
return (
<div className="space-y-4">
<div>
<div className="flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
{/* Tab 导航 */}
<div className="flex flex-wrap gap-1 border-b border-gray-200">
{TABS.map(tab => {
const Icon = tab.icon
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
)
})}
</div>
{activeTab === 'confirm' && <ConfirmTab />}
{activeTab === 'shifts' && <ShiftsTab />}
{activeTab === 'schedule' && <ScheduleTab />}
{activeTab === 'daily' && <DailyTab />}
{activeTab === 'monthly' && <MonthlyTab />}
{activeTab === 'leaves' && <LeavesTab />}
</div>
)
}
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [filterDepartment, setFilterDepartment] = useState('')
const { data: list, isLoading } = useQuery<any>({
queryKey: ['attendance', month],
queryKey: ['attendance', month, filterDepartment],
queryFn: async () => {
const res = await api.get(`/attendance?month=${month}`) as any
const params: any = { month }
if (filterDepartment) params.department = filterDepartment
const res = await api.get('/attendance', { params }) as any
return res.data
},
})
@@ -36,16 +109,25 @@ export default function Attendance() {
},
})
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div className="flex items-center gap-2 justify-end">
<select
value={filterDepartment}
onChange={e => setFilterDepartment(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>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<input
type="month"
value={month}
@@ -54,7 +136,6 @@ export default function Attendance() {
/>
</div>
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
@@ -117,3 +198,580 @@ export default function Attendance() {
</div>
)
}
// ========== 班次管理 Tab ==========
function ShiftsTab() {
const queryClient = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [editShift, setEditShift] = useState<any>(null)
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
const { data: shifts, isLoading } = useQuery<any>({
queryKey: ['shifts'],
queryFn: async () => {
const res = await api.get('/attendance/shifts') as any
return res.data
},
})
const saveMutation = useMutation({
mutationFn: async (data: any) => {
if (editShift) {
return api.put(`/attendance/shifts/${editShift.id}`, data)
}
return api.post('/attendance/shifts', data)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shifts'] })
setShowAdd(false)
setEditShift(null)
setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/shifts/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shifts'] }),
})
const handleSubmit = () => {
if (!form.name.trim()) return toast.error('请输入班次名称')
saveMutation.mutate(form)
}
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !shifts || shifts.length === 0 ? (
<EmptyState title="暂无班次" description="请先创建班次" />
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{shifts.map((s: any) => (
<Card key={s.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ background: s.color }} />
<span className="font-medium text-sm">{s.name}</span>
</div>
<div className="flex gap-1">
<button className="text-xs text-gray-400 hover:text-primary px-1" onClick={() => { setEditShift(s); setForm(s); setShowAdd(true) }}></button>
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(s.id) }}></button>
</div>
</div>
<div className="mt-2 text-xs text-gray-500 space-y-0.5">
<div>{s.startTime} {s.endTime}</div>
<div>{s.flexibleMinutes} {s.restMinutes} </div>
</div>
</Card>
))}
</div>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title={editShift ? '编辑班次' : '新增班次'}>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="如:早班、白班、夜班" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="time" value={form.startTime} onChange={e => setForm({ ...form, startTime: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="time" value={form.endTime} onChange={e => setForm({ ...form, endTime: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.flexibleMinutes} onChange={e => setForm({ ...form, flexibleMinutes: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.restMinutes} onChange={e => setForm({ ...form, restMinutes: Number(e.target.value) })} />
</div>
</div>
<div>
<Label></Label>
<input type="color" value={form.color} onChange={e => setForm({ ...form, color: e.target.value })} className="h-9 w-16 rounded border border-gray-200" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAdd(false)}></Button>
<Button onClick={handleSubmit} disabled={saveMutation.isPending}>{saveMutation.isPending ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
</div>
)
}
// ========== 排班 Tab ==========
function ScheduleTab() {
const queryClient = useQueryClient()
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [showAssign, setShowAssign] = useState(false)
const [selectedShiftId, setSelectedShiftId] = useState('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
const { data: shifts } = useQuery<any>({
queryKey: ['shifts'],
queryFn: async () => {
const res = await api.get('/attendance/shifts') as any
return res.data
},
})
const { data: assignments, isLoading } = useQuery<any>({
queryKey: ['shift-assignments', date],
queryFn: async () => {
const res = await api.get(`/attendance/shift-assignments?date=${date}`) as any
return res.data
},
})
const { data: dailyData } = useQuery<any>({
queryKey: ['daily-attendance', date],
queryFn: async () => {
const res = await api.get(`/attendance/daily?date=${date}`) as any
return res.data
},
})
const batchAssignMutation = useMutation({
mutationFn: (items: any[]) => api.post('/attendance/shift-assignments/batch', { items }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
setShowAssign(false)
setSelectedEmployeeIds(new Set())
setSelectedShiftId('')
toast.success('排班成功')
},
})
const deleteAssignmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/shift-assignments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
},
})
const handleBatchAssign = () => {
if (!selectedShiftId) return toast.error('请选择班次')
if (selectedEmployeeIds.size === 0) return toast.error('请选择员工')
const items = Array.from(selectedEmployeeIds).map(empId => ({ employeeId: empId, shiftId: selectedShiftId, date }))
batchAssignMutation.mutate(items)
}
const employees = dailyData || []
const assignmentMap: Map<string, any> = new Map((assignments || []).map((a: any) => [a.employeeId, a]))
const toggleEmployee = (id: string) => {
const next = new Set(selectedEmployeeIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setSelectedEmployeeIds(next)
}
return (
<div className="space-y-3">
<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"
/>
<Button onClick={() => setShowAssign(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : employees.length === 0 ? (
<EmptyState title="暂无员工" description="没有可排班的员工" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full 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"></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>
</tr>
</thead>
<tbody>
{employees.map((emp: any) => {
const assignment = assignmentMap.get(emp.employeeId)
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">
{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 }}>
<div className="w-2 h-2 rounded-full" style={{ background: (assignment.shift as any)?.color }} />
{(assignment.shift as any)?.name} {(assignment.shift as any)?.startTime}-{(assignment.shift as any)?.endTime}
</span>
) : (
<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>
</tr>
)
})}
</tbody>
</table>
</Card>
)}
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="批量排班">
<div className="space-y-3">
<div>
<Label></Label>
<Select value={selectedShiftId} onChange={e => setSelectedShiftId(e.target.value)}>
<option value=""></option>
{(shifts || []).map((s: any) => (
<option key={s.id} value={s.id}>{s.name} ({s.startTime}-{s.endTime})</option>
))}
</Select>
</div>
<div>
<Label>{selectedEmployeeIds.size} </Label>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{employees.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>
<span className="text-xs text-gray-400">{emp.department}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAssign(false)}></Button>
<Button onClick={handleBatchAssign} disabled={batchAssignMutation.isPending}>{batchAssignMutation.isPending ? '排班中...' : '确认排班'}</Button>
</div>
</div>
</Modal>
</div>
)
}
// ========== 每日出勤 Tab ==========
function DailyTab() {
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const { data, isLoading } = useQuery<any>({
queryKey: ['daily-attendance', date],
queryFn: async () => {
const res = await api.get(`/attendance/daily?date=${date}`) as any
return res.data
},
})
const statusColors: Record<string, string> = {
NORMAL: 'bg-green-50 text-green-700',
LATE: 'bg-amber-50 text-amber-700',
EARLY_LEAVE: 'bg-orange-50 text-orange-700',
ABSENT: 'bg-red-50 text-red-700',
LEAVE: 'bg-blue-50 text-blue-700',
BUSINESS_TRIP: 'bg-purple-50 text-purple-700',
UNREGISTERED: 'bg-gray-100 text-gray-500',
}
return (
<div className="space-y-3">
<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>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
<EmptyState title="暂无员工" description="没有出勤数据" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full 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"></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="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody>
{data.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">
<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>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
)
}
// ========== 月度报表 Tab ==========
function MonthlyTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const { data, isLoading } = useQuery<any>({
queryKey: ['monthly-report', month],
queryFn: async () => {
const res = await api.get(`/attendance/monthly-report?month=${month}`) as any
return res.data
},
})
const handleExport = () => {
if (!data || data.length === 0) return
const headers = ['姓名', '部门', '出勤天数', '迟到次数', '早退次数', '缺勤天数', '请假天数', '加班工时', '加班费', '确认状态']
const rows = data.map((r: any) => [
r.name, r.department, r.workDays, r.lateCount, r.earlyLeaveCount, r.absentDays, r.leaveDays,
r.overtimeHours, r.overtimePay, r.confirmationStatus === 'CONFIRMED' ? '已确认' : r.confirmationStatus === 'PENDING' ? '待确认' : r.confirmationStatus === 'DISPUTED' ? '有异议' : '未创建',
])
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 = `attendance-report-${month}.csv`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="space-y-3">
<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"
/>
<Button variant="secondary" onClick={handleExport} disabled={!data || data.length === 0}>
<BarChart3 className="w-4 h-4 mr-1" /> CSV
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
<EmptyState title="暂无报表数据" description="该月份没有出勤数据" />
) : (
<Card className="overflow-hidden p-0">
<table className="w-full 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"></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"></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">(h)</th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.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>
<td className="px-4 py-3 text-center">{r.workDays}</td>
<td className="px-4 py-3 text-center">{r.lateCount > 0 ? <span className="text-amber-600">{r.lateCount}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.earlyLeaveCount > 0 ? <span className="text-orange-600">{r.earlyLeaveCount}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.absentDays > 0 ? <span className="text-red-600">{r.absentDays}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.leaveDays > 0 ? <span className="text-blue-600">{r.leaveDays}</span> : '0'}</td>
<td className="px-4 py-3 text-center">{r.overtimeHours > 0 ? r.overtimeHours.toFixed(1) : '—'}</td>
<td className="px-4 py-3 text-right">{r.overtimePay > 0 ? `¥${r.overtimePay.toFixed(2)}` : '—'}</td>
<td className="px-4 py-3 text-center">
{r.confirmationStatus === 'CONFIRMED' ? <span className="text-xs text-green-600"></span>
: r.confirmationStatus === 'PENDING' ? <span className="text-xs text-amber-600"></span>
: r.confirmationStatus === 'DISPUTED' ? <span className="text-xs text-red-600"></span>
: <span className="text-xs text-gray-400"></span>}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
)
}
// ========== 休假记录 Tab ==========
function LeavesTab() {
const queryClient = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
const { data: leaves, isLoading } = useQuery<any>({
queryKey: ['leave-records'],
queryFn: async () => {
const res = await api.get('/attendance/leaves') as any
return res.data
},
})
const { data: rosterData } = useQuery<any>({
queryKey: ['roster-employees'],
queryFn: async () => {
const res = await api.get('/roster?pageSize=200') as any
return res.data
},
})
const createMutation = useMutation({
mutationFn: (data: any) => api.post('/attendance/leaves', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-records'] })
setShowAdd(false)
setForm({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
toast.success('休假记录已添加')
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attendance/leaves/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['leave-records'] }),
})
const handleSubmit = () => {
if (!form.employeeId) return toast.error('请选择员工')
if (!form.startDate || !form.endDate) return toast.error('请选择日期')
createMutation.mutate(form)
}
const employees = rosterData || []
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !leaves || leaves.length === 0 ? (
<EmptyState title="暂无休假记录" description="点击右上角添加休假记录" />
) : (
<div className="space-y-2">
{leaves.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">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 flex-shrink-0">
<Plane className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{lv.employee?.name}</span>
<span className="text-xs text-gray-500">{lv.employee?.department}</span>
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-700">{LEAVE_TYPES[lv.leaveType] || lv.leaveType}</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
{lv.startDate?.toString().slice(0, 10)} ~ {lv.endDate?.toString().slice(0, 10)}{lv.days}
{lv.reason && <span className="ml-2">{lv.reason}</span>}
</div>
</div>
</div>
<button className="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(lv.id) }}>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</Card>
))}
</div>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title="新增休假记录">
<div className="space-y-3">
<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>
<Select value={form.leaveType} onChange={e => setForm({ ...form, leaveType: e.target.value })}>
{Object.entries(LEAVE_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.startDate} onChange={e => setForm({ ...form, startDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={e => setForm({ ...form, endDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input type="number" step="0.5" value={form.days} onChange={e => setForm({ ...form, days: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={e => setForm({ ...form, reason: e.target.value })} placeholder="请简述请假原因" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAdd(false)}></Button>
<Button onClick={handleSubmit} disabled={createMutation.isPending}>{createMutation.isPending ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
</div>
)
}
+391
View File
@@ -0,0 +1,391 @@
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, MapPin, User } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
const EVENT_TYPE_COLORS: Record<string, string> = {
CONTRACT_EXPIRY: 'bg-red-100 text-red-700 border-red-200',
PROBATION_END: 'bg-amber-100 text-amber-700 border-amber-200',
TERMINATION: 'bg-red-100 text-red-700 border-red-200',
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',
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',
TRAINING: 'bg-indigo-100 text-indigo-700 border-indigo-200',
INTERVIEW: 'bg-teal-100 text-teal-700 border-teal-200',
}
const EVENT_TYPE_LABELS: Record<string, string> = {
CONTRACT_EXPIRY: '合同到期',
PROBATION_END: '试用期到期',
TERMINATION: '离职/解聘',
ANNIVERSARY: '入职周年',
RISK_DEADLINE: '风险截止',
RETIREMENT: '退休',
CUSTOM: '自定义',
MEETING: '会议',
TEAM_BUILDING: '团建',
TRAINING: '培训',
INTERVIEW: '面试',
}
const PRIORITY_DOT: Record<string, string> = {
high: 'bg-red-500',
medium: 'bg-amber-500',
low: 'bg-gray-400',
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
export default function Calendar() {
const queryClient = useQueryClient()
const [calendarMonth, setCalendarMonth] = useState(new Date().toISOString().slice(0, 7))
const [showEventForm, setShowEventForm] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<string | null>(null)
const [eventForm, setEventForm] = useState({
title: '',
date: new Date().toISOString().slice(0, 10),
type: 'CUSTOM',
priority: 'medium',
location: '',
description: '',
})
const { data: calendarData } = useQuery<any>({
queryKey: ['calendar', calendarMonth],
queryFn: async () => {
const res = await api.get(`/dashboard/calendar?month=${calendarMonth}`) as any
return res.data
},
})
const { data: customEvents } = useQuery<any[]>({
queryKey: ['custom-events', calendarMonth],
queryFn: async () => {
const res = await api.get(`/calendar?month=${calendarMonth}`) as any
return res.data
},
})
const createEventMutation = useMutation({
mutationFn: (data: any) => api.post('/calendar', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-events'] })
queryClient.invalidateQueries({ queryKey: ['calendar'] })
setShowEventForm(false)
setEventForm({ title: '', date: selectedDate || new Date().toISOString().slice(0, 10), type: 'CUSTOM', priority: 'medium', location: '', description: '' })
toast.success('事件已创建')
},
onError: () => toast.error('创建事件失败'),
})
const deleteEventMutation = useMutation({
mutationFn: (id: string) => api.delete(`/calendar/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-events'] })
queryClient.invalidateQueries({ queryKey: ['calendar'] })
toast.success('事件已删除')
},
})
const calendarGrid = useMemo(() => {
const [year, mon] = calendarMonth.split('-').map(Number)
const firstDay = new Date(year, mon - 1, 1)
const lastDay = new Date(year, mon, 0)
const startWeekday = firstDay.getDay()
const daysInMonth = lastDay.getDate()
const todayStr = new Date().toISOString().slice(0, 10)
const cells: Array<{ day: number | null; date: string | null; events: any[]; isToday: boolean }> = []
for (let i = 0; i < startWeekday; i++) cells.push({ day: null, date: null, events: [], isToday: false })
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${calendarMonth}-${String(d).padStart(2, '0')}`
let dayEvents = (calendarData?.events || []).filter((ev: any) => ev.date === dateStr)
if (typeFilter) dayEvents = dayEvents.filter((ev: any) => ev.type === typeFilter)
cells.push({ day: d, date: dateStr, events: dayEvents, isToday: dateStr === todayStr })
}
return cells
}, [calendarMonth, calendarData, typeFilter])
const allEvents = useMemo(() => {
let events = calendarData?.events || []
if (typeFilter) events = events.filter((ev: any) => ev.type === typeFilter)
return events
}, [calendarData, typeFilter])
const customEventMap = useMemo(() => {
const map: Record<string, any> = {}
for (const ev of (customEvents || [])) {
map[ev.id] = ev
}
return map
}, [customEvents])
const prevMonth = () => {
const [y, m] = calendarMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
setCalendarMonth(d.toISOString().slice(0, 7))
}
const nextMonth = () => {
const [y, m] = calendarMonth.split('-').map(Number)
const d = new Date(y, m, 1)
setCalendarMonth(d.toISOString().slice(0, 7))
}
const goToday = () => setCalendarMonth(new Date().toISOString().slice(0, 7))
const handleDayClick = (date: string | null) => {
if (!date) return
setSelectedDate(date)
setEventForm({ ...eventForm, date })
setShowEventForm(true)
}
const handleSubmitEvent = () => {
if (!eventForm.title.trim()) {
toast.error('请输入事件标题')
return
}
createEventMutation.mutate(eventForm)
}
const isCustomEvent = (ev: any) => {
return !!customEventMap[ev.id] || ['CUSTOM', 'MEETING', 'TEAM_BUILDING', 'TRAINING', 'INTERVIEW'].includes(ev.type)
}
return (
<div className="space-y-3">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold flex items-center gap-2">
<CalendarDays className="w-5 h-5 text-primary" />
</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={prevMonth}>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium min-w-[80px] text-center">{calendarMonth}</span>
<Button variant="secondary" size="sm" onClick={nextMonth}>
<ChevronRight className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={goToday}></Button>
<Button size="sm" onClick={() => { setEventForm({ ...eventForm, date: new Date().toISOString().slice(0, 10) }); setShowEventForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 类型筛选 */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setTypeFilter(null)}
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${!typeFilter ? 'bg-gray-700 text-white border-gray-700' : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`}
>
</button>
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
<button
key={type}
onClick={() => setTypeFilter(typeFilter === type ? null : type)}
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${typeFilter === type ? 'bg-gray-700 text-white border-gray-700' : EVENT_TYPE_COLORS[type] || 'bg-gray-100 text-gray-600 border-gray-200'}`}
>
{label}
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
{/* 月历网格 */}
<div className="lg:col-span-2">
<Card>
<div className="grid grid-cols-7 gap-px mb-1">
{WEEKDAYS.map(wd => (
<div key={wd} className="text-center text-xs font-medium text-gray-400 py-1.5">{wd}</div>
))}
</div>
<div className="grid grid-cols-7 gap-px">
{calendarGrid.map((cell, i) => (
<div
key={i}
onClick={() => handleDayClick(cell.date)}
className={`min-h-[72px] p-1.5 rounded-md cursor-pointer transition-colors border ${
cell.day === null
? 'bg-gray-50/50 border-transparent cursor-default'
: cell.isToday
? 'bg-primary/5 border-primary/30 hover:bg-primary/10'
: 'border-gray-100 hover:bg-gray-50'
}`}
>
{cell.day && (
<>
<div className={`text-xs font-medium mb-0.5 ${cell.isToday ? 'text-primary' : 'text-gray-600'}`}>
{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.length > 3 && (
<div className="text-[10px] text-gray-400 px-1">+{cell.events.length - 3} </div>
)}
</div>
</>
)}
</div>
))}
</div>
</Card>
</div>
{/* 事件列表 */}
<div>
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
<CalendarDays className="w-4 h-4 text-primary" />
{calendarMonth}
<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>
<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.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>
) : (
<div className="text-xs text-gray-500 text-center py-8"></div>
)}
</Card>
</div>
</div>
{/* 新建事件弹窗 */}
{showEventForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowEventForm(false)}>
<Card className="w-full max-w-md mx-4" onClick={(e: any) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium flex items-center gap-1.5">
<Plus className="w-4 h-4 text-primary" />
</h3>
<button onClick={() => setShowEventForm(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={eventForm.title}
onChange={(e) => setEventForm({ ...eventForm, title: e.target.value })}
placeholder="如:月度全员会议"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input
type="date"
value={eventForm.date}
onChange={(e) => setEventForm({ ...eventForm, date: e.target.value })}
/>
</div>
<div>
<Label></Label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm"
value={eventForm.type}
onChange={(e) => setEventForm({ ...eventForm, type: e.target.value })}
>
<option value="CUSTOM"></option>
<option value="MEETING"></option>
<option value="TEAM_BUILDING"></option>
<option value="TRAINING"></option>
<option value="INTERVIEW"></option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm"
value={eventForm.priority}
onChange={(e) => setEventForm({ ...eventForm, priority: e.target.value })}
>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
</div>
<div>
<Label></Label>
<Input
value={eventForm.location}
onChange={(e) => setEventForm({ ...eventForm, location: e.target.value })}
placeholder="可选"
/>
</div>
</div>
<div>
<Label></Label>
<Input
value={eventForm.description}
onChange={(e) => setEventForm({ ...eventForm, description: e.target.value })}
placeholder="可选"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowEventForm(false)}></Button>
<Button size="sm" onClick={handleSubmitEvent} disabled={createEventMutation.isPending}>
{createEventMutation.isPending ? '创建中...' : '创建'}
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
)
}
+42 -5
View File
@@ -34,15 +34,20 @@ interface EmployeeListResponse {
export default function Contracts() {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const [page, setPage] = useState(1)
const [showAddModal, setShowAddModal] = useState(false)
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
const { data, isLoading } = useQuery<EmployeeListResponse>({
queryKey: ['employees', search, page],
queryKey: ['employees', search, filterDepartment, filterContractStatus, page],
queryFn: async () => {
const res = await api.get('/employees', { params: { search, page, pageSize: 20 } }) as any
return res.data
const params: any = { search, page, pageSize: 20 }
if (filterDepartment) params.department = filterDepartment
if (filterContractStatus) params.contractStatus = filterContractStatus
const res = await api.get('/roster', { params }) as any
return res
},
})
@@ -57,6 +62,14 @@ export default function Contracts() {
},
})
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -73,8 +86,8 @@ export default function Contracts() {
</div>
{/* 搜索栏 */}
<div className="flex gap-2">
<div className="relative flex-1">
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="搜索员工姓名或手机号"
@@ -83,6 +96,30 @@ export default function Contracts() {
className="pl-9"
/>
</div>
<select
value={filterDepartment}
onChange={(e) => { setFilterDepartment(e.target.value); setPage(1) }}
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>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<select
value={filterContractStatus}
onChange={(e) => { setFilterContractStatus(e.target.value); setPage(1) }}
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>
<option value="active"></option>
<option value="expiring"></option>
<option value="expired"></option>
<option value="unsigned"></option>
<option value="unsigned_over_30">(30)</option>
<option value="unsigned_over_year">()</option>
</select>
{(search || filterDepartment || filterContractStatus) && (
<button onClick={() => { setSearch(''); setFilterDepartment(''); setFilterContractStatus(''); setPage(1) }} className="text-xs text-gray-500 hover:text-primary"></button>
)}
</div>
{/* 员工列表 */}
+152 -38
View File
@@ -3,8 +3,9 @@ import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, CalendarDays, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles } from 'lucide-react'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
@@ -57,13 +58,6 @@ export default function Dashboard() {
})
const currentMonth = new Date().toISOString().slice(0, 7)
const { data: calendarData } = useQuery<any>({
queryKey: ['calendar', currentMonth],
queryFn: async () => {
const res = await api.get(`/dashboard/calendar?month=${currentMonth}`) as any
return res.data
},
})
const { data: costAnalysis } = useQuery<any>({
queryKey: ['cost-analysis', currentMonth],
@@ -81,6 +75,14 @@ export default function Dashboard() {
},
})
const { data: workforceStats } = useQuery<any>({
queryKey: ['workforce-stats'],
queryFn: async () => {
const res = await api.get('/dashboard/workforce-stats') as any
return res.data
},
})
const resolveMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
@@ -107,9 +109,21 @@ export default function Dashboard() {
},
})
const handleExportPayroll = () => {
const handleExportPayroll = async () => {
try {
const month = payroll?.month || new Date().toISOString().slice(0, 7)
window.open(`/api/v1/export/payroll?month=${month}`, '_blank')
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/payroll?month=${month}`, { headers: { 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 = `薪税汇总-${month}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}
const toggleSelect = (id: string) => {
@@ -583,35 +597,8 @@ export default function Dashboard() {
</Card>
</div>
{/* HR 月度日历 + 人力成本分析 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* 月度日历 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><CalendarDays className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{currentMonth}</span>
</div>
{calendarData?.events && calendarData.events.length > 0 ? (
<div className="space-y-1.5 max-h-64 overflow-y-auto">
{calendarData.events.slice(0, 10).map((ev: any, i: number) => (
<Link key={i} to={ev.actionUrl || '/'} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
ev.priority === 'high' ? 'bg-red-500' : ev.priority === 'medium' ? 'bg-amber-500' : 'bg-gray-400'
}`} />
<span className="text-gray-500 w-20 flex-shrink-0">{ev.date.slice(5)}</span>
<span className="text-gray-800 truncate flex-1">{ev.title}</span>
</Link>
))}
{calendarData.events.length > 10 && (
<div className="text-xs text-gray-500 text-center pt-1"> {calendarData.events.length - 10} </div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-4"></div>
)}
</Card>
{/* 人力成本分析 */}
<div className="grid grid-cols-1 gap-3">
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
@@ -654,6 +641,36 @@ export default function Dashboard() {
))}
</div>
)}
{costAnalysis.departmentCost && costAnalysis.departmentCost.length > 0 && (
<div className="space-y-1.5 border-t pt-2">
<div className="text-xs font-medium text-gray-600"></div>
<div className="max-h-40 overflow-y-auto space-y-1">
{costAnalysis.departmentCost.map((d: any, i: number) => {
const maxCost = costAnalysis.departmentCost[0].totalCost || 1
return (
<div key={i} className="text-xs">
<div className="flex items-center justify-between mb-0.5">
<span className="text-gray-700">{d.department}{d.headcount}</span>
<span className="font-medium text-gray-800">{fmt(d.totalCost)}</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary/60 rounded-full"
style={{ width: `${(d.totalCost / maxCost) * 100}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mt-0.5">
<span> {fmt(d.totalPay)}</span>
<span> {fmt(d.socialOrg)}</span>
<span> {fmt(d.housingOrg)}</span>
<span> {fmt(d.perCapita)}</span>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-4"></div>
@@ -767,6 +784,103 @@ export default function Dashboard() {
</Card>
)}
{/* 员工分布统计 */}
{activeTab === 'overview' && workforceStats && workforceStats.total > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* 性别分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Users className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.gender} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.gender.map((_: any, i: number) => <Cell key={i} fill={['#3b82f6', '#ec4899', '#9ca3af'][i % 3]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-2 text-xs mt-1">
{workforceStats.gender.map((g: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#3b82f6', '#ec4899', '#9ca3af'][i % 3] }} />
{g.name} {g.value}
</span>
))}
</div>
</Card>
{/* 年龄段分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Users className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.age.filter((a: any) => a.value > 0)} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.age.filter((a: any) => a.value > 0).map((_: any, i: number) => <Cell key={i} fill={['#22c55e', '#10b981', '#3b82f6', '#6366f1', '#f59e0b', '#ef4444'][i % 6]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.age.filter((a: any) => a.value > 0).map((a: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#22c55e', '#10b981', '#3b82f6', '#6366f1', '#f59e0b', '#ef4444'][i % 6] }} />
{a.name} {a.value}
</span>
))}
</div>
</Card>
{/* 学历分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><BookOpen className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.education} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.education.map((_: any, i: number) => <Cell key={i} fill={['#8b5cf6', '#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b', '#9ca3af'][i % 7]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.education.map((e: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#8b5cf6', '#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b', '#9ca3af'][i % 7] }} />
{e.name} {e.value}
</span>
))}
</div>
</Card>
{/* 司龄分布 */}
<Card>
<h3 className="text-xs font-medium mb-2 flex items-center gap-1.5"><Clock className="w-4 h-4 text-primary" /></h3>
<div className="flex items-center justify-center" style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={workforceStats.tenure.filter((t: any) => t.value > 0)} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={55} innerRadius={30}>
{workforceStats.tenure.filter((t: any) => t.value > 0).map((_: any, i: number) => <Cell key={i} fill={['#a5f3fc', '#67e8f9', '#22d3ee', '#0891b2', '#155e75'][i % 5]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-1.5 text-xs mt-1">
{workforceStats.tenure.filter((t: any) => t.value > 0).map((t: any, i: number) => (
<span key={i} className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ background: ['#a5f3fc', '#67e8f9', '#22d3ee', '#0891b2', '#155e75'][i % 5] }} />
{t.name} {t.value}
</span>
))}
</div>
</Card>
</div>
)}
{/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-3">
+97 -4
View File
@@ -654,7 +654,24 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
<Button
variant="secondary"
size="sm"
onClick={() => window.open('/api/v1/import/payroll-template', '_blank')}
onClick={async () => {
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/payroll-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.click()
URL.revokeObjectURL(url)
} catch {
toast.error('下载模板失败')
}
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
@@ -696,11 +713,87 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)}
{isArchived && (
<div className="flex gap-2">
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
<Button variant="secondary" size="sm">
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/payroll2/batches/${batchId}/export?format=csv`, { headers: { 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 = `银行代发文件-${batch.month}-批次${batch.batchNo}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
</a>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const res = await api.get(`/payroll/batch/${batchId}/summary`) as any
const { departments, grandTotal } = res.data
const headers = ['部门', '人数', '应发合计', '实发合计', '个人社保', '单位社保', '个人公积金', '单位公积金', '个税合计']
const rows = departments.map((d: any) => [
d.department, d.headcount, d.totalPay.toFixed(2), d.totalNetPay.toFixed(2),
d.totalSocialEmp.toFixed(2), d.totalSocialOrg.toFixed(2),
d.totalHousingEmp.toFixed(2), d.totalHousingOrg.toFixed(2), d.totalTax.toFixed(2),
])
rows.push(['合计', grandTotal.headcount, grandTotal.totalPay.toFixed(2), grandTotal.totalNetPay.toFixed(2),
grandTotal.totalSocialEmp.toFixed(2), grandTotal.totalSocialOrg.toFixed(2),
grandTotal.totalHousingEmp.toFixed(2), grandTotal.totalHousingOrg.toFixed(2), grandTotal.totalTax.toFixed(2)])
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 = `salary-summary-${batch.month}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出汇总表失败') }
}}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const res = await api.get(`/payroll/batch/${batchId}/detail`) as any
const { details } = res.data
const headers = ['姓名', '部门', '基本工资', '岗位工资', '绩效工资', '工龄工资', '加班费', '交通补贴', '餐补', '住房补贴', '通讯补贴', '其他津贴', '奖金', '扣款', '其他扣款', '个人社保', '个人公积金', '个税', '应发合计', '实发工资']
const rows = details.map((d: any) => [
d.name, d.department,
d.baseSalary.toFixed(2), d.positionSalary.toFixed(2), d.performanceSalary.toFixed(2),
d.senioritySalary.toFixed(2), d.overtimePay.toFixed(2),
d.transportAllowance.toFixed(2), d.mealAllowance.toFixed(2), d.housingAllowance.toFixed(2),
d.communicationAllowance.toFixed(2), d.allowance.toFixed(2), d.bonus.toFixed(2),
d.deduction.toFixed(2), d.otherDeduction.toFixed(2),
d.socialEmp.toFixed(2), d.housingEmp.toFixed(2), d.tax.toFixed(2),
d.totalPay.toFixed(2), d.netPay.toFixed(2),
])
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 = `salary-detail-${batch.month}.csv`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出明细表失败') }
}}
>
<FileText className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
size="sm"
+48 -4
View File
@@ -2,8 +2,9 @@ import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet } from 'lucide-react'
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet, Download } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -39,18 +40,20 @@ export default function Roster() {
const [previewData, setPreviewData] = useState<any>(null)
const [filterStatus, setFilterStatus] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
const { data: rosterData, isLoading } = useQuery<any>({
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus],
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
queryFn: async () => {
const params: any = { page, pageSize }
if (debouncedSearch) params.search = debouncedSearch
if (filterStatus) params.status = filterStatus
if (filterContractStatus) params.contractStatus = filterContractStatus
if (filterDepartment) params.department = filterDepartment
const res = await api.get('/roster', { params }) as any
return res
},
@@ -206,10 +209,19 @@ export default function Roster() {
setSearch('')
setFilterStatus('')
setFilterContractStatus('')
setFilterDepartment('')
setPage(1)
}
const hasActiveFilters = search || filterStatus || filterContractStatus
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search)
@@ -231,7 +243,7 @@ export default function Roster() {
</div>
<div className="flex flex-wrap items-center justify-start gap-2 xl:justify-end">
<Input
placeholder="搜索姓名部门"
placeholder="搜索姓名部门或身份证后4位"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="!w-full sm:!w-64 shrink-0"
@@ -259,6 +271,14 @@ export default function Roster() {
<option value="unsigned_over_30">(30)</option>
<option value="unsigned_over_year">()</option>
</select>
<select
value={filterDepartment}
onChange={(e) => { setFilterDepartment(e.target.value); setPage(1) }}
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>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{hasActiveFilters && (
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700"></button>
)}
@@ -268,6 +288,28 @@ export default function Roster() {
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={async () => {
try {
const params = new URLSearchParams()
if (debouncedSearch) params.set('search', debouncedSearch)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/roster?${params}`, { headers: { 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 = `花名册-${new Date().toISOString().slice(0, 10)}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}} className="h-9 shrink-0">
<Download className="mr-1.5 h-4 w-4" />
</Button>
</div>
</div>
@@ -308,6 +350,7 @@ export default function Roster() {
<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>
@@ -335,6 +378,7 @@ export default function Roster() {
<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">
<span className={`px-2 py-0.5 rounded text-xs ${
+6 -4
View File
@@ -1012,14 +1012,15 @@ function InitImport() {
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/template', {
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/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 = 'import-template.xlsx'
a.download = '员工导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
@@ -1193,14 +1194,15 @@ function MonthlyImport() {
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/monthly-template', {
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
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 = 'monthly-import-template.xlsx'
a.download = '月度增减员导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
+33 -1
View File
@@ -43,6 +43,7 @@ export default function SocialInsurance() {
const [newHousingVersion, setNewHousingVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
accountType: 'BASIC',
housingOrg: 12, housingEmp: 12,
baseMin: 6326, baseMax: 33891,
})
@@ -71,6 +72,14 @@ export default function SocialInsurance() {
return res.data
},
})
const { data: housingAllAccounts } = useQuery<any[]>({
queryKey: ['housing-config-all-accounts', city],
queryFn: async () => {
const res = await api.get('/social/housing-config/versions', { params: { city } }) as any
const current = (res.data || []).filter((v: any) => v.isCurrent)
return current
},
})
const { data: versions } = useQuery<any[]>({
queryKey: ['social-config-versions', city],
@@ -471,12 +480,24 @@ export default function SocialInsurance() {
</div>
</div>
{isHousing ? (
<>
{(housingAllAccounts || []).length > 1 && (
<div className="flex gap-2 mb-3">
{(housingAllAccounts || []).map((a: any) => (
<span key={a.id} className={`px-2 py-0.5 rounded text-xs ${a.accountType === 'SUPPLEMENTARY' ? 'bg-purple-50 text-purple-700' : 'bg-blue-50 text-blue-700'}`}>
{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}%
</span>
))}
</div>
)}
<div className="grid md:grid-cols-4 gap-3 text-sm">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">{activeConfig.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
</div>
</>
) : (
<div className="grid md:grid-cols-4 gap-3 text-sm">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
@@ -611,6 +632,7 @@ export default function SocialInsurance() {
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
{isHousing && <th className="py-2 text-left"></th>}
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
{isHousing ? (
@@ -630,6 +652,7 @@ export default function SocialInsurance() {
<td className="py-2">{v.effectiveFrom}</td>
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
<td className="py-2">{v.city}</td>
{isHousing && <td className="py-2">{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</td>}
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
{isHousing ? (
@@ -695,10 +718,19 @@ export default function SocialInsurance() {
</div>
)}
{isHousing ? (
<div className="grid md:grid-cols-2 gap-3">
<>
<div className="grid md:grid-cols-3 gap-3">
<div>
<Label></Label>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.accountType || 'BASIC'} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, accountType: e.target.value })}>
<option value="BASIC"></option>
<option value="SUPPLEMENTARY"></option>
</select>
</div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
</div>
</>
) : (
<div className="grid md:grid-cols-4 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
+73 -2
View File
@@ -4,6 +4,7 @@ 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 jsPDF from 'jspdf'
import api from '../lib/api'
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'
@@ -115,6 +116,9 @@ export default function Termination() {
}>>([])
// 是否展开对比
const [showCompare, setShowCompare] = useState(false)
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const { data: employees } = useQuery<RosterEmployee[]>({
queryKey: ['roster-for-termination'],
@@ -126,6 +130,14 @@ export default function Termination() {
const selectedEmployee = employees?.find((e) => e.id === employeeId)
const { data: departmentList } = useQuery<string[]>({
queryKey: ['roster-departments'],
queryFn: async () => {
const res = await api.get('/roster/departments') as any
return res.data || []
},
})
const { data: profile } = useQuery<EmployeeProfile>({
queryKey: ['employee-profile', employeeId],
queryFn: async () => {
@@ -239,9 +251,13 @@ export default function Termination() {
// 草稿列表
const { data: drafts, refetch: refetchDrafts } = useQuery({
queryKey: ['termination-drafts'],
queryKey: ['termination-drafts', filterStatus, filterDepartment, searchTerm],
queryFn: async () => {
const res = await api.get('/termination/drafts') as any
const params: any = {}
if (filterStatus) params.status = filterStatus
if (filterDepartment) params.department = filterDepartment
if (searchTerm) params.search = searchTerm
const res = await api.get('/termination/drafts', { params }) as any
return res.data
},
enabled: view === 'list',
@@ -624,6 +640,60 @@ export default function Termination() {
{/* 草稿列表视图 */}
{view === 'list' && (
<>
<div className="flex gap-2 flex-wrap items-center">
<Input
placeholder="搜索员工姓名或部门"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="!w-48"
/>
<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>
<option value="DRAFT">稿</option>
<option value="PENDING_APPROVAL"></option>
<option value="APPROVED"></option>
<option value="EXECUTING"></option>
<option value="COMPLETED"></option>
<option value="CANCELLED"></option>
</select>
<select
value={filterDepartment}
onChange={(e) => setFilterDepartment(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>
{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>
)}
<Button variant="secondary" size="sm" onClick={async () => {
try {
const params = new URLSearchParams()
if (searchTerm) params.set('search', searchTerm)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
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}` } })
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 = `解聘记录-${new Date().toISOString().slice(0, 10)}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
<Card>
{(!drafts || drafts.length === 0) ? (
<EmptyState
@@ -754,6 +824,7 @@ export default function Termination() {
</div>
)}
</Card>
</>
)}
{/* 详情视图 */}
+4
View File
@@ -76,6 +76,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
housingFundBase: profile.housingFundBase ?? '',
specialDeduction: profile.specialDeduction ?? 0,
city: profile.city || '',
education: profile.education || '',
cityChangeReason: '',
})
@@ -112,6 +113,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
specialDeduction: Number(form.specialDeduction) || 0,
city: form.city || undefined,
education: form.education || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
updateMutation.mutate(data)
@@ -126,6 +128,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
: []),
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '学历', value: profile.education || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
@@ -221,6 +224,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
<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>
)}
<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 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>
+3 -1
View File
@@ -517,7 +517,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
const [form, setForm] = useState({
name: '', department: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京',
city: '北京', education: '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
@@ -619,6 +619,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
idCardNumber: form.idCardNumber || undefined,
phone: form.phone || undefined,
education: form.education || undefined,
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
@@ -669,6 +670,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
</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.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>
{/* 社保公积金 */}
<div className="border-t border-gray-200 pt-4">