feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件) - 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录) - AI顾问新增人力报告Tab,支持流式生成+Word导出 - 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分 - 花名册/合同/解聘补偿新增部门和状态筛选 - 薪税管理新增工资表导入模板下载、银行代发CSV导出 - 社保公积金支持多公积金账户类型显示 - 数据导出新增花名册/解聘记录导出,中文文件名编码修复 - 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出 - 移除工作台日历卡片(已迁移至独立工作日历页面) - 新增20260728/20260729更新测试指导文档
This commit is contained in:
+38
-24
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`),确保加载最新前端代码。
|
||||
|
||||
---
|
||||
|
||||
*如有问题或反馈,请联系开发团队。*
|
||||
@@ -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`),确保加载最新前端代码。
|
||||
|
||||
---
|
||||
|
||||
*如有问题或反馈,请联系开发团队。*
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 员工列表 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 详情视图 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user