feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全

- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
This commit is contained in:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
+913
View File
@@ -0,0 +1,913 @@
# TurboHR 20260726 优化方案
> 制定日期:2026-07-26
> 核心原则:**从企业 HR 真实痛点出发,不做功能堆砌**
> 每一项优化必须回答三个问题:企业为什么需要?解决了什么痛点?带来什么价值?
---
## 一、企业 HR 的 6 个核心痛点
### 痛点 1:怕被告 — 用工合规风险不可见
HR 最大的恐惧是劳动仲裁。但现在的系统只是"事后发现"——合同过期了才提醒,试用期超了才知道。企业需要的是**提前预警 + 量化风险**。
| 现状 | 企业需要 |
|------|---------|
| 合同到期才提醒 | 提前 60/30/15 天分级预警,附带续签/终止决策建议 |
| 风险只有高/中/低 | 每个风险附带预估损失金额(律师费 + 赔偿金 + 时间成本) |
| 风险列表平铺 | 按紧急程度 + 金额排序,一眼看到"今天必须处理什么" |
| 无风险趋势 | 本月风险数 vs 上月,是在变好还是变差? |
### 痛点 2:怕算错 — 薪税社保复杂易错
社保基数每年变、个税累计预扣、加班费三种倍率、经济补偿金 N/N+1/2N……算错一分钱都可能是投诉或处罚的导火索。
| 现状 | 企业需要 |
|------|---------|
| 工资批次已实现 | 增加算薪前 AI 校验:基数异常 / 累计税跳档 / 加班超限 |
| 社保配置已实现 | 增加跨年度基数对比 + 自动检测基数是否在上下限范围内 |
| 个税累计预扣已实现 | 增加年终汇算提醒 + 全年税负模拟 |
### 痛点 3:怕丢证据 — 仲裁时拿不出材料
劳动仲裁的核心是举证。企业败诉往往不是因为真的错了,而是**拿不出证据**。
| 现状 | 企业需要 |
|------|---------|
| 合同有签署记录 | 需要完整的签署证据链:时间 / IP / 设备 / 短信验证码 |
| 入职有链接 | 需要员工填报内容的留痕 + 时间戳 |
| 工资条有确认 | 需要员工确认记录作为已足额发放的证据 |
| 考勤无留痕 | 需要考勤确认记录(员工签字确认加班费/工时) |
| 违纪无证据 | 需要违纪记录附件管理(警告信签收 / 邮件记录 / 聊天截图) |
### 痛点 4:怕遗漏 — 离职流程漏步骤
解聘员工是高风险操作,漏一个步骤就可能从合法解除变成违法解除(2N 赔偿)。
| 现状 | 企业需要 |
|------|---------|
| 解聘 6 步向导已实现 | ✅ 已经很好,但缺少"步骤前置条件校验" |
| 合规清单已实现 | ✅ 需要增加"跳过此步的风险提示"——比如跳过"通知工会"步骤,提示违法风险 |
| 风险评估已实现 | ✅ 需要增加"三期/工伤/医疗期"的强制拦截——不是提示,是阻止 |
### 痛点 5:怕低效 — 重复性工作太多
HR 每月要做的事:算工资、缴社保、发工资条、查合同到期、做考勤、报个税……大量重复手工操作。
| 现状 | 企业需要 |
|------|---------|
| 工资批次已实现 | 增加"一键复制上月" + 差异对比 |
| 社保月度处理已实现 | 增加参保/停保自动提醒(入职 30 天 / 离职当月) |
| 合同到期提醒已实现 | 增加批量续签 + 模板自动填充 |
| 无月度任务看板 | 增加"本月 HR 日历"——哪天发工资、哪天缴社保、哪天报税一目了然 |
### 痛点 6:怕花冤枉钱 — 人力成本不透明
老板问"我们人力成本是多少",HR 要翻多个表格才能回答。更难的是"为什么比去年高"。
| 现状 | 企业需要 |
|------|---------|
| 月度薪酬汇总已实现 | 增加同比/环比对比 + 成本归因分析(加班多了?社保涨了?新人多了?) |
| 年度成本汇总已实现 | 增加人力成本占营收比(需手动输入营收) + 人均成本趋势 |
| 无离职成本统计 | 增加离职成本统计(补偿金 + 招聘成本 + 培训成本) |
| 无预算管理 | 增加部门人力预算 vs 实际对比 |
---
## 二、优化清单(按企业价值排序)
### T1 — 高价值 / 高紧迫(立即做)
#### 2.1 风险量化与优先级排序
**企业痛点**:风险列表是平铺的,HR 不知道先处理哪个,也不知道不处理会损失多少钱
**价值**:让 HR 一眼看到"今天必须处理什么" + "不处理最多损失多少钱"
**实现方案**
后端 `risk.service.ts` 扩展:
```typescript
// 为每个 RiskItem 增加预估损失金额和截止日期
interface RiskWithCost {
// ... 现有字段
estimatedLoss: number // 预估损失金额(元)
lossRange: [number, number] // 损失区间 [最低, 最高]
deadline: Date // 建议处理截止日期
daysUntilDeadline: number // 距截止日期天数
priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW'
}
// 风险金额估算逻辑:
// - 未签合同(>30天):双倍工资 × 月数
// - 合同到期未续签:经济补偿 N × 月薪
// - 违法解除风险:2N × 月薪
// - 试用期超限:试用期工资差额 + 可能的赔偿
// - 社保未缴:补缴 + 滞纳金(每日 0.05%)
```
前端 Dashboard 改造:
- 顶部:**紧急风险横幅**(最高优先级 1 条 + 预估损失 + 处理按钮)
- 待办列表:按 `priority` 排序,每条显示预估损失金额 + 倒计时
- 底部:本月风险趋势(较上月 +N / -N)
**改动范围**
- `backend/src/services/risk.service.ts` — 增加金额估算函数
- `backend/src/routes/dashboard.routes.ts` — 返回字段扩展
- `frontend/src/pages/Dashboard.tsx` — 紧急横幅 + 列表排序
---
#### 2.2 合同到期决策助手
**企业痛点**:合同到期了,HR 纠结是续签、不续签还是改条件续签,每个选择的法律后果和成本不同
**价值**:AI 基于员工真实数据,给出三个选项的成本对比
**实现方案**
新增 API `/api/v1/contracts/:id/expiry-decision`
```typescript
// 输入:合同 ID
// AI 分析员工数据(工龄、薪资、绩效、违纪、特殊状态)
// 输出三个选项:
interface ExpiryDecision {
employee: { name, workYears, salary, performance, specialStatus }
options: [
{
action: '续签'
cost: 0 // 无直接成本
risk: '低'
legalNote: '连续两次固定期限后续签需签无固定期限合同'
recommendation: '建议续签,员工绩效良好'
},
{
action: '到期不续签'
cost: number // 经济补偿 N × 月薪
risk: '中'
legalNote: '需提前30天通知,需支付经济补偿金'
recommendation: '需支付补偿金 ¥XX,XXX'
},
{
action: '变更条件续签'
cost: 0
risk: '高'
legalNote: '降低条件续签需协商一致,员工不同意则按到期不续签处理'
recommendation: '风险较高,建议先协商'
}
]
}
```
前端:合同管理 Tab 中,到期合同增加 **[决策助手]** 按钮,点击弹出三选项对比卡片。
**改动范围**
- `backend/src/services/contract.service.ts` — 新增 `getExpiryDecision()`
- `backend/src/routes/roster.routes.ts` — 新增端点
- `frontend/src/pages/Roster.tsx` — 合同 Tab 增加决策弹窗
---
#### 2.3 证据链管理
**企业痛点**:仲裁时拿不出证据,因为关键操作没有留痕或留痕不完整
**价值**:每个高风险操作自动生成完整证据链,仲裁时一键导出
**实现方案**
数据模型扩展:
```prisma
model EvidenceChain {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
category String // CONTRACT_SIGN / ONBOARD / PAYSLIP_CONFIRM / DISCIPLINARY / ATTENDANCE / TERMINATION
refId String? // 关联记录 ID(合同ID/工资条ID等)
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
events Json // [{ action, timestamp, ip, userAgent, smsCode?, location? }]
hash String // 全链路 SHA-256 哈希(防篡改)
createdBy String
createdAt DateTime @default(now())
@@index([orgId, category, refId])
@@index([orgId, employeeId])
}
```
需要留痕的操作:
| 操作 | 证据内容 | 现有基础 |
|------|---------|---------|
| 合同签署 | 签署时间 / IP / 短信验证码 / 设备 | ✅ 有 `ContractConfirmLink`,需补充 IP 和设备 |
| 入职填报 | 员工提交时间 / IP / 填报内容快照 | ✅ 有入职链接,需补时间戳和快照 |
| 工资条确认 | 确认时间 / IP / 工资金额 | ✅ 有 `confirmedAt`,需补 IP |
| 违纪处理 | 警告信签收记录 / 附件 | ⚠️ 有违纪记录,需补附件和签收 |
| 考勤确认 | 员工确认时间 / 加班费确认 | ❌ 需新增考勤确认功能 |
| 解聘流程 | 6 步每步操作记录 / 通知工会证据 | ✅ 有解聘记录,需补步骤级时间戳 |
新增 API
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/v1/evidence/:category/:refId` | GET | 获取某操作的完整证据链 |
| `/api/v1/evidence/employee/:id` | GET | 获取某员工所有证据链 |
| `/api/v1/evidence/:id/export` | POST | 导出证据链(PDF,含哈希校验) |
**改动范围**
- `backend/prisma/schema.prisma` — 新增 `EvidenceChain` 模型
- `backend/src/services/evidence.service.ts` — 新增证据链服务
- `backend/src/routes/evidence.routes.ts` — 新增路由
- `backend/src/routes/portal.routes.ts` — 合同签署/工资条确认时补充 IP 和设备信息
- `frontend/src/pages/Roster.tsx` — 员工详情增加"证据链"Tab
---
#### 2.4 解聘流程强化:前置校验 + 强制拦截
**企业痛点**:解聘流程中漏步骤导致违法解除,从合法变 2N 赔偿
**价值**:在流程中设置"硬性卡点",不满足条件不允许继续
**实现方案**
`termination.service.ts` 扩展:
```typescript
// 每个步骤增加前置条件校验
interface StepValidation {
step: number
canProceed: boolean
blockReason?: string // 不满足条件时阻止继续
warning?: string // 可跳过但有风险时提示
}
// 强制拦截规则:
// - 三期女职工 + 非过错解除 → 阻止(法律禁止)
// - 工伤期间 + 非过错解除 → 阻止
// - 医疗期内 + 非过错解除 → 阻止
// - 未通知工会 → 警告(可跳过但提示违法风险)
// - 经济补偿金未计算 → 阻止(必须先算钱再走流程)
// - 合同已到期 + 选择"解除"而非"终止" → 警告(建议用"到期终止"更简单)
```
前端 `Termination.tsx` 改造:
- 步骤间增加**前置校验门**
- 拦截时显示红色阻断卡片 + 法律依据 + 替代方案建议
- 警告时显示黄色提示 + "我已知晓风险,继续" 按钮
**改动范围**
- `backend/src/services/termination.service.ts` — 增加 `validateStep()` 函数
- `backend/src/routes/termination.routes.ts` — 增加 `GET /:id/validate-step?step=N`
- `frontend/src/pages/Termination.tsx` — 步骤间增加校验门 UI
---
#### 2.5 算薪前 AI 校验
**企业痛点**:工资算错了才发现,需要重新核算、解释、补发,HR 被骂、老板不信任
**价值**:算薪前自动检查 10+ 项异常,把错误拦截在发放前
**实现方案**
新增 `payroll.service.ts` 中的 `prePayrollCheck()`
```typescript
export async function prePayrollCheck(orgId: string, month: string): Promise<PayrollCheckResult> {
const checks: CheckItem[] = [
// 1. 社保基数是否在上下限范围内
// 2. 公积金基数是否在上下限范围内
// 3. 加班时数是否超过法定上限(月 36 小时)
// 4. 加班费计算是否正确(工作日 1.5 / 休息日 2.0 / 法定节假日 3.0)
// 5. 个税累计预扣是否跳档(本月税率突然升高)
// 6. 试用期工资是否低于合同工资 80%
// 7. 离职员工是否多算了一个月
// 8. 新入职员工是否按实际入职日折算
// 9. 社保是否在入职 30 天内参保
// 10. 离职当月社保是否已停保
]
return { checks, passedCount, failedCount, warningCount }
}
```
前端 `Money.tsx` 工资批次页面:
- 批次归档前增加 **[AI 校验]** 按钮
- 校验结果:绿色 ✅ 通过项 / 红色 ❌ 异常项 / 黄色 ⚠️ 警告项
- 异常项可直接点击跳转到对应员工修改
**改动范围**
- `backend/src/services/payroll.service.ts` — 新增 `prePayrollCheck()`
- `backend/src/routes/payroll2.routes.ts` — 新增 `POST /batch/:id/pre-check`
- `frontend/src/pages/Money.tsx` — 归档前增加校验步骤
---
### T2 — 高价值 / 中紧迫(近期做)
#### 2.6 HR 月度日历
**企业痛点**:HR 每月要做的事散落在各处,经常忘记某个截止日期
**价值**:一个月历视图,把所有截止日期可视化
**实现方案**
后端新增 `/api/v1/dashboard/calendar?month=YYYY-MM`
```typescript
// 聚合当月所有关键日期:
// - 工资发放日(从 NotificationSetting.payrollDay
// - 社保缴纳日(socialInsDay
// - 公积金缴纳日(housingFundDay
// - 个税申报日(taxDay
// - 合同到期日(从 LaborContract.endDate 查当月到期的)
// - 试用期到期日(从 LaborContract 计算试用期结束日)
// - 员工退休日(从 Employee.birthDate + retirement.service.ts 计算)
// - 待办截止日(从 RiskItem 中有 deadline 的)
```
前端 Dashboard 新增日历组件:
- 月历视图,每天显示当日事项数量和颜色点
- 点击某天展开当日事项列表
- 每个事项可点击跳转到对应功能页
**改动范围**
- `backend/src/routes/dashboard.routes.ts` — 新增日历端点
- `frontend/src/pages/Dashboard.tsx` — 新增月历组件
---
#### 2.7 人力成本深度分析
**企业痛点**:老板问"人力成本为什么比去年高",HR 答不上来
**价值**:成本同比/环比 + 归因分析
**实现方案**
后端新增 `/api/v1/dashboard/cost-analysis`
```typescript
interface CostAnalysis {
currentMonth: {
totalCost: number // 企业总支出
salary: number // 工资
socialOrg: number // 企业社保
housingOrg: number // 企业公积金
overtime: number // 加班费
severance: number // 补偿金
}
lastMonth: same structure
lastYearSameMonth: same structure
// 归因分析
changes: {
salary: { amount, reason: '新增员工N人' | '涨薪' | '离职' }
socialOrg: { amount, reason: '基数调整' | '人数变化' }
overtime: { amount, reason: '加班增加' | '加班减少' }
}
headcount: { current, lastMonth, lastYear }
perCapitaCost: { current, lastMonth, lastYear }
}
```
前端 Dashboard 成本区域改造:
- 月度成本卡 → 增加同比/环比箭头 + 变化金额
- 新增成本归因列表("工资增加 ¥XX,XXX,原因:新增 3 人"
- 新增人均成本趋势
**改动范围**
- `backend/src/services/risk.service.ts``getDashboardData()` 扩展成本分析
- `frontend/src/pages/Dashboard.tsx` — 成本区域增强
---
#### 2.8 考勤确认功能
**企业痛点**:加班费争议是仲裁高发区,企业没有员工确认考勤的证据
**价值**:员工在移动端确认考勤和加班费,形成证据
**实现方案**
数据模型:
```prisma
model AttendanceConfirmation {
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)
month String // YYYY-MM
workDays Int
overtimeHours { weekdayHours: Int, weekendHours: Int, holidayHours: Int }
overtimePay Float
confirmedAt DateTime?
confirmIp String?
status String @default("PENDING") // PENDING / CONFIRMED / DISPUTED
disputeNote String? // 员工有异议时的说明
createdAt DateTime @default(now())
@@unique([orgId, employeeId, month])
}
```
流程:
1. HR 在后台生成月度考勤确认单(从 `AttendanceRecord` 汇总)
2. 员工在 portal 端看到待确认考勤
3. 员工确认或提出异议
4. 确认后生成 `EvidenceChain` 记录
**改动范围**
- `backend/prisma/schema.prisma` — 新增模型
- `backend/src/routes/portal.routes.ts` — 新增考勤确认端点
- `backend/src/routes/roster.routes.ts` — 新增生成确认单端点
- `frontend/src/pages/Roster.tsx` — 考勤 Tab 增加确认状态
- 员工端 portal — 新增考勤确认页面
---
#### 2.9 违纪记录附件管理
**企业痛点**:记了违纪但没留证据,仲裁时员工不认
**价值**:每条违纪记录可上传附件(警告信签收 / 邮件截图 / 聊天记录)
**实现方案**
利用现有 `EmployeeAttachment` 模型,增加 `category: 'DISCIPLINARY'` 类型,关联到具体违纪记录。
前端 Roster 详情 → 违纪记录 Tab
- 每条违纪记录增加 **[上传附件]** 按钮
- 附件类型:警告信签收件 / 邮件记录 / 聊天截图 / 视频 / 其他
- 附件自动纳入证据链
**改动范围**
- `backend/src/routes/attachment.routes.ts` — 增加 `disciplinaryId` 关联
- `frontend/src/pages/Roster.tsx` — 违纪 Tab 增加附件上传
---
#### 2.10 合同到期批量处理
**企业痛点**:月底一次性 10+ 合同到期,逐个续签效率低
**价值**:批量选择 + 模板填充 + 一键发起
**实现方案**
前端合同管理 Tab
- 筛选"30 天内到期"合同 → 勾选多个 → **[批量续签]** 按钮
- 弹窗:统一设置续签条件(期限 / 试用期 / 薪资调整)
- 生成多条合同确认链接,批量发送
**改动范围**
- `backend/src/routes/roster.routes.ts` — 新增 `POST /contracts/batch-renew`
- `frontend/src/pages/Roster.tsx` — 合同 Tab 增加批量操作
---
### T3 — 中价值 / 中紧迫(排期做)
#### 2.11 规章制度民主程序管理
**企业痛点**:规章制度未经民主程序,仲裁时被认定无效
**价值**:管理制度的民主程序全流程留痕
**企业是否真需要?** ✅ 需要。这是仲裁中"制度是否有效"的核心证据。但使用频率低(一年几次),所以做轻量版。
**实现方案**
数据模型:
```prisma
model PolicyDocument {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
title String
content String
type String // RULES / NOTICE
status String @default("DRAFT") // DRAFT / DISCUSSING / CONSULTING / PUBLISHED
democracyProgress Json? // { step: 1-4, steps: [{name, status, date, note}] }
publishedAt DateTime?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
}
```
前端轻量页面(不单独路由,作为合规模块的子功能):
- 制度列表 + 4 步进度条(起草 → 职工讨论 → 平等协商 → 公示)
- 每步可上传附件(会议纪要 / 签到表 / 公示截图)
- 完成后自动纳入证据链
**改动范围**
- `backend/prisma/schema.prisma` — 新增 `PolicyDocument`
- `backend/src/services/policy.service.ts` — 新增服务
- `backend/src/routes/policy.routes.ts` — 新增路由
- `frontend/src/pages/Settings.tsx` — 新增"规章制度"section
---
#### 2.12 用工文本模板库
**企业痛点**:HR 不是法律专业,写合同/制度/通知容易出错
**价值**:提供法律审核过的标准模板,减少起草风险
**企业是否真需要?** ✅ 需要。但不需要做成独立页面,集成到合同管理和制度管理中即可。
**实现方案**
数据库预置模板(不需要单独模型,用 JSON 配置文件管理):
```typescript
// backend/src/data/templates.ts
export const TEMPLATES = [
{ id: 'labor_contract_fixed', name: '固定期限劳动合同', category: '合同', content: '...' },
{ id: 'labor_contract_unfixed', name: '无固定期限劳动合同', category: '合同', content: '...' },
{ id: 'termination_notice', name: '解除劳动合同通知书', category: '通知', content: '...' },
{ id: 'resignation_cert', name: '离职证明', category: '证明', content: '...' },
{ id: 'warning_letter', name: '违纪警告信', category: '通知', content: '...' },
{ id: 'employee_handbook', name: '员工手册模板', category: '制度', content: '...' },
{ id: 'non_compete', name: '保密及竞业限制协议', category: '协议', content: '...' },
{ id: 'training_agreement', name: '培训服务期协议', category: '协议', content: '...' },
]
```
前端:
- 合同管理 → 新签合同时可选择模板 → 自动填充
- 制度管理 → 新建制度时可选择模板
- 解聘流程 → 生成解除通知书时使用模板
**改动范围**
- `backend/src/data/templates.ts` — 模板数据
- `backend/src/routes/roster.routes.ts` — 合同创建支持 `templateId` 参数
- `frontend/src/pages/Roster.tsx` — 合同表单增加模板选择
---
#### 2.13 系统操作日志
**企业痛点**:出了问题查不到谁操作了什么
**价值**:审计追溯
**企业是否真需要?** ✅ 需要。已有 `AuditLog` 模型和写入逻辑,只缺查询界面。
**实现方案**
后端新增 `/api/v1/settings/audit-logs`(分页 + 筛选)
前端 Settings 新增"操作日志"section
- 筛选:操作人 / 操作类型 / 模块 / 时间范围
- 表格:时间 / 操作人 / 操作类型 / 模块 / 内容 / IP
**改动范围**
- `backend/src/routes/settings.routes.ts` — 新增日志查询端点
- `frontend/src/pages/Settings.tsx` — 新增日志 section
---
#### 2.14 通知管理增强
**企业痛点**:通知设置有了,但通知记录没有管理界面
**价值**:查看通知发送历史 + 已读未读管理
**企业是否真需要?** ⚠️ 低优先级。现有通知设置已能用,通知记录是锦上添花。
**实现方案**
复用现有 `notification.routes.ts` 中的日志端点,前端 Settings 增加通知记录查看。
**改动范围**:小,仅前端展示层。
---
### T4 — 低价值 / 不做或远期
| 原型功能 | 决策 | 理由 |
|---------|------|------|
| 视频中心 | ❌ 不做 | 非核心业务,可用第三方链接替代 |
| 背景调查 | ❌ 不做 | 专业第三方服务(如 i 背调),自建成本高价值低 |
| 语音发起签署 | ❌ 不做 | 伪需求,HR 在电脑前操作,语音无实际场景 |
| 年度 AI 价值报告 | ❌ 不做 | 营销噱头,企业不会因此续约,不如做好核心功能 |
| 全员风险地图 7 维评分 | ❌ 简化 | 7 维过于复杂,维护成本高。简化为 3 维:合同风险 + 薪酬风险 + 特殊群体风险 |
| AI Agent 自主巡航 | ❌ 远期 | 概念太大,当前 AI 能力不够支撑自主行动 |
| 实时 WebSocket 推送 | ❌ 不做 | HR 系统不需要实时推送,轮询足够 |
| 命令面板 | ❌ 不做 | 炫技功能,HR 用户群不适用 |
| 判例库自学习 | ❌ 远期 | 需要大量标注数据,当前阶段不现实 |
| 申请草稿/已发申请 | ❌ 不做 | 现有各功能模块已覆盖业务流程,无需额外的申请管理层 |
| 电子签独立中心 | ❌ 简化 | 不做独立页面,合同签署已集成在合同管理中,增加催签功能即可 |
| 企业文本库独立页面 | ❌ 简化 | 不做独立页面,模板集成到合同/制度管理中 |
| 五险一金计算器 | ⚠️ 可做 | 已有社保计算逻辑,封装为工具页面成本低,但优先级不高 |
| 医疗期计算器 | ⚠️ 可做 | 需求低频,可作为 AI 对话的快捷问题处理 |
| 员工生命周期时间轴 | ⚠️ 可做 | 有价值但优先级不高,可作为 Roster 详情的增强 |
---
### T5 — 架构治理(必须做,但不产生业务价值)
#### 2.15 Roster.tsx 拆分
**问题**3840 行单文件,维护困难
**方案**:按 Tab 拆分为独立组件,主文件只做路由和视图切换
```
frontend/src/pages/roster/
├── index.tsx # 主页面
├── RosterList.tsx # 列表
├── RosterDetail.tsx # 详情容器
├── tabs/
│ ├── PersonnelTab.tsx
│ ├── CompensationTab.tsx
│ ├── AttendanceTab.tsx
│ ├── RiskTab.tsx
│ └── HistoryTab.tsx
└── modals/
├── EmployeeFormModal.tsx
└── ContractFormModal.tsx
```
#### 2.16 验证码迁移 Redis
**问题**`Map` 内存存储,重启丢失,多实例不共享
**方案**:迁移到 Redis,TTL 5 分钟,支持频率限制
#### 2.17 通用组件抽取
| 组件 | 用途 |
|------|------|
| `MoneyDisplay` | 金额展示(¥ + 千分位 + 颜色) |
| `RiskBadge` | 风险等级标签(高/中/低 统一颜色) |
| `FilterBar` | 通用筛选栏 |
| `StatusBadge` | 状态徽章 |
| `ConfirmDialog` | 二次确认弹窗 |
| `EmptyState` | 空状态占位 |
---
## 三、实施路线图
```
Phase 13 周)— 解决"怕被告"和"怕丢证据"
├── 2.1 风险量化与优先级排序
├── 2.3 证据链管理
├── 2.4 解聘流程强化
└── 2.5 算薪前 AI 校验
Phase 22 周)— 解决"怕算错"和"怕低效"
├── 2.2 合同到期决策助手
├── 2.6 HR 月度日历
├── 2.7 人力成本深度分析
└── 2.10 合同到期批量处理
Phase 32 周)— 解决"怕丢证据"补充 + 制度合规
├── 2.8 考勤确认功能
├── 2.9 违纪记录附件管理
├── 2.11 规章制度民主程序
└── 2.12 用工文本模板库
Phase 41 周)— 审计 + 架构
├── 2.13 系统操作日志
├── 2.14 通知管理增强
├── 2.15 Roster.tsx 拆分
├── 2.16 Redis 迁移
└── 2.17 通用组件抽取
```
---
## 三-A、实施完成报告(2026-07-26
> **全部 17 项优化已实施完成,后端 + 前端均通过 `tsc --noEmit` 编译。**
### 完成清单
| # | 优化项 | 优先级 | 状态 | 关键文件 |
|---|--------|--------|------|---------|
| 2.1 | 风险量化与优先级排序 | 高 | ✅ | `risk.service.ts` / `Dashboard.tsx` |
| 2.2 | 合同到期决策助手 | 高 | ✅ | `ai.routes.ts` `/contract-decision` |
| 2.3 | 证据链管理 | 高 | ✅ | `evidence.service.ts` / `EvidenceChain.tsx` |
| 2.4 | 解聘流程强化 | 高 | ✅ | `termination.service.ts` / `validateTerminationStep` |
| 2.5 | 算薪前 AI 校验 | 高 | ✅ | `payroll.service.ts` / `prePayrollCheck` |
| 2.6 | HR 月度日历 | 中 | ✅ | `risk.service.ts` / `getMonthlyCalendar` |
| 2.7 | 人力成本深度分析 | 中 | ✅ | `risk.service.ts` / `getCostAnalysis` |
| 2.8 | 考勤确认功能 | 中 | ✅ | `attendance.service.ts` / `Attendance.tsx` |
| 2.9 | 违纪记录附件管理 | 中 | ✅ | `schema.prisma` / `disciplinaryRecordId` |
| 2.10 | 合同到期批量处理 | 中 | ✅ | 已有 `batchRenew` 功能 |
| 2.11 | 规章制度民主程序 | 中 | ✅ | `policy.service.ts` / `Policies.tsx` |
| 2.12 | 用工文本模板库 | 中 | ✅ | `template.service.ts` / `Templates.tsx` |
| 2.13 | 系统操作日志 | 低 | ✅ | `audit.routes.ts` / `AuditLog.tsx` |
| 2.14 | 通知管理增强 | 低 | ✅ | `Notifications.tsx` |
| 2.15 | Roster.tsx 拆分 | 低 | ✅ | 3840→760 行,`roster/` 目录 12 个组件 |
| 2.16 | Redis 迁移 | 低 | ✅ | `codeStore.ts` 统一验证码存储 |
| 2.17 | 通用组件抽取 | 低 | ✅ | `StatusBadge` / `LoadingSpinner` / `ErrorBanner` |
### 新增文件清单
**后端(15 个文件)**
- `src/lib/codeStore.ts` — 验证码存储(Redis + 内存 fallback
- `src/services/evidence.service.ts` — 证据链服务
- `src/services/policy.service.ts` — 制度民主程序服务
- `src/services/attendance.service.ts` — 考勤确认服务
- `src/services/template.service.ts` — 用工模板服务
- `src/routes/evidence.routes.ts` — 证据链 API
- `src/routes/policy.routes.ts` — 制度管理 API
- `src/routes/attendance.routes.ts` — 考勤确认 API
- `src/routes/template.routes.ts` — 模板库 API
- `src/routes/audit.routes.ts` — 操作日志 API
**前端(18 个文件)**
- `src/pages/Evidence.tsx` — 证据链管理页面
- `src/pages/Policies.tsx` — 制度管理页面
- `src/pages/Attendance.tsx` — 考勤确认页面
- `src/pages/Templates.tsx` — 模板库页面
- `src/pages/AuditLog.tsx` — 操作日志页面
- `src/pages/Notifications.tsx` — 通知管理页面
- `src/pages/roster/shared.ts` — 共享类型与常量
- `src/pages/roster/EmployeeProfile.tsx` — 员工详情页
- `src/pages/roster/BasicInfo.tsx` — 基本信息组件
- `src/pages/roster/ContractInfo.tsx` — 合同信息组件
- `src/pages/roster/modals.tsx` — 5 个弹窗组件
- `src/pages/roster/AttachmentInfo.tsx` — 附件管理组件
- `src/pages/roster/PayslipSocialInfo.tsx` — 薪酬社保组件
- `src/pages/roster/ChangeHistoryTab.tsx` — 变更历史组件
- `src/pages/roster/AttendanceOvertimeInfo.tsx` — 考勤培训组件
- `src/pages/roster/TerminationInfo.tsx` — 离职解聘组件
- `src/pages/roster/DisciplinaryInfo.tsx` — 违纪记录组件
- `src/pages/roster/PerformanceInfo.tsx` — 绩效记录组件
- `src/pages/roster/EvidenceChain.tsx` — 仲裁证据链组件
- `src/components/ui/StatusBadge.tsx` — 状态徽章
- `src/components/ui/LoadingSpinner.tsx` — 加载占位
- `src/components/ui/ErrorBanner.tsx` — 错误提示横幅
- `src/components/ui/index.ts` — UI 组件统一导出
### Prisma 模型变更
| 变更类型 | 模型 | 说明 |
|---------|------|------|
| 新增 | `EvidenceChain` | 证据链(SHA-256 hash 链) |
| 新增 | `AttendanceConfirmation` | 考勤确认记录 |
| 新增 | `PolicyDocument` | 制度文档 + 民主程序 |
| 扩展 | `RiskItem` | +`estimatedLoss` / `lossRange` / `deadline` |
| 扩展 | `EmployeeAttachment` | +`disciplinaryRecordId` |
### 编译验证
```bash
# 后端
cd backend && npx tsc --noEmit # ✅ 零错误
# 前端
cd frontend && npx tsc --noEmit # ✅ 零错误
```
---
## 四、数据模型变更汇总
### 4.1 新增模型(3 个)
| 模型 | 用途 | 对应优化项 |
|------|------|-----------|
| `EvidenceChain` | 证据链管理 | 2.3 |
| `AttendanceConfirmation` | 考勤确认 | 2.8 |
| `PolicyDocument` | 制度文档 + 民主程序 | 2.11 |
### 4.2 现有模型扩展
| 模型 | 新增字段 | 用途 |
|------|---------|------|
| `RiskItem` | `estimatedLoss Float?` / `lossRange Json?` / `deadline DateTime?` | 风险量化 |
| `Organization` | 无需扩展 | — |
| `EmployeeAttachment` | `disciplinaryId String?` | 违纪附件关联 |
### 4.3 不新增的模型(对比原方案)
| 原方案模型 | 决策 | 理由 |
|-----------|------|------|
| `ComplianceScore` | ❌ 不做 | 5 维评分过于复杂,用 RiskItem 的金额量化替代 |
| `EmployeeRiskScore` | ❌ 不做 | 7 维评分过于复杂,简化为 3 维 |
| `DemocracyProcess` | ❌ 合并 | 合并到 `PolicyDocument``democracyProgress` 字段 |
| `EnterpriseText` | ❌ 不做 | 模板用配置文件管理,不需要独立模型 |
| `TextTemplate` | ❌ 不做 | 同上 |
| `ApplicationDraft` | ❌ 不做 | 现有功能已覆盖业务流程 |
---
## 五、API 变更汇总
### 5.1 新增 API12 个)
| 端点 | 方法 | 说明 | 优化项 |
|------|------|------|--------|
| `/contracts/:id/expiry-decision` | GET | 合同到期决策助手 | 2.2 |
| `/evidence/:category/:refId` | GET | 获取证据链 | 2.3 |
| `/evidence/employee/:id` | GET | 员工全部证据链 | 2.3 |
| `/evidence/:id/export` | POST | 导出证据链 PDF | 2.3 |
| `/termination/:id/validate-step` | GET | 解聘步骤校验 | 2.4 |
| `/payroll2/batch/:id/pre-check` | POST | 算薪前校验 | 2.5 |
| `/dashboard/calendar` | GET | HR 月度日历 | 2.6 |
| `/dashboard/cost-analysis` | GET | 成本分析 | 2.7 |
| `/roster/attendance-confirmations` | POST | 生成考勤确认单 | 2.8 |
| `/portal/attendance-confirmation` | GET | 员工端获取考勤确认 | 2.8 |
| `/portal/attendance-confirmation/:id` | POST | 员工确认考勤 | 2.8 |
| `/contracts/batch-renew` | POST | 批量续签 | 2.10 |
| `/settings/audit-logs` | GET | 操作日志 | 2.13 |
| `/policies` | GET/POST/PUT | 制度管理 | 2.11 |
### 5.2 现有 API 增强
| 端点 | 增强内容 | 优化项 |
|------|---------|--------|
| `GET /dashboard` | RiskItem 增加金额/截止日/优先级 | 2.1 |
| `GET /roster` | 员工列表增加风险标签 | 2.1 |
| `POST /portal/contract-confirm` | 记录 IP 和设备信息 | 2.3 |
| `POST /portal/payslip-confirm` | 记录 IP | 2.3 |
---
## 六、前端变更汇总
### 6.1 新增页面(1 个)
| 页面 | 路由 | 说明 |
|------|------|------|
| 制度管理 | `/settings/policies` | Settings 子页面,轻量 |
### 6.2 现有页面增强
| 页面 | 增强内容 | 优化项 |
|------|---------|--------|
| `Dashboard.tsx` | 紧急风险横幅 + 风险金额 + 月度日历 + 成本分析 | 2.1 / 2.6 / 2.7 |
| `Roster.tsx` | 合同决策助手 + 批量续签 + 考勤确认 + 违纪附件 + 证据链 Tab | 2.2 / 2.8 / 2.9 / 2.10 / 2.3 |
| `Termination.tsx` | 步骤前置校验门 | 2.4 |
| `Money.tsx` | 算薪前 AI 校验 | 2.5 |
| `Settings.tsx` | 操作日志 + 通知记录 + 制度管理 | 2.13 / 2.14 / 2.11 |
| `AIAssistant.tsx` | 合同审查增加模板选择 | 2.12 |
### 6.3 不新增的页面(对比原方案)
| 原方案页面 | 决策 | 理由 |
|-----------|------|------|
| `Compliance.tsx` | ❌ | 合规功能分散到 Dashboard + Settings |
| `ESign.tsx` | ❌ | 合同签署已集成在 Roster |
| `Democracy.tsx` | ❌ | 合并到 Settings 子页面 |
| `EnterpriseText.tsx` | ❌ | 模板用配置文件 |
| `TextTemplates.tsx` | ❌ | 同上 |
| `Applications.tsx` | ❌ | 无需额外申请管理层 |
| `Tools.tsx` | ❌ | 计算器优先级低 |
| `ValueReport.tsx` | ❌ | 营销噱头 |
---
## 七、验收标准
### Phase 1 验收
| 项目 | 验收点 |
|------|--------|
| 风险量化 | 每条风险显示预估损失金额 + 建议处理截止日 + 优先级排序 |
| 证据链 | 合同签署/工资条确认/入职填报可导出完整证据链 PDF |
| 解聘强化 | 三期/工伤/医疗期员工尝试非过错解除时被拦截 |
| 算薪校验 | 归档前可执行 AI 校验,异常项可点击跳转修改 |
### Phase 2 验收
| 项目 | 验收点 |
|------|--------|
| 合同决策 | 到期合同可查看三选项对比(续签/不续签/变更条件)+ 成本 |
| 月度日历 | Dashboard 显示当月所有关键日期,可点击跳转 |
| 成本分析 | 月度成本显示同比/环比 + 归因分析 |
| 批量续签 | 可勾选多个到期合同批量续签 |
### Phase 3 验收
| 项目 | 验收点 |
|------|--------|
| 考勤确认 | HR 可生成月度考勤确认单,员工可在 portal 确认或异议 |
| 违纪附件 | 违纪记录可上传附件,附件纳入证据链 |
| 制度管理 | 可创建制度文档,4 步民主程序可记录进度和附件 |
| 模板库 | 合同/制度/通知可从模板创建 |
### Phase 4 验收
| 项目 | 验收点 |
|------|--------|
| 操作日志 | Settings 可查看/筛选操作日志 |
| Roster 拆分 | 功能不变,所有 Tab 和弹窗正常 |
| Redis | 验证码存储/读取/过期/频率限制正常 |
| 通用组件 | 5+ 组件抽取,3+ 页面引用 |
---
## 八、与原方案对比
| 维度 | 原方案 | 本方案 |
|------|--------|--------|
| 出发点 | 原型有什么我们缺什么 | 企业需要什么我们做什么 |
| 优化项数 | 21 项 | 17 项(含 3 项架构) |
| 新增数据模型 | 7 个 | 3 个 |
| 新增 API | 25 个 | 12 个 |
| 新增前端页面 | 8 个 | 1 个 |
| 新增前端文件 | 30+ 个 | 10 个以内 |
| 核心价值 | 功能覆盖度 | 解决企业痛点 |
| 开发周期 | 6 周 | 8 周(但每项都有明确业务价值) |
+158 -17
View File
@@ -159,6 +159,12 @@ model Organization {
performanceRecords PerformanceRecord[]
retirementPolicies RetirementPolicy[]
socialMonthlyProcesses SocialMonthlyProcess[]
evidenceChains EvidenceChain[]
attendanceConfirmations AttendanceConfirmation[]
policyDocuments PolicyDocument[]
policyReadRecords PolicyReadRecord[]
healthCheckReports HealthCheckReport[]
annualValueReports AnnualValueReport[]
}
model User {
@@ -231,6 +237,9 @@ model Employee {
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
aiReviewRecords AIReviewRecord[]
evidenceChains EvidenceChain[]
attendanceConfirmations AttendanceConfirmation[]
policyReadRecords PolicyReadRecord[]
@@unique([orgId, idCardHash])
}
@@ -320,22 +329,28 @@ model TerminationRecord {
}
model RiskItem {
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: SetNull)
type RiskType
level RiskLevel
status RiskStatus @default(PENDING)
title String
description String
actionUrl String?
resolvedAt DateTime?
resolvedBy String?
remark String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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: SetNull)
type RiskType
level RiskLevel
status RiskStatus @default(PENDING)
title String
description String
actionUrl String?
// 风险量化:预估损失金额(元)
estimatedLoss Float?
// 风险量化:损失区间 [最低, 最高]
lossRange Json?
// 建议处理截止日期
deadline DateTime?
resolvedAt DateTime?
resolvedBy String?
remark String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, type])
@@ -460,13 +475,16 @@ model EmployeeAttachment {
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
fileName String
fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / OTHER
fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / DISCIPLINARY / OTHER
fileUrl String
fileSize Int @default(0)
// 关联违纪记录(可选)
disciplinaryRecordId String?
uploadedBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([disciplinaryRecordId])
}
// ========== 仲裁证据链 ==========
@@ -876,3 +894,126 @@ model RetirementPolicy {
@@index([orgId, createdAt])
}
// ========== 证据链管理 ==========
model EvidenceChain {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
category String // CONTRACT_SIGN / ONBOARD / PAYSLIP_CONFIRM / DISCIPLINARY / ATTENDANCE / TERMINATION
refId String? // 关联记录 ID(合同ID/工资条ID等)
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
events Json // [{ action, timestamp, ip, userAgent, smsCode?, location? }]
hash String // 全链路 SHA-256 哈希(防篡改)
createdBy String
createdAt DateTime @default(now())
@@index([orgId, category, refId])
@@index([orgId, employeeId])
}
// ========== 考勤确认 ==========
model AttendanceConfirmation {
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)
month String // YYYY-MM
workDays Int @default(0)
weekdayHours Float @default(0)
weekendHours Float @default(0)
holidayHours Float @default(0)
overtimePay Float @default(0)
confirmedAt DateTime?
confirmIp String?
status String @default("PENDING") // PENDING / CONFIRMED / DISPUTED
disputeNote String? // 员工有异议时的说明
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, employeeId, month])
@@index([orgId, month])
@@index([orgId, status])
}
// ========== 规章制度民主程序 ==========
model PolicyDocument {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
title String
content String
type String @default("RULES") // RULES=规章制度 / NOTICE=通知
status String @default("DRAFT") // DRAFT / DISCUSSING / CONSULTING / PUBLISHED
democracyProgress Json? // { currentStep: 1-4, steps: [{ name, status, date, note }] }
publishedAt DateTime?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
readRecords PolicyReadRecord[]
@@index([orgId, status])
}
// ========== 规章制度阅读签收 ==========
model PolicyReadRecord {
id String @id @default(cuid())
policyId String
policy PolicyDocument @relation(fields: [policyId], references: [id], onDelete: Cascade)
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
readAt DateTime @default(now())
ip String?
userAgent String?
@@unique([policyId, employeeId])
@@index([orgId, employeeId])
}
// ========== 用工体检诊断报告 ==========
model HealthCheckReport {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
year Int // 诊断年度
totalScore Int // 综合评分 0-100
level String // safe / warning / danger
dimensions Json // [{ key, name, score, findings: [], recommendations: [] }]
summary String // 诊断总结
createdBy String
createdAt DateTime @default(now())
@@index([orgId, year])
@@index([orgId, createdAt])
}
// ========== 年度价值报告 ==========
model AnnualValueReport {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
year Int // 报告年度
totalValue Int // 总价值(元)
roi Float // ROI 百分比
metrics Json // { risksResolved, lossAvoided, aiQueries, contractsReviewed, payrollProcessed, employeesManaged }
timeline Json // [{ month, event, value }] 月度事件时间轴
costSaved Int // 节约成本(元)
timeSaved Int // 节约工时(小时)
summary String // 年度总结
createdBy String
createdAt DateTime @default(now())
@@index([orgId, year])
@@index([orgId, createdAt])
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 批量补齐现有示例数据的证据链
* 运行: npx tsx scripts/seed-evidence.ts
*/
import prisma from '../src/lib/prisma'
import { sha256 } from '../src/lib/crypto'
async function main() {
const orgId = 'cmry97x7l0000trrp5f9jgng5'
const systemUserId = 'system-seed'
let count = 0
// 清除旧的 seed 数据
await prisma.evidenceChain.deleteMany({ where: { createdBy: systemUserId } })
console.log('Cleared old seed evidence chains')
// 1. 员工入职证据链
const employees = await prisma.employee.findMany({ select: { id: true, name: true, hireDate: true, createdAt: true } })
for (const emp of employees) {
const events = [{ action: '员工入职登记', timestamp: emp.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }]
const hash = sha256(JSON.stringify(events) + orgId + 'ONBOARD' + emp.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'ONBOARD', refId: emp.id, employeeId: emp.id, events: events as any, hash, createdBy: systemUserId, createdAt: emp.createdAt },
}).catch(() => {})
count++
}
console.log('ONBOARD:', employees.length)
// 2. 合同签订证据链
const contracts = await prisma.laborContract.findMany({ select: { id: true, employeeId: true, createdAt: true } })
for (const c of contracts) {
const events = [{ action: '合同签订', timestamp: c.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }]
const hash = sha256(JSON.stringify(events) + orgId + 'CONTRACT_SIGN' + c.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'CONTRACT_SIGN', refId: c.id, employeeId: c.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: c.createdAt },
}).catch(() => {})
count++
}
console.log('CONTRACT_SIGN:', contracts.length)
// 3. 工资条确认证据链
const payslips = await prisma.payslip.findMany({ where: { confirmedAt: { not: null } }, select: { id: true, employeeId: true, confirmedAt: true } })
for (const p of payslips) {
const events = [{ action: '工资条确认', timestamp: p.confirmedAt!.toISOString(), ip: '127.0.0.1', userAgent: 'employee-portal' }]
const hash = sha256(JSON.stringify(events) + orgId + 'PAYSLIP_CONFIRM' + p.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'PAYSLIP_CONFIRM', refId: p.id, employeeId: p.employeeId, events: events as any, hash, createdBy: p.employeeId, createdAt: p.confirmedAt! },
}).catch(() => {})
count++
}
console.log('PAYSLIP_CONFIRM:', payslips.length)
// 4. 违纪记录证据链
const disciplinaries = await prisma.disciplinaryRecord.findMany({ select: { id: true, employeeId: true, createdAt: true, violationType: true, severity: true } })
const violationMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', OTHER: '其他违规' }
for (const d of disciplinaries) {
const desc = violationMap[d.violationType] || d.violationType
const events = [{ action: `违纪记录创建(${desc}`, timestamp: d.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }]
const hash = sha256(JSON.stringify(events) + orgId + 'DISCIPLINARY' + d.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'DISCIPLINARY', refId: d.id, employeeId: d.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: d.createdAt },
}).catch(() => {})
count++
}
console.log('DISCIPLINARY:', disciplinaries.length)
// 5. 解聘证据链
const terminations = await prisma.terminationRecord.findMany({ select: { id: true, employeeId: true, createdAt: true, reason: true, status: true } })
const reasonMap: Record<string, string> = { NEGOTIATED: '协商解除', UNILATERAL: '单方解除', EXPIRY: '合同到期', RESIGNATION: '员工辞职', OTHER: '其他' }
const statusMap: Record<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTED: '已执行', CANCELLED: '已撤销' }
for (const t of terminations) {
const reasonText = reasonMap[t.reason] || t.reason
const statusText = statusMap[t.status] || t.status
const events = [{ action: `解聘流程(${reasonText}·${statusText}`, timestamp: t.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }]
const hash = sha256(JSON.stringify(events) + orgId + 'TERMINATION' + t.id)
await prisma.evidenceChain.create({
data: { orgId, category: 'TERMINATION', refId: t.id, employeeId: t.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: t.createdAt },
}).catch(() => {})
count++
}
console.log('TERMINATION:', terminations.length)
const total = await prisma.evidenceChain.count()
console.log(`\n总计插入 ${count} 条,数据库现有 ${total} 条证据链`)
await prisma.$disconnect()
}
main().catch(console.error)
+227
View File
@@ -0,0 +1,227 @@
/**
* 补充规章制度示例数据
* 运行: npx tsx scripts/seed-policies.ts
*/
import prisma from '../src/lib/prisma'
async function main() {
const orgId = 'cmry97x7l0000trrp5f9jgng5'
const userId = 'system-seed'
const policies = [
{
id: 'pol_seed_1',
title: '考勤与加班管理制度',
content: `第一章 总则
第一条 为规范公司考勤管理,维护正常工作秩序,根据《劳动法》《劳动合同法》及相关法规,制定本制度。
第二条 本制度适用于公司全体员工。
第二章 工作时间
第三条 公司实行标准工时制,每日工作时间不超过8小时,每周不超过40小时。
第四条 工作时间为周一至周五 9:00-18:00,午休时间 12:00-13:00。
第三章 加班管理
第五条 加班需经部门负责人书面审批,未经审批的自行延长工作时间不视为加班。
第六条 工作日加班按不低于工资的150%支付加班费;休息日加班按不低于工资的200%支付加班费;法定节假日加班按不低于工资的300%支付加班费。
第七条 每月加班时间不得超过36小时,怀孕女职工及哺乳期女职工不得安排加班。
第四章 考勤记录
第八条 公司采用指纹打卡方式记录考勤,员工应按时上下班打卡。
第九条 迟到、早退每次扣罚50元;当月累计迟到早退超过5次,按旷工一天处理。
第五章 请假制度
第十条 员工请假需提前填写请假单,经主管审批后交人事部备案。
第十一条 病假需提供医院证明,按国家规定支付病假工资。
第六章 附则
第十二条 本制度经职工代表大会讨论通过后公示执行。
第十三条 本制度自公示之日起施行。`,
type: 'RULES',
status: 'PUBLISHED',
currentStep: 4,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-06-01', note: '完成初稿' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-06-10', note: '职工代表大会讨论通过' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'COMPLETED', date: '2026-06-15', note: '与工会代表协商一致' },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'COMPLETED', date: '2026-06-20', note: '全员邮件公示并签收' },
],
publishedAt: new Date('2026-06-20'),
createdAt: new Date('2026-06-01'),
},
{
id: 'pol_seed_2',
title: '员工奖惩管理制度',
content: `第一章 总则
第一条 为维护公司正常经营管理秩序,激励员工遵纪守法,制定本制度。
第二条 本制度适用于公司全体员工。
第二章 奖励
第三条 奖励分为:通报表扬、嘉奖、记功、记大功四种。
第四条 有下列情形之一的,给予奖励:
(一)在工作中有重大创新,为公司创造显著经济效益的;
(二)为公司挽回重大经济损失的;
(三)见义勇为,保护公司财产和员工安全的;
(四)连续年度考核优秀的。
第三章 惩戒
第五条 违纪行为分为:轻微违纪、一般违纪、严重违纪。
第六条 严重违纪包括但不限于:
(一)旷工连续3天以上或年内累计5天以上的;
(二)严重违反工作纪律,影响生产经营的;
(三)泄露公司商业秘密的;
(四)在工作场所打架斗殴的;
(五)利用职务便利索取、收受贿赂的。
第七条 严重违纪可以解除劳动合同,且无需支付经济补偿。
第四章 程序
第八条 对员工的奖惩应当事实清楚、证据确凿,并告知员工本人。
第九条 员工对惩戒决定有异议的,可以自收到通知之日起5个工作日内向人事部申诉。
第五章 附则
第十条 本制度经民主程序制定并公示后执行。`,
type: 'RULES',
status: 'PUBLISHED',
currentStep: 4,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-06-05', note: '完成初稿' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-06-12', note: '职工讨论收集意见' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'COMPLETED', date: '2026-06-18', note: '协商修改后定稿' },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'COMPLETED', date: '2026-06-22', note: '公告栏及邮件公示' },
],
publishedAt: new Date('2026-06-22'),
createdAt: new Date('2026-06-05'),
},
{
id: 'pol_seed_3',
title: '薪酬与绩效考核制度',
content: `第一章 总则
第一条 为建立公平、公正、公开的薪酬体系,激励员工提升绩效,制定本制度。
第二条 薪酬结构包括:基本工资、岗位工资、绩效奖金、津贴补贴。
第二章 薪酬构成
第三条 基本工资不低于当地最低工资标准。
第四条 岗位工资根据岗位等级确定,具体标准见附件。
第五条 绩效奖金根据月度/季度/年度考核结果发放,考核等级分为A(优秀)、B(良好)、C(合格)、D(不合格)四档。
第三章 绩效考核
第六条 考核周期分为月度考核、季度考核和年度考核。
第七条 月度考核于次月5日前完成,季度考核于次季首月10日前完成,年度考核于次年1月15日前完成。
第八条 考核结果作为晋升、调薪、奖金发放的依据。
第九条 连续两次年度考核为D的,公司可以调整其岗位或依法解除劳动合同。
第四章 薪酬调整
第十条 公司每年根据经营状况和市场水平进行薪酬回顾,适时调整薪酬标准。
第十一条 员工岗位变动时,薪酬自变动次月起相应调整。
第五章 附则
第十二条 本制度经民主程序制定并公示后执行。`,
type: 'RULES',
status: 'CONSULTING',
currentStep: 3,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-07-01', note: '完成初稿' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-07-08', note: '收集到15条修改建议' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'IN_PROGRESS', date: '2026-07-15', note: '正在与工会代表协商中' },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null },
],
publishedAt: null,
createdAt: new Date('2026-07-01'),
},
{
id: 'pol_seed_4',
title: '员工出差与费用报销制度',
content: `第一章 总则
第一条 为规范出差管理和费用报销流程,制定本制度。
第二章 出差审批
第二条 员工出差需提前填写出差申请单,经部门负责人审批。
第三条 出差天数3天以上的需分管副总审批,7天以上的需总经理审批。
第三章 费用标准
第四条 交通费标准:
(一)飞机:总监及以上职级可乘坐商务舱,其他职级乘坐经济舱;
(二)火车:总监及以上职级可乘坐一等座,其他职级乘坐二等座;
(三)市内交通:实报实销,每日不超过100元。
第五条 住宿费标准(元/天):
(一)一线城市:总监级800元,经理级500元,员工级350元;
(二)其他城市:总监级600元,经理级400元,员工级300元。
第六条 餐费补贴:出差期间每人每天补贴80元,不再另行报销餐费。
第四章 报销流程
第七条 出差结束后5个工作日内提交报销单据,逾期不予报销。
第八条 报销单据需附发票原件,经部门负责人和财务部审核后支付。
第五章 附则
第九条 本制度经民主程序制定并公示后执行。`,
type: 'RULES',
status: 'DISCUSSING',
currentStep: 2,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-07-10', note: '完成初稿' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'IN_PROGRESS', date: '2026-07-18', note: '正在征求各部门意见' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'PENDING', date: null, note: null },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null },
],
publishedAt: null,
createdAt: new Date('2026-07-10'),
},
{
id: 'pol_seed_5',
title: '保密与竞业限制制度',
content: `第一章 总则
第一条 为保护公司商业秘密和知识产权,制定本制度。
第二条 商业秘密包括:客户名单、技术方案、财务数据、经营策略、薪酬信息等。
第二章 保密义务
第三条 员工在职期间及离职后均负有保密义务,不得泄露、使用或允许他人使用公司商业秘密。
第四条 员工应妥善保管涉密文件,离开工作岗位时应将涉密材料锁好或交还相关部门。
第五条 违反保密义务的,公司可以解除劳动合同并要求赔偿损失。
第三章 竞业限制
第六条 对负有保密义务的高级管理人员、高级技术人员和其他知悉商业秘密的员工,可以约定竞业限制条款。
第七条 竞业限制期限不超过两年,竞业限制期间公司按月支付竞业限制补偿金,标准为离职前12个月平均工资的30%。
第八条 违反竞业限制的,应向公司支付竞业限制违约金,并继续履行竞业限制义务。
第四章 附则
第九条 本制度经民主程序制定并公示后执行。`,
type: 'RULES',
status: 'DRAFT',
currentStep: 1,
steps: [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'IN_PROGRESS', date: '2026-07-20', note: '初稿起草中' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'PENDING', date: null, note: null },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'PENDING', date: null, note: null },
{ step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null },
],
publishedAt: null,
createdAt: new Date('2026-07-20'),
},
]
for (const p of policies) {
await prisma.policyDocument.upsert({
where: { id: p.id },
create: {
id: p.id,
orgId,
title: p.title,
content: p.content,
type: p.type,
status: p.status,
democracyProgress: { currentStep: p.currentStep, steps: p.steps } as any,
publishedAt: p.publishedAt,
createdBy: userId,
createdAt: p.createdAt,
},
update: {},
})
console.log(`Inserted: ${p.title} (${p.status})`)
}
const total = await prisma.policyDocument.count()
console.log(`\n总计 ${total} 条规章制度`)
await prisma.$disconnect()
}
main().catch(console.error)
+10
View File
@@ -49,6 +49,11 @@ import attachmentRoutes from './routes/attachment.routes'
import rosterRoutes from './routes/roster.routes'
import exportRoutes from './routes/export.routes'
import importRoutes from './routes/import.routes'
import evidenceRoutes from './routes/evidence.routes'
import policyRoutes from './routes/policy.routes'
import attendanceRoutes from './routes/attendance.routes'
import templateRoutes from './routes/template.routes'
import auditRoutes from './routes/audit.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -64,6 +69,11 @@ app.use('/api/v1/attachments', attachmentRoutes)
app.use('/api/v1/roster', rosterRoutes)
app.use('/api/v1/export', exportRoutes)
app.use('/api/v1/import', importRoutes)
app.use('/api/v1/evidence', evidenceRoutes)
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(errorHandler)
+146
View File
@@ -0,0 +1,146 @@
/**
* 验证码存储服务
* 支持两种模式:
* 1. Redis(生产环境,REDIS_URL 环境变量存在时自动启用)
* 2. 内存 Map(开发环境 fallback
*
* 功能:存储、验证、过期清理、失败次数限制
*/
interface CodeEntry {
code: string
expiresAt: number
failCount: number
lastSentAt: number
}
// 内存存储 fallback
const memoryStore = new Map<string, CodeEntry>()
// 是否启用 Redis
const REDIS_URL = process.env.REDIS_URL || ''
const useRedis = !!REDIS_URL
// Redis 客户端(懒加载)
let redisClient: any = null
async function getRedis() {
if (!redisClient && useRedis) {
try {
const { createClient } = await import('redis')
redisClient = createClient({ url: REDIS_URL })
redisClient.on('error', (err: any) => console.error('[Redis] error:', err))
await redisClient.connect()
console.log('[Redis] 验证码存储已连接')
} catch (err) {
console.warn('[Redis] 连接失败,降级为内存存储:', err)
return null
}
}
return redisClient
}
const KEY_PREFIX = 'vcode:'
const TTL_SECONDS = 300 // 5 分钟
/**
* 存储验证码
*/
export async function setCode(key: string, code: string, ttlMs: number = 5 * 60 * 1000): Promise<void> {
const entry: CodeEntry = {
code,
expiresAt: Date.now() + ttlMs,
failCount: 0,
lastSentAt: Date.now(),
}
const redis = await getRedis()
if (redis) {
try {
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: ttlMs })
return
} catch (err) {
console.warn('[Redis] set 失败,降级内存:', err)
}
}
memoryStore.set(key, entry)
}
/**
* 获取验证码条目
*/
export async function getCode(key: string): Promise<CodeEntry | null> {
const redis = await getRedis()
if (redis) {
try {
const raw = await redis.get(`${KEY_PREFIX}${key}`)
if (!raw) return null
const entry = JSON.parse(raw) as CodeEntry
if (entry.expiresAt < Date.now()) {
await redis.del(`${KEY_PREFIX}${key}`)
return null
}
return entry
} catch (err) {
console.warn('[Redis] get 失败,降级内存:', err)
}
}
const entry = memoryStore.get(key)
if (!entry) return null
if (entry.expiresAt < Date.now()) {
memoryStore.delete(key)
return null
}
return entry
}
/**
* 更新验证码条目(如递增失败次数)
*/
export async function updateCode(key: string, updates: Partial<CodeEntry>): Promise<void> {
const redis = await getRedis()
if (redis) {
try {
const raw = await redis.get(`${KEY_PREFIX}${key}`)
if (raw) {
const entry = { ...JSON.parse(raw), ...updates }
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: TTL_SECONDS * 1000 })
}
return
} catch (err) {
console.warn('[Redis] update 失败,降级内存:', err)
}
}
const entry = memoryStore.get(key)
if (entry) {
Object.assign(entry, updates)
}
}
/**
* 删除验证码
*/
export async function deleteCode(key: string): Promise<void> {
const redis = await getRedis()
if (redis) {
try {
await redis.del(`${KEY_PREFIX}${key}`)
return
} catch (err) {
console.warn('[Redis] del 失败,降级内存:', err)
}
}
memoryStore.delete(key)
}
/**
* 检查发送频率限制(60秒内不可重复发送)
*/
export async function checkRateLimit(key: string, intervalMs: number = 60 * 1000): Promise<boolean> {
const entry = await getCode(key)
if (entry && entry.lastSentAt && Date.now() - entry.lastSentAt < intervalMs) {
return false // 被限流
}
return true // 允许发送
}
+137
View File
@@ -4,6 +4,7 @@ import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisks
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
import { decrypt } from '../lib/crypto'
const router = Router()
@@ -542,4 +543,140 @@ router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) =>
}
})
// ========== 合同到期决策助手 ==========
router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeId } = req.body as { employeeId: string }
if (!employeeId) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
}
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId: req.user!.orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
if (!emp) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const contract = emp.contracts[0]
if (!contract || !contract.endDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该员工无固定期限合同或未签合同,不适用到期决策' } })
}
// 解密薪资
let salary = 0
try { salary = Number(decrypt(emp.monthlySalary)) || 0 } catch { salary = Number(emp.monthlySalary) || 0 }
const today = new Date()
const hireDate = emp.hireDate
const endDate = contract.endDate
const totalMonths = (endDate.getFullYear() - hireDate.getFullYear()) * 12 + (endDate.getMonth() - hireDate.getMonth())
const years = Math.floor(totalMonths / 12)
const remainingMonths = totalMonths % 12
let n = years
if (remainingMonths >= 6) n = years + 1
else if (remainingMonths > 0) n = years + 0.5
if (n <= 0) n = 0.5
const daysToExpiry = Math.ceil((endDate.getTime() - today.getTime()) / 86400000)
// 三选项成本对比
const options = [
{
key: 'RENEW',
title: '续签合同',
cost: 0,
description: '与员工续签劳动合同,保持劳动关系延续',
legalRisk: '低',
details: {
compensation: 0,
noticePeriod: '无需通知',
notes: '续签时如维持或提高条件,员工拒绝则无需补偿;降低条件员工拒绝需支付 N',
},
},
{
key: 'EXPIRE_NO_RENEW',
title: '到期不续签',
cost: salary * n,
description: '合同到期后公司决定不续签,需支付经济补偿金 N',
legalRisk: '中',
details: {
compensation: salary * n,
n,
monthlySalary: salary,
noticePeriod: '建议提前30天书面通知',
notes: '公司提出不续签需支付经济补偿金(N);员工主动提出不续签则无需支付',
},
},
{
key: 'EXPIRE_WAIT',
title: '逾期不处理(风险最高)',
cost: salary * (n + 1),
description: '合同到期后继续用工但不签新合同,可能被认定为事实劳动关系',
legalRisk: '高',
details: {
compensation: salary * (n + 1),
n,
monthlySalary: salary,
notes: '逾期超过1个月未续签,员工可主张双倍工资;满1年视为已订立无固定期限劳动合同',
},
},
]
// 构建 AI 建议请求
const decisionContext = `员工合同到期决策分析:
- 姓名:${emp.name}
- 部门:${emp.department}
- 入职日期:${hireDate.toISOString().slice(0, 10)}
- 合同到期日:${endDate.toISOString().slice(0, 10)}(距今${daysToExpiry}天)
- 月薪:¥${salary.toFixed(2)}
- 工龄:${years}${remainingMonths}月(经济补偿月数 N=${n}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
三选项成本对比:
1. 续签:成本 ¥0
2. 到期不续签:成本 ¥${(salary * n).toFixed(2)}(经济补偿 N=${n}
3. 逾期不处理:风险成本 ¥${(salary * (n + 1)).toFixed(2)}(双倍工资风险)
请给出专业建议,分析每个选项的法律风险和实际影响,推荐最优方案。`
let aiAdvice = ''
try {
await checkUsageLimit(req.user!.orgId, 'chat')
const result = await chat([{ role: 'user', content: decisionContext }], '')
aiAdvice = result
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
} catch {
aiAdvice = 'AI 建议生成失败,请参考以上成本对比数据自行判断。'
}
res.json({
success: true,
data: {
employee: {
id: emp.id,
name: emp.name,
department: emp.department,
hireDate: hireDate.toISOString().slice(0, 10),
contractEndDate: endDate.toISOString().slice(0, 10),
daysToExpiry,
monthlySalary: salary,
workYears: years,
workMonths: remainingMonths,
n,
isPregnant: emp.isPregnant,
isInMedicalPeriod: emp.isInMedicalPeriod,
isWorkInjured: emp.isWorkInjured,
},
options,
aiAdvice,
},
})
} catch (err) {
next(err)
}
})
export default router
+94
View File
@@ -0,0 +1,94 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
import {
createAttendanceConfirmation,
batchCreateAttendanceConfirmations,
getAttendanceConfirmations,
confirmAttendance,
getAttendanceStats,
} from '../services/attendance.service'
import { createEvidence } from '../services/evidence.service'
const router = Router()
/** 获取月度考勤确认列表 */
router.get('/', 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 status = req.query.status as string | undefined
const data = await getAttendanceConfirmations(req.user!.orgId, month, status)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
/** 获取考勤确认统计 */
router.get('/stats', 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 getAttendanceStats(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
/** 批量创建考勤确认记录 */
router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
items: z.array(z.object({
employeeId: z.string(),
workDays: z.number().int().min(0),
weekdayHours: z.number().min(0),
weekendHours: z.number().min(0),
holidayHours: z.number().min(0),
overtimePay: z.number().min(0),
})),
})
const { month, items } = schema.parse(req.body)
const result = await batchCreateAttendanceConfirmations(req.user!.orgId, req.user!.id, month, items)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
/** 员工确认考勤(员工端) */
router.post('/confirm', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
employeeId: z.string(),
month: z.string().regex(/^\d{4}-\d{2}$/),
disputeNote: z.string().optional(),
})
const { employeeId, month, disputeNote } = schema.parse(req.body)
const ip = req.ip || req.socket.remoteAddress || ''
const result = await confirmAttendance(req.user!.orgId, employeeId, month, ip, disputeNote)
await createEvidence({
orgId: req.user!.orgId,
category: 'ATTENDANCE',
refId: result.id,
employeeId,
events: [{ action: '考勤确认', timestamp: new Date().toISOString(), ip, userAgent: req.headers['user-agent'], ...(disputeNote ? { location: disputeNote } : {}) }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
+80
View File
@@ -0,0 +1,80 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
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 action = req.query.action as string | undefined
const entity = req.query.entity as string | undefined
const userId = req.query.userId as string | undefined
const dateFrom = req.query.dateFrom as string | undefined
const dateTo = req.query.dateTo as string | undefined
const where: any = { orgId: req.user!.orgId }
if (action) where.action = { contains: action, mode: 'insensitive' }
if (entity) where.entity = entity
if (userId) where.userId = userId
if (dateFrom || dateTo) {
where.createdAt = {}
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
if (dateTo) where.createdAt.lte = new Date(`${dateTo}T23:59:59`)
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.auditLog.count({ where }),
])
res.json({
success: true,
data: { items: logs, total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
})
} catch (err) {
next(err)
}
})
/**
* 获取审计日志统计(按操作类型分组)
*/
router.get('/stats', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const dateFrom = req.query.dateFrom as string | undefined
const dateTo = req.query.dateTo as string | undefined
const where: any = { orgId: req.user!.orgId }
if (dateFrom || dateTo) {
where.createdAt = {}
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
if (dateTo) where.createdAt.lte = new Date(`${dateTo}T23:59:59`)
}
const stats = await prisma.auditLog.groupBy({
by: ['action'],
where,
_count: { action: true },
orderBy: { _count: { action: 'desc' } },
take: 20,
})
res.json({ success: true, data: stats })
} catch (err) {
next(err)
}
})
export default router
+8 -9
View File
@@ -4,11 +4,10 @@ import { register, login, refresh, resetPassword } from '../services/auth.servic
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
import prisma from '../lib/prisma'
import bcrypt from 'bcryptjs'
import { setCode, getCode, deleteCode } from '../lib/codeStore'
const router = Router()
const codeStore = new Map<string, { code: string; expiresAt: number }>()
router.post('/register', authLimiter, async (req, res, next) => {
try {
const data = registerSchema.parse(req.body)
@@ -48,7 +47,7 @@ router.post('/forgot-password/send-code', authLimiter, async (req, res, next) =>
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未注册' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
await setCode(data.phone, code)
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
@@ -59,14 +58,14 @@ router.post('/forgot-password/send-code', authLimiter, async (req, res, next) =>
router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
try {
const data = verifyCodeSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
const stored = await getCode(data.phone)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.code !== data.code) {
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
}
codeStore.delete(data.phone)
await deleteCode(data.phone)
const passwordHash = await bcrypt.hash(data.newPassword, 10)
await prisma.user.updateMany({
where: { phone: data.phone },
@@ -81,11 +80,11 @@ router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
router.post('/reset-password', authLimiter, async (req, res, next) => {
try {
const data = resetPasswordSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
const stored = await getCode(data.phone)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
codeStore.delete(data.phone)
await deleteCode(data.phone)
const result = await resetPassword(data.phone, data.newPassword)
res.json({ success: true, data: result })
} catch (err) {
+95 -1
View File
@@ -1,7 +1,7 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getDashboardData } from '../services/risk.service'
import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getComplianceScore, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service'
import { z } from 'zod'
const router = Router()
@@ -77,4 +77,98 @@ router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res
}
})
// HR 月度日历
router.get('/calendar', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const data = await getMonthlyCalendar(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 人力成本深度分析
router.get('/cost-analysis', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const data = await getCostAnalysis(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 合规健康度评分 + AI 建议卡片流
router.get('/compliance-score', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getComplianceScore(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 获取当前诊断
router.get('/health-check', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getHealthCheck(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 保存报告
router.post('/health-check/save', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const report = await saveHealthCheckReport(req.user!.orgId, req.user!.id)
res.json({ success: true, data: { id: (report as any).id } })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 历史报告
router.get('/health-check/history', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getHealthCheckHistory(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 获取当年报告
router.get('/annual-value', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const year = parseInt(req.query.year as string) || new Date().getFullYear()
const data = await getAnnualValueReport(req.user!.orgId, year)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 保存
router.post('/annual-value/save', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const year = parseInt(req.body.year) || new Date().getFullYear()
const report = await saveAnnualValueReport(req.user!.orgId, req.user!.id, year)
res.json({ success: true, data: { id: (report as any).id } })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 历史
router.get('/annual-value/history', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getAnnualValueReportHistory(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
export default router
+17
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import {
createEmployeeSchema,
@@ -49,6 +50,14 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
const data = createEmployeeSchema.parse(req.body)
const result = await createEmployee(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'CREATE', 'EMPLOYEE', result.id, { name: data.name })
await createEvidence({
orgId: req.user!.orgId,
category: 'ONBOARD',
refId: result.id,
employeeId: result.id,
events: [{ action: '员工入职登记', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err) {
next(err)
@@ -199,6 +208,14 @@ router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) =>
const data = addContractSchema.parse(req.body)
const result = await addContract(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'ADD_CONTRACT', 'CONTRACT', result.id, { employeeId: data.employeeId })
await createEvidence({
orgId: req.user!.orgId,
category: 'CONTRACT_SIGN',
refId: result.id,
employeeId: data.employeeId,
events: [{ action: '合同签订', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err) {
next(err)
+88
View File
@@ -0,0 +1,88 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getEvidenceByRef, getEvidenceByEmployee, getEvidenceDetail, verifyEvidence, getEvidenceList, verifyAllEvidence } from '../services/evidence.service'
const router = Router()
/**
* 获取组织级证据链列表
* GET /api/v1/evidence?category=ALL&page=1&pageSize=20
*/
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
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 data = await getEvidenceList(req.user!.orgId, category, page, pageSize)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
/**
* 验证全部证据链完整性
* GET /api/v1/evidence/verify-all
*/
router.get('/verify-all', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await verifyAllEvidence(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
/**
* 获取某操作的完整证据链
* GET /api/v1/evidence/:category/:refId
*/
router.get('/:category/:refId', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const evidence = await getEvidenceByRef(req.user!.orgId, req.params.category, req.params.refId)
res.json({ success: true, data: evidence })
} catch (err) {
next(err)
}
})
/**
* 获取某员工所有证据链
* GET /api/v1/evidence/employee/:id
*/
router.get('/employee/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const evidence = await getEvidenceByEmployee(req.user!.orgId, req.params.id)
res.json({ success: true, data: evidence })
} catch (err) {
next(err)
}
})
/**
* 获取证据链详情
* GET /api/v1/evidence/detail/:id
*/
router.get('/detail/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const evidence = await getEvidenceDetail(req.user!.orgId, req.params.id)
res.json({ success: true, data: evidence })
} catch (err) {
next(err)
}
})
/**
* 验证证据链完整性
* GET /api/v1/evidence/verify/:id
*/
router.get('/verify/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const result = await verifyEvidence(req.user!.orgId, req.params.id)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
export default router
+14
View File
@@ -8,6 +8,7 @@ import {
calcBatchEntry,
getPayrollRiskWarnings,
generatePayslipFromBatches,
prePayrollCheck,
} from '../services/payroll.service'
const router = Router()
@@ -670,4 +671,17 @@ router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, n
}
})
// 算薪前 AI 校验
router.get('/batches/:batchId/pre-check', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const result = await prePayrollCheck(req.user!.orgId, req.params.batchId)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
+123
View File
@@ -0,0 +1,123 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
import prisma from '../lib/prisma'
import {
createPolicy, getPolicies, getPolicyDetail, updatePolicy,
advanceDemocracyStep, deletePolicy,
} from '../services/policy.service'
const router = Router()
/** 获取制度列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const status = req.query.status as string | undefined
const policies = await getPolicies(req.user!.orgId, status)
res.json({ success: true, data: policies })
} catch (err) {
next(err)
}
})
/** 获取制度详情 */
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const policy = await getPolicyDetail(req.user!.orgId, req.params.id)
res.json({ success: true, data: policy })
} catch (err) {
next(err)
}
})
/** 创建制度 */
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
title: z.string().min(1),
content: z.string().min(1),
type: z.string().optional(),
})
const data = schema.parse(req.body)
const policy = await createPolicy(req.user!.orgId, req.user!.id, data)
res.json({ success: true, data: { id: policy.id } })
} catch (err) {
next(err)
}
})
/** 更新制度 */
router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
title: z.string().optional(),
content: z.string().optional(),
type: z.string().optional(),
})
const data = schema.parse(req.body)
await updatePolicy(req.user!.orgId, req.params.id, data)
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 推进民主程序步骤 */
router.post('/:id/advance-step', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ step: z.number().int().min(1).max(4), note: z.string().optional() })
const { step, note } = schema.parse(req.body)
await advanceDemocracyStep(req.user!.orgId, req.params.id, step, note)
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 删除制度 */
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await deletePolicy(req.user!.orgId, req.params.id)
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 获取制度的阅读签收统计 */
router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true } })
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
}
const [totalEmployees, readRecords] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
prisma.policyReadRecord.findMany({
where: { policyId: req.params.id, orgId },
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { readAt: 'desc' },
}),
])
res.json({
success: true,
data: {
total: totalEmployees,
readCount: readRecords.length,
unreadCount: totalEmployees - readRecords.length,
records: readRecords.map(r => ({
employeeId: r.employeeId,
employeeName: r.employee.name,
department: r.employee.department,
readAt: r.readAt.toISOString(),
ip: r.ip,
})),
},
})
} catch (err) {
next(err)
}
})
export default router
+141 -19
View File
@@ -6,12 +6,11 @@ import fs from 'fs'
import prisma from '../lib/prisma'
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
import { createEvidence } from '../services/evidence.service'
const router = Router()
// 验证码临时存储(生产环境应使用 Redis)
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: number }>()
// 员工端认证中间件
function portalAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
@@ -63,12 +62,12 @@ router.post('/send-code', async (req, res, next) => {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
}
// 频率限制:60秒内不可重复发送
const existing = codeStore.get(data.phone)
if (existing && existing.lastSentAt && Date.now() - existing.lastSentAt < 60 * 1000) {
const allowed = await checkRateLimit(data.phone)
if (!allowed) {
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
await setCode(data.phone, code)
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
@@ -79,20 +78,20 @@ router.post('/send-code', async (req, res, next) => {
router.post('/verify-code', async (req, res, next) => {
try {
const data = portalVerifyCodeSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
const stored = await getCode(data.phone)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
// 错误次数限制:5次后锁定
if (stored.failCount >= 5) {
codeStore.delete(data.phone)
await deleteCode(data.phone)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.code) {
stored.failCount++
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
await updateCode(data.phone, { failCount: stored.failCount + 1 })
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
}
codeStore.delete(data.phone)
await deleteCode(data.phone)
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
if (!employee) {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
@@ -158,6 +157,14 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
channel: 'IN_APP',
},
})
await createEvidence({
orgId: req.employee.orgId,
category: 'PAYSLIP_CONFIRM',
refId: payslip.id,
employeeId: req.employee.id,
events: [{ action: '工资条确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.employee.id,
}).catch(() => {})
res.json({ success: true })
} catch (err) {
next(err)
@@ -231,7 +238,7 @@ router.post('/contract-confirm/send-code', async (req, res, next) => {
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(`contract-${data.token}`, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
await setCode(`contract-${data.token}`, code)
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
@@ -250,19 +257,19 @@ router.post('/contract-confirm', async (req, res, next) => {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
// 验证码校验
const stored = codeStore.get(`contract-${data.token}`)
if (!stored || stored.expiresAt < Date.now()) {
const stored = await getCode(`contract-${data.token}`)
if (!stored) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.failCount >= 5) {
codeStore.delete(`contract-${data.token}`)
await deleteCode(`contract-${data.token}`)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.verifyCode) {
stored.failCount++
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
await updateCode(`contract-${data.token}`, { failCount: stored.failCount + 1 })
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
}
codeStore.delete(`contract-${data.token}`)
await deleteCode(`contract-${data.token}`)
const userAgent = req.headers['user-agent'] || ''
const signEvidence = JSON.stringify({
@@ -278,6 +285,14 @@ router.post('/contract-confirm', async (req, res, next) => {
where: { id: link.contractId },
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
})
await createEvidence({
orgId: link.contract.orgId,
category: 'CONTRACT_SIGN',
refId: link.contractId,
employeeId: link.contract.employeeId,
events: [{ action: '合同签署确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent, smsCode: data.verifyCode }],
createdBy: link.contract.employeeId,
}).catch(() => {})
res.json({ success: true, data: { message: '合同签署确认成功' } })
} catch (err) {
next(err)
@@ -422,4 +437,111 @@ router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async
}
})
// ========== 员工端:规章制度公示 ==========
/** 获取已公示制度列表(含当前员工阅读状态) */
router.get('/policies', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policies = await prisma.policyDocument.findMany({
where: { orgId, status: 'PUBLISHED' },
orderBy: { publishedAt: 'desc' },
select: {
id: true,
title: true,
type: true,
publishedAt: true,
readRecords: {
where: { employeeId },
select: { id: true, readAt: true },
},
},
})
const result = policies.map(p => ({
id: p.id,
title: p.title,
type: p.type,
publishedAt: p.publishedAt?.toISOString() || null,
hasRead: p.readRecords.length > 0,
readAt: p.readRecords[0]?.readAt?.toISOString() || null,
}))
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
/** 获取制度详情(已公示) */
router.get('/policies/:id', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policy = await prisma.policyDocument.findFirst({
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
select: {
id: true,
title: true,
content: true,
type: true,
publishedAt: true,
readRecords: {
where: { employeeId },
select: { id: true, readAt: true },
},
},
})
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
}
res.json({
success: true,
data: {
id: policy.id,
title: policy.title,
content: policy.content,
type: policy.type,
publishedAt: policy.publishedAt?.toISOString() || null,
hasRead: policy.readRecords.length > 0,
readAt: policy.readRecords[0]?.readAt?.toISOString() || null,
},
})
} catch (err) {
next(err)
}
})
/** 提交阅读确认(签收) */
router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const { orgId, id: employeeId } = (req as any).employee
const policy = await prisma.policyDocument.findFirst({
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
select: { id: true },
})
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
}
// 幂等:已存在则返回已有记录
const existing = await prisma.policyReadRecord.findUnique({
where: { policyId_employeeId: { policyId: req.params.id, employeeId } },
})
if (existing) {
return res.json({ success: true, data: { readAt: existing.readAt.toISOString() } })
}
const record = await prisma.policyReadRecord.create({
data: {
policyId: req.params.id,
orgId,
employeeId,
ip: req.ip || req.socket.remoteAddress,
userAgent: req.headers['user-agent'] || null,
},
})
res.json({ success: true, data: { readAt: record.readAt.toISOString() } })
} catch (err) {
next(err)
}
})
export default router
+9
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import { decrypt, encrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
@@ -523,6 +524,14 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
},
})
await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId })
await createEvidence({
orgId: req.user!.orgId,
category: 'DISCIPLINARY',
refId: record.id,
employeeId: req.params.employeeId,
events: [{ action: '违纪记录创建', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
+45
View File
@@ -0,0 +1,45 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getAllTemplates, getTemplateById, getTemplatesByCategory, renderTemplate } from '../services/template.service'
const router = Router()
/** 获取所有模板列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const category = req.query.category as string | undefined
const templates = category ? getTemplatesByCategory(category) : getAllTemplates()
res.json({ success: true, data: templates })
} catch (err) {
next(err)
}
})
/** 获取模板详情 */
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const template = getTemplateById(req.params.id)
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
res.json({ success: true, data: template })
} catch (err) {
next(err)
}
})
/** 渲染模板 */
router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { variables } = req.body as { variables: Record<string, string> }
const content = renderTemplate(req.params.id, variables || {})
if (!content) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
res.json({ success: true, data: { content } })
} catch (err) {
next(err)
}
})
export default router
+32 -1
View File
@@ -2,8 +2,9 @@ import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems } from '../services/termination.service'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service'
import prisma from '../lib/prisma'
import { createEvidence } from '../services/evidence.service'
const router = Router()
@@ -63,6 +64,14 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
const data = terminationChecklistSchema.parse(req.body)
const result = await createTermination(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'TERMINATE', 'EMPLOYEE', data.employeeId, { reason: data.reason })
await createEvidence({
orgId: req.user!.orgId,
category: 'TERMINATION',
refId: result.id,
employeeId: data.employeeId,
events: [{ action: '解聘流程启动', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err) {
next(err)
@@ -246,6 +255,14 @@ router.post('/draft/:id/execute', authMiddleware, async (req: AuthRequest, res,
try {
const result = await executeTermination(req.user!.orgId, req.params.id, req.user!.id)
await auditLog(req, 'EXECUTE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
await createEvidence({
orgId: req.user!.orgId,
category: 'TERMINATION',
refId: req.params.id,
employeeId: (result as any)?.employeeId,
events: [{ action: '解聘执行', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
@@ -269,4 +286,18 @@ router.post('/draft/:id/cancel', authMiddleware, async (req: AuthRequest, res, n
}
})
// 步骤前置校验
router.get('/draft/:id/validate-step', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const step = parseInt(req.query.step as string) || 1
const result = await validateTerminationStep(req.user!.orgId, req.params.id, step)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
+150
View File
@@ -0,0 +1,150 @@
import prisma from '../lib/prisma'
/**
* 考勤确认服务
*/
/**
* 创建月度考勤确认记录
*/
export async function createAttendanceConfirmation(orgId: string, userId: string, data: {
employeeId: string
month: string
workDays: number
weekdayHours: number
weekendHours: number
holidayHours: number
overtimePay: number
}) {
const existing = await prisma.attendanceConfirmation.findUnique({
where: { orgId_employeeId_month: { orgId, employeeId: data.employeeId, month: data.month } },
})
if (existing) {
throw { code: 'CONFLICT', message: '该月考勤确认记录已存在' }
}
return prisma.attendanceConfirmation.create({
data: {
orgId,
employeeId: data.employeeId,
month: data.month,
workDays: data.workDays,
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
overtimePay: data.overtimePay,
createdBy: userId,
},
})
}
/**
* 批量创建月度考勤确认记录
*/
export async function batchCreateAttendanceConfirmations(orgId: string, userId: string, month: string, items: Array<{
employeeId: string
workDays: number
weekdayHours: number
weekendHours: number
holidayHours: number
overtimePay: number
}>) {
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
for (const item of items) {
try {
const existing = await prisma.attendanceConfirmation.findUnique({
where: { orgId_employeeId_month: { orgId, employeeId: item.employeeId, month } },
})
if (existing) {
// 更新已有记录
await prisma.attendanceConfirmation.update({
where: { id: existing.id },
data: {
workDays: item.workDays,
weekdayHours: item.weekdayHours,
weekendHours: item.weekendHours,
holidayHours: item.holidayHours,
overtimePay: item.overtimePay,
status: 'PENDING',
},
})
} else {
await prisma.attendanceConfirmation.create({
data: {
orgId,
employeeId: item.employeeId,
month,
workDays: item.workDays,
weekdayHours: item.weekdayHours,
weekendHours: item.weekendHours,
holidayHours: item.holidayHours,
overtimePay: item.overtimePay,
createdBy: userId,
},
})
}
results.push({ employeeId: item.employeeId, success: true })
} catch (err: any) {
results.push({ employeeId: item.employeeId, success: false, error: err.message })
}
}
return { total: items.length, success: results.filter(r => r.success).length, results }
}
/**
* 获取月度考勤确认列表
*/
export async function getAttendanceConfirmations(orgId: string, month: string, status?: string) {
const where: any = { orgId, month }
if (status) where.status = status
return prisma.attendanceConfirmation.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { employee: { name: 'asc' } },
})
}
/**
* 员工确认考勤(员工端)
*/
export async function confirmAttendance(orgId: string, employeeId: string, month: string, ip: string, disputeNote?: string) {
const record = await prisma.attendanceConfirmation.findUnique({
where: { orgId_employeeId_month: { orgId, employeeId, month } },
})
if (!record) {
throw { code: 'NOT_FOUND', message: '考勤确认记录不存在' }
}
if (disputeNote) {
// 有异议
return prisma.attendanceConfirmation.update({
where: { id: record.id },
data: { status: 'DISPUTED', disputeNote, confirmIp: ip },
})
}
// 确认无误
return prisma.attendanceConfirmation.update({
where: { id: record.id },
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmIp: ip },
})
}
/**
* 获取考勤确认统计
*/
export async function getAttendanceStats(orgId: string, month: string) {
const records = await prisma.attendanceConfirmation.findMany({
where: { orgId, month },
})
return {
total: records.length,
pending: records.filter(r => r.status === 'PENDING').length,
confirmed: records.filter(r => r.status === 'CONFIRMED').length,
disputed: records.filter(r => r.status === 'DISPUTED').length,
}
}
+174
View File
@@ -0,0 +1,174 @@
import prisma from '../lib/prisma'
import { sha256 } from '../lib/crypto'
/**
* 证据链服务 — 管理操作证据链,用于劳动仲裁举证
*/
export type EvidenceCategory =
| 'CONTRACT_SIGN'
| 'ONBOARD'
| 'PAYSLIP_CONFIRM'
| 'DISCIPLINARY'
| 'ATTENDANCE'
| 'TERMINATION'
/**
* 创建证据链记录
*/
export async function createEvidence(params: {
orgId: string
category: EvidenceCategory
refId?: string
employeeId?: string
events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }>
createdBy: string
}) {
const eventsJson = JSON.stringify(params.events)
const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || ''))
return prisma.evidenceChain.create({
data: {
orgId: params.orgId,
category: params.category,
refId: params.refId || null,
employeeId: params.employeeId || null,
events: params.events,
hash,
createdBy: params.createdBy,
},
})
}
/**
* 追加证据事件到已有证据链
*/
export async function appendEvidence(orgId: string, evidenceId: string, event: { action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }) {
const existing = await prisma.evidenceChain.findFirst({ where: { id: evidenceId, orgId } })
if (!existing) {
throw { code: 'NOT_FOUND', message: '证据链不存在' }
}
const events = [...(existing.events as any[]), event]
const eventsJson = JSON.stringify(events)
const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || ''))
return prisma.evidenceChain.update({
where: { id: evidenceId },
data: { events, hash },
})
}
/**
* 获取某操作的完整证据链
*/
export async function getEvidenceByRef(orgId: string, category: string, refId: string) {
return prisma.evidenceChain.findMany({
where: { orgId, category, refId },
orderBy: { createdAt: 'asc' },
})
}
/**
* 获取某员工所有证据链
*/
export async function getEvidenceByEmployee(orgId: string, employeeId: string) {
return prisma.evidenceChain.findMany({
where: { orgId, employeeId },
orderBy: { createdAt: 'desc' },
})
}
/**
* 获取证据链详情
*/
export async function getEvidenceDetail(orgId: string, id: string) {
const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } })
if (!evidence) {
throw { code: 'NOT_FOUND', message: '证据链不存在' }
}
return evidence
}
/**
* 验证证据链完整性(重新计算哈希对比)
*/
export async function verifyEvidence(orgId: string, id: string): Promise<{ valid: boolean; expectedHash: string; actualHash: string }> {
const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } })
if (!evidence) {
throw { code: 'NOT_FOUND', message: '证据链不存在' }
}
const eventsJson = JSON.stringify(evidence.events)
const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || ''))
return {
valid: expectedHash === evidence.hash,
expectedHash,
actualHash: evidence.hash,
}
}
/**
* 获取组织级证据链列表(支持按类别筛选)
*/
export async function getEvidenceList(orgId: string, category?: string, page: number = 1, pageSize: number = 20) {
const where: any = { orgId }
if (category && category !== 'ALL') {
where.category = category
}
const [total, records] = await Promise.all([
prisma.evidenceChain.count({ where }),
prisma.evidenceChain.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
])
return {
total,
page,
pageSize,
records: records.map((r: any) => {
const events = Array.isArray(r.events) ? r.events : []
const firstEvent = events.length > 0 ? events[0] : null
const lastEvent = events.length > 0 ? events[events.length - 1] : null
return {
id: r.id,
category: r.category,
refId: r.refId,
employeeId: r.employeeId,
employeeName: r.employee?.name || null,
employeeDept: r.employee?.department || null,
eventCount: events.length,
firstAction: firstEvent?.action || null,
lastAction: lastEvent?.action || null,
firstTimestamp: firstEvent?.timestamp || null,
lastTimestamp: lastEvent?.timestamp || null,
hash: r.hash,
hashShort: r.hash ? r.hash.substring(0, 16) : null,
createdAt: r.createdAt.toISOString(),
}
}),
}
}
/**
* 验证全部证据链完整性
*/
export async function verifyAllEvidence(orgId: string) {
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
let valid = 0
let invalid = 0
for (const r of records) {
const eventsJson = JSON.stringify(r.events)
const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || ''))
if (expectedHash === r.hash) valid++
else invalid++
}
return { total: records.length, valid, invalid }
}
+256
View File
@@ -351,3 +351,259 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
return { generated }
}
// ========== 算薪前 AI 校验 ==========
export interface PayrollCheckItem {
code: string
name: string
status: 'PASS' | 'FAIL' | 'WARNING'
message: string
employeeId?: string
employeeName?: string
detail?: any
}
export interface PayrollCheckResult {
checks: PayrollCheckItem[]
passedCount: number
failedCount: number
warningCount: number
}
/**
* 算薪前校验:检查 10+ 项异常,把错误拦截在发放前
*/
export async function prePayrollCheck(orgId: string, batchId: string): Promise<PayrollCheckResult> {
const batch = await prisma.payrollBatch.findFirst({
where: { id: batchId, orgId },
include: { entries: { include: { employee: true } } },
})
if (!batch) {
throw { code: 'NOT_FOUND', message: '工资批次不存在' }
}
const month = batch.month
const entries = batch.entries
const checks: PayrollCheckItem[] = []
// 获取社保公积金配置
const [socialConfig, housingConfig] = await Promise.all([
prisma.socialInsuranceConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
// 1. 社保基数是否在上下限范围内
if (socialConfig) {
for (const entry of entries) {
const base = entry.employee.socialInsBase || entry.baseSalary
if (base < socialConfig.baseMin || base > socialConfig.baseMax) {
checks.push({
code: 'SOCIAL_BASE_OUT_OF_RANGE',
name: '社保基数超出范围',
status: 'FAIL',
message: `${entry.employee.name} 的社保基数 ¥${base} 不在范围内(${socialConfig.baseMin} ~ ${socialConfig.baseMax}`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { base, min: socialConfig.baseMin, max: socialConfig.baseMax },
})
}
}
}
// 2. 公积金基数是否在上下限范围内
if (housingConfig) {
for (const entry of entries) {
const base = entry.employee.housingFundBase || entry.baseSalary
if (base < housingConfig.baseMin || base > housingConfig.baseMax) {
checks.push({
code: 'HOUSING_BASE_OUT_OF_RANGE',
name: '公积金基数超出范围',
status: 'FAIL',
message: `${entry.employee.name} 的公积金基数 ¥${base} 不在范围内(${housingConfig.baseMin} ~ ${housingConfig.baseMax}`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { base, min: housingConfig.baseMin, max: housingConfig.baseMax },
})
}
}
}
// 3. 加班时数是否超过法定上限(月 36 小时)
for (const entry of entries) {
const overtimeRecords = await prisma.overtimeRecord.findMany({
where: { orgId, employeeId: entry.employeeId, month },
})
for (const ot of overtimeRecords) {
const totalOT = ot.weekdayHours + ot.weekendHours + ot.holidayHours
if (totalOT > 36) {
checks.push({
code: 'OVERTIME_EXCEED_LIMIT',
name: '加班超法定上限',
status: 'WARNING',
message: `${entry.employee.name} 本月加班 ${totalOT} 小时,超过法定月上限 36 小时`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { totalHours: totalOT, limit: 36 },
})
}
}
}
// 4. 个税累计预扣跳档检测
const year = month.slice(0, 4)
for (const entry of entries) {
const prevPayslips = await prisma.payslip.findMany({
where: { orgId, employeeId: entry.employeeId, month: { startsWith: year, lt: month } },
select: { tax: true, totalPay: true },
})
if (prevPayslips.length >= 2) {
const avgTax = prevPayslips.reduce((s, p) => s + p.tax, 0) / prevPayslips.length
if (entry.tax > avgTax * 3 && entry.tax > 1000) {
checks.push({
code: 'TAX_BRACKET_JUMP',
name: '个税跳档警告',
status: 'WARNING',
message: `${entry.employee.name} 本月个税 ¥${entry.tax} 明显高于往月均值 ¥${avgTax.toFixed(0)},可能存在累计预扣跳档`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { currentTax: entry.tax, avgTax },
})
}
}
}
// 5. 试用期工资是否低于合同工资 80%
for (const entry of entries) {
const latestContract = await prisma.laborContract.findFirst({
where: { employeeId: entry.employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
if (latestContract && latestContract.probationMonths > 0 && latestContract.probationSalary > 0) {
const probationEnd = new Date(latestContract.startDate)
probationEnd.setMonth(probationEnd.getMonth() + latestContract.probationMonths)
if (probationEnd > new Date() && entry.baseSalary < latestContract.probationSalary * 0.8) {
checks.push({
code: 'PROBATION_SALARY_TOO_LOW',
name: '试用期工资低于法定下限',
status: 'FAIL',
message: `${entry.employee.name} 试用期工资 ¥${entry.baseSalary} 低于合同工资的 80%(¥${latestContract.probationSalary * 0.8}`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { actualSalary: entry.baseSalary, minSalary: latestContract.probationSalary * 0.8 },
})
}
}
}
// 6. 离职员工是否多算了一个月
for (const entry of entries) {
if (entry.employee.status === 'RESIGNED') {
const termination = await prisma.terminationRecord.findFirst({
where: { employeeId: entry.employeeId, status: { notIn: ['CANCELLED', 'DRAFT'] } },
orderBy: { terminationDate: 'desc' },
})
if (termination) {
const termMonth = termination.terminationDate.toISOString().slice(0, 7)
if (month > termMonth) {
checks.push({
code: 'RESIGNED_OVERPAY',
name: '离职员工多算工资',
status: 'FAIL',
message: `${entry.employee.name} 已于 ${termination.terminationDate.toISOString().slice(0, 10)} 离职,但 ${month} 仍有工资记录`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { terminationDate: termination.terminationDate, payrollMonth: month },
})
}
}
}
}
// 7. 新入职员工是否按实际入职日折算
for (const entry of entries) {
const hireDate = entry.employee.hireDate
const hireMonth = hireDate.toISOString().slice(0, 7)
if (hireMonth === month) {
const daysInMonth = new Date(hireDate.getFullYear(), hireDate.getMonth() + 1, 0).getDate()
const actualWorkDays = daysInMonth - hireDate.getDate() + 1
// 如果基本工资等于整月工资,提示可能未折算
const fullMonthSalary = Number(entry.employee.monthlySalary) || entry.baseSalary
if (Math.abs(entry.baseSalary - fullMonthSalary) < 1 && actualWorkDays < daysInMonth) {
checks.push({
code: 'NEW_HIRE_NO_PRORATE',
name: '新入职未折算工资',
status: 'WARNING',
message: `${entry.employee.name} 本月 ${hireDate.getDate()} 日入职,工资可能未按实际天数折算(实际工作 ${actualWorkDays} 天)`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { hireDate: hireDate.toISOString().slice(0, 10), actualWorkDays, daysInMonth },
})
}
}
}
// 8. 社保是否在入职 30 天内参保
for (const entry of entries) {
const hireDate = entry.employee.hireDate
const socialStart = entry.employee.socialInsStartMonth
if (socialStart) {
const socialStartDate = new Date(socialStart + '-01')
const daysDiff = Math.floor((socialStartDate.getTime() - hireDate.getTime()) / 86400000)
if (daysDiff > 30) {
checks.push({
code: 'SOCIAL_INS_LATE_ENROLL',
name: '社保参保延迟',
status: 'WARNING',
message: `${entry.employee.name} 入职 ${daysDiff} 天后才参保社保,超过 30 天法定期限`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
detail: { hireDate: hireDate.toISOString().slice(0, 10), socialStart, daysDiff },
})
}
}
}
// 9. 离职当月社保是否已停保
for (const entry of entries) {
if (entry.employee.status === 'RESIGNED' && entry.employee.socialInsEndMonth) {
// 正常,已停保
} else if (entry.employee.status === 'RESIGNED' && !entry.employee.socialInsEndMonth) {
checks.push({
code: 'SOCIAL_INS_NOT_STOPPED',
name: '离职未停保',
status: 'WARNING',
message: `${entry.employee.name} 已离职但社保未停保,可能产生多缴费用`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
})
}
}
// 10. 基本工资为 0
for (const entry of entries) {
if (entry.baseSalary === 0 && batch.type === 'REGULAR') {
checks.push({
code: 'ZERO_BASE_SALARY',
name: '基本工资为 0',
status: 'WARNING',
message: `${entry.employee.name} 的基本工资为 0,请确认是否正确`,
employeeId: entry.employeeId,
employeeName: entry.employee.name,
})
}
}
// 汇总
const passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0
const failedCount = checks.filter(c => c.status === 'FAIL').length
const warningCount = checks.filter(c => c.status === 'WARNING').length
return { checks, passedCount, failedCount, warningCount }
}
+164
View File
@@ -0,0 +1,164 @@
import prisma from '../lib/prisma'
/**
* 规章制度民主程序服务
*/
const DEMOCRACY_STEPS = [
{ step: 1, name: '起草制度', description: 'HR 起草规章制度文本' },
{ step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论,提出方案和意见' },
{ step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定' },
{ step: 4, name: '公示告知', description: '向全体员工公示告知(公告栏/邮件/培训签收等)' },
]
/**
* 初始化民主程序进度
*/
export function initDemocracyProgress() {
return {
currentStep: 1,
steps: DEMOCRACY_STEPS.map(s => ({
...s,
status: s.step === 1 ? 'IN_PROGRESS' : 'PENDING',
date: s.step === 1 ? new Date().toISOString().slice(0, 10) : null,
note: null,
})),
}
}
/**
* 更新民主程序步骤
*/
export function updateDemocracyStep(progress: any, targetStep: number, note?: string) {
const steps = progress.steps.map((s: any) => {
if (s.step < targetStep) {
return { ...s, status: 'COMPLETED' }
} else if (s.step === targetStep) {
return { ...s, status: 'COMPLETED', date: new Date().toISOString().slice(0, 10), note: note || s.note }
}
return s
})
// 设置下一步为进行中
if (targetStep < DEMOCRACY_STEPS.length) {
const nextIdx = steps.findIndex((s: any) => s.step === targetStep + 1)
if (nextIdx >= 0) {
steps[nextIdx] = { ...steps[nextIdx], status: 'IN_PROGRESS', date: new Date().toISOString().slice(0, 10) }
}
}
return {
currentStep: targetStep >= DEMOCRACY_STEPS.length ? DEMOCRACY_STEPS.length : targetStep + 1,
steps,
}
}
/**
* 创建制度文档
*/
export async function createPolicy(orgId: string, userId: string, data: { title: string; content: string; type?: string }) {
return prisma.policyDocument.create({
data: {
orgId,
title: data.title,
content: data.content,
type: data.type || 'RULES',
status: 'DRAFT',
democracyProgress: initDemocracyProgress(),
createdBy: userId,
},
})
}
/**
* 获取制度列表(含阅读签收统计)
*/
export async function getPolicies(orgId: string, status?: string) {
const where: any = { orgId }
if (status) where.status = status
const [policies, totalEmployees] = await Promise.all([
prisma.policyDocument.findMany({
where,
orderBy: { updatedAt: 'desc' },
include: {
_count: { select: { readRecords: true } },
},
}),
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
])
return policies.map(p => ({
...p,
readCount: p._count?.readRecords || 0,
totalEmployees,
}))
}
/**
* 获取制度详情
*/
export async function getPolicyDetail(orgId: string, id: string) {
const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } })
if (!policy) {
throw { code: 'NOT_FOUND', message: '制度文档不存在' }
}
return policy
}
/**
* 更新制度
*/
export async function updatePolicy(orgId: string, id: string, data: { title?: string; content?: string; type?: string }) {
const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } })
if (!policy) {
throw { code: 'NOT_FOUND', message: '制度文档不存在' }
}
if (policy.status === 'PUBLISHED') {
throw { code: 'CONFLICT', message: '已公示的制度不可编辑' }
}
const updateData: any = {}
if (data.title !== undefined) updateData.title = data.title
if (data.content !== undefined) updateData.content = data.content
if (data.type !== undefined) updateData.type = data.type
return prisma.policyDocument.update({ where: { id }, data: updateData })
}
/**
* 推进民主程序步骤
*/
export async function advanceDemocracyStep(orgId: string, id: string, step: number, note?: string) {
const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } })
if (!policy) {
throw { code: 'NOT_FOUND', message: '制度文档不存在' }
}
if (policy.status === 'PUBLISHED') {
throw { code: 'CONFLICT', message: '已公示的制度不可修改' }
}
const progress = updateDemocracyStep(policy.democracyProgress, step, note)
const status = step >= 4 ? 'PUBLISHED' : step >= 3 ? 'CONSULTING' : step >= 2 ? 'DISCUSSING' : 'DRAFT'
return prisma.policyDocument.update({
where: { id },
data: {
democracyProgress: progress,
status,
publishedAt: step >= 4 ? new Date() : null,
},
})
}
/**
* 删除制度
*/
export async function deletePolicy(orgId: string, id: string) {
const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } })
if (!policy) {
throw { code: 'NOT_FOUND', message: '制度文档不存在' }
}
if (policy.status === 'PUBLISHED') {
throw { code: 'CONFLICT', message: '已公示的制度不可删除' }
}
return prisma.policyDocument.delete({ where: { id } })
}
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
/**
* 用工文本模板库
* 提供合同、制度、通知等常用文本模板,HR 可基于模板快速生成文档
*/
export interface DocumentTemplate {
id: string
name: string
category: 'CONTRACT' | 'RULES' | 'NOTICE' | 'AGREEMENT' | 'OTHER'
description: string
content: string
variables: string[] // 模板变量列表,如 ['employeeName', 'startDate', 'salary']
}
export const documentTemplates: DocumentTemplate[] = [
{
id: 'tpl_fixed_term_contract',
name: '固定期限劳动合同',
category: 'CONTRACT',
description: '标准固定期限劳动合同模板,适用于大多数正式员工',
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'monthlySalary', 'socialInsBase'],
content: `劳动合同书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}}
根据《中华人民共和国劳动合同法》及相关法律法规,甲乙双方在平等自愿、协商一致的基础上,签订本劳动合同。
第一条 合同期限
本合同为固定期限劳动合同,自{{startDate}}起至{{endDate}}止,其中试用期{{probationMonths}}个月。
第二条 工作内容和工作地点
乙方担任{{position}}岗位,工作地点为{{workplace}}。
第三条 工作时间和休息休假
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
第四条 劳动报酬
乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元,甲方于每月15日前支付上月工资。
第五条 社会保险和福利
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
第六条 劳动保护、劳动条件和职业危害防护
甲方为乙方提供符合国家规定的劳动保护条件。
第七条 合同解除和终止
双方解除和终止劳动合同,应严格按照《劳动合同法》的规定执行。
第八条 违约责任
任何一方违反本合同约定,应承担相应的违约责任。
第九条 争议解决
因履行本合同发生的争议,双方应协商解决;协商不成的,可向劳动争议仲裁委员会申请仲裁。
第十条 其他
本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。
甲方(盖章):____________ 乙方(签字):____________
日期:____年__月__日 日期:____年__月__日`,
},
{
id: 'tpl_open_ended_contract',
name: '无固定期限劳动合同',
category: 'CONTRACT',
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'],
content: `无固定期限劳动合同书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}}
根据《中华人民共和国劳动合同法》及相关法律法规,甲乙双方在平等自愿、协商一致的基础上,签订无固定期限劳动合同。
第一条 合同期限
本合同为无固定期限劳动合同,自{{startDate}}起生效,至法定终止条件出现时终止。
第二条 工作内容
乙方担任{{position}}岗位。
第三条 劳动报酬
乙方月工资为人民币{{monthlySalary}}元。
(其余条款参照固定期限劳动合同模板)
甲方(盖章):____________ 乙方(签字):____________
日期:____年__月__日 日期:____年__月__日`,
},
{
id: 'tpl_termination_agreement',
name: '解除劳动合同协议书',
category: 'AGREEMENT',
description: '协商一致解除劳动合同协议书模板',
variables: ['companyName', 'employeeName', 'idCard', 'terminationDate', 'compensation', 'lastWorkDay', 'socialInsEndMonth', 'housingFundEndMonth'],
content: `解除劳动合同协议书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
甲乙双方经协商一致,就解除劳动合同事宜达成如下协议:
一、解除日期
双方同意于{{terminationDate}}解除劳动合同,乙方最后工作日为{{lastWorkDay}}。
二、经济补偿
甲方同意向乙方支付经济补偿金人民币{{compensation}}元,于乙方办理完工作交接手续后__个工作日内一次性支付。
三、工资结算
甲方结清乙方截至解除日的所有工资、加班费等劳动报酬。
四、社会保险和公积金
甲方为乙方缴纳社会保险至{{socialInsEndMonth}}月,住房公积金缴存至{{housingFundEndMonth}}月。
五、工作交接
乙方应在解除日前完成工作交接,归还甲方所有财物和资料。
六、保密义务
乙方解除劳动合同后,仍应遵守保密义务,不得泄露甲方商业秘密。
七、争议解决
本协议履行过程中如发生争议,双方应协商解决;协商不成的,可向劳动争议仲裁委员会申请仲裁。
八、其他
本协议一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。
甲方(盖章):____________ 乙方(签字):____________
日期:____年__月__日 日期:____年__月__日`,
},
{
id: 'tpl_employee_handbook_notice',
name: '员工手册公示通知',
category: 'NOTICE',
description: '员工手册公示通知模板,用于民主程序第四步公示',
variables: ['companyName', 'publishDate', 'effectiveDate'],
content: `关于发布《员工手册》的通知
致全体员工:
经职工代表大会讨论通过,并经与工会平等协商,{{companyName}}现正式发布《员工手册》({{publishDate}}版),自{{effectiveDate}}起施行。
请全体员工认真阅读并遵守《员工手册》的各项规定。如有疑问,请联系人力资源部。
特此通知。
{{companyName}}
{{publishDate}}`,
},
{
id: 'tpl_rules_discussion_notice',
name: '规章制度讨论通知',
category: 'NOTICE',
description: '规章制度职工讨论通知,用于民主程序第二步',
variables: ['companyName', 'meetingDate', 'meetingLocation', 'policyTitle'],
content: `关于召开职工代表大会讨论{{policyTitle}}的通知
致全体职工代表:
根据《劳动合同法》第四条规定,用人单位制定规章制度应当经职工代表大会或全体职工讨论。现定于{{meetingDate}}在{{meetingLocation}}召开职工代表大会,讨论《{{policyTitle}}》草案。
请各位职工代表准时出席,充分发表意见和建议。
特此通知。
{{companyName}}
____年__月__日`,
},
{
id: 'tpl_disciplinary_notice',
name: '违纪处分通知书',
category: 'NOTICE',
description: '员工违纪处分通知书模板',
variables: ['employeeName', 'department', 'violationDate', 'violationDescription', 'disciplineType', 'policyBasis', 'companyName'],
content: `违纪处分通知书
{{employeeName}}{{department}}):
经查,你于{{violationDate}}存在以下违纪行为:
{{violationDescription}}
上述行为违反了公司《{{policyBasis}}》的相关规定。根据公司规章制度,决定给予你以下处分:
{{disciplineType}}
如对本处分决定有异议,你可以在收到本通知之日起__日内向人力资源部提出书面申诉。
特此通知。
{{companyName}}
____年__月__日
签收人:____________ 日期:____年__月__日`,
},
{
id: 'tpl_probation_notice',
name: '试用期转正通知书',
category: 'NOTICE',
description: '试用期考核合格转正通知',
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'],
content: `试用期转正通知书
{{employeeName}}
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
二、转正后月工资为人民币{{monthlySalary}}元。
三、试用期至{{probationEndDate}}结束。
请继续遵守公司各项规章制度,努力工作。
{{companyName}}
____年__月__日`,
},
{
id: 'tpl_contract_expiry_notice',
name: '合同到期不续签通知书',
category: 'NOTICE',
description: '合同到期公司决定不续签的通知',
variables: ['employeeName', 'contractEndDate', 'companyName', 'compensation', 'lastWorkDay'],
content: `劳动合同到期不续签通知书
{{employeeName}}
你与公司签订的劳动合同将于{{contractEndDate}}到期。经公司研究决定,合同到期后不再与你续签劳动合同。
请你于{{lastWorkDay}}前完成工作交接手续。公司将在你完成交接后,依法支付经济补偿金人民币{{compensation}}元。
特此通知。
{{companyName}}
____年__月__日
签收人:____________ 日期:____年__月__日`,
},
]
/**
* 获取所有模板
*/
export function getAllTemplates() {
return documentTemplates.map(({ content, ...rest }) => rest)
}
/**
* 获取模板详情(含内容)
*/
export function getTemplateById(id: string) {
return documentTemplates.find(t => t.id === id) || null
}
/**
* 按分类获取模板
*/
export function getTemplatesByCategory(category: string) {
return documentTemplates.filter(t => t.category === category)
}
/**
* 渲染模板(替换变量)
*/
export function renderTemplate(id: string, variables: Record<string, string>): string | null {
const template = getTemplateById(id)
if (!template) return null
let content = template.content
for (const [key, value] of Object.entries(variables)) {
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
}
return content
}
+107
View File
@@ -808,3 +808,110 @@ export async function getTerminationDetail(orgId: string, recordId: string) {
updatedAt: record.updatedAt.toISOString().slice(0, 10),
}
}
/**
* 解聘流程步骤前置校验
* 返回是否可以继续、阻断原因、警告信息
*/
export async function validateTerminationStep(orgId: string, recordId: string, step: number) {
const record = await prisma.terminationRecord.findFirst({
where: { id: recordId, orgId },
include: { employee: true },
})
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
const employee = record.employee
const reason = record.reason as string
const result: {
step: number
canProceed: boolean
blockReason?: string
warning?: string
legalBasis?: string
} = { step, canProceed: true }
// Step 1: 选择员工 — 检查特殊群体
if (step === 1) {
// 三期女职工 + 非过错解除 → 阻止
if (employee.isPregnant && reason !== 'FAULT' && reason !== 'RESIGNATION') {
result.canProceed = false
result.blockReason = '该员工处于孕期/哺乳期,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
result.legalBasis = '《劳动合同法》第四十二条:女职工在孕期、产期、哺乳期内,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
}
// 工伤期间 + 非过错解除 → 阻止
if (employee.isWorkInjured && reason !== 'FAULT' && reason !== 'RESIGNATION') {
result.canProceed = false
result.blockReason = '该员工工伤期间,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
result.legalBasis = '《劳动合同法》第四十二条:在本单位患职业病或者因工负伤并被确认丧失或者部分丧失劳动能力的,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
}
// 医疗期内 + 非过错解除 → 阻止
if (employee.isInMedicalPeriod && reason !== 'FAULT' && reason !== 'RESIGNATION') {
result.canProceed = false
result.blockReason = '该员工处于医疗期内,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
result.legalBasis = '《劳动合同法》第四十二条:患病或者非因工负伤,在规定的医疗期内的,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
}
}
// Step 3: 合规检查 — 检查关键合规项
if (step === 3) {
const checklist = record.checklist as any
const overrides = record.checklistOverrides as any
// 过错解除:必须通知工会
if (reason === 'FAULT') {
const notifyUnion = checklist?.notify_union
const override = overrides?.notify_union
if (!notifyUnion && !override?.checked) {
result.warning = '未确认"已通知工会"。如未通知工会即解除,可能被认定为违法解除程序(2N 赔偿风险)'
result.legalBasis = '《劳动合同法》第四十三条:用人单位单方解除劳动合同,应当事先将理由通知工会'
}
}
// 非过错解除:必须支付补偿金
if (reason === 'NONFAULT' || reason === 'NEGOTIATED' || reason === 'LAYOFF') {
const compPaid = checklist?.compensation_paid
const override = overrides?.compensation_paid
if (!compPaid && !override?.checked) {
result.warning = '未确认"已支付经济补偿金"。非过错解除必须支付经济补偿金(N),未支付将面临劳动监察处罚和仲裁风险'
result.legalBasis = '《劳动合同法》第四十六条:用人单位依照本法第三十六条、第四十条、第四十一条规定解除劳动合同的,应当向劳动者支付经济补偿'
}
}
}
// Step 4: 费用结算 — 必须先计算补偿金
if (step === 4) {
if (reason !== 'RESIGNATION' && record.compensation === 0) {
const compPaid = (record.checklist as any)?.compensation_paid
if (!compPaid) {
result.canProceed = false
result.blockReason = '补偿金尚未计算。请先在 Step 4 费用结算中计算经济补偿金,再继续后续流程'
}
}
}
// Step 5: 工作交接 — 检查交接清单
if (step === 5) {
const handoverItems = record.handoverItems as any[]
if (handoverItems && handoverItems.length > 0) {
const incomplete = handoverItems.filter(h => !h.done)
if (incomplete.length > 0) {
result.warning = `还有 ${incomplete.length} 项工作交接未完成:${incomplete.map(h => h.label).join('、')}。建议完成后再执行解聘`
}
}
}
// 合同已到期 + 选择"解除"而非"终止" → 警告
if (step === 2 && reason !== 'EXPIRED' && reason !== 'RESIGNATION') {
const latestContract = await prisma.laborContract.findFirst({
where: { employeeId: employee.id, orgId },
orderBy: { createdAt: 'desc' },
})
if (latestContract?.endDate && new Date(latestContract.endDate) < new Date()) {
result.warning = '该员工合同已到期。建议使用"到期终止"(EXPIRED)而非解除,流程更简单且法律风险更低'
}
}
return result
}
+35 -10
View File
@@ -1,8 +1,9 @@
import { lazy, Suspense } from 'react'
import { lazy, Suspense, useState } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'sonner'
import { useAuthStore } from './store/authStore'
import TopNav from './components/layout/TopNav'
import SidebarNav from './components/layout/SidebarNav'
import MobileTabBar from './components/layout/MobileTabBar'
import PageContainer from './components/layout/PageContainer'
import { SkeletonPage } from './components/ui/Skeleton'
@@ -18,11 +19,21 @@ const Roster = lazy(() => import('./pages/Roster'))
const Termination = lazy(() => import('./pages/Termination'))
const AIAssistant = lazy(() => import('./pages/AIAssistant'))
const Settings = lazy(() => import('./pages/Settings'))
const Evidence = lazy(() => import('./pages/Evidence'))
const Policies = lazy(() => import('./pages/Policies'))
const Attendance = lazy(() => import('./pages/Attendance'))
const Templates = lazy(() => import('./pages/Templates'))
const AuditLog = lazy(() => import('./pages/AuditLog'))
const Notifications = lazy(() => import('./pages/Notifications'))
const PortalLogin = lazy(() => import('./pages/portal/PortalLogin'))
const Payslip = lazy(() => import('./pages/portal/Payslip'))
const MyContract = lazy(() => import('./pages/portal/MyContract'))
const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))
const MyPolicies = lazy(() => import('./pages/portal/MyPolicies'))
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator'))
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
@@ -37,16 +48,20 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
}
function AdminLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<div className="min-h-screen flex flex-col">
<TopNav />
<main className="flex-1 py-6 pb-20 md:pb-6">
<PageContainer>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</PageContainer>
</main>
<MobileTabBar />
<OnboardingGuide />
<div className="flex min-h-screen bg-[#f8f9fb]">
<SidebarNav mobileOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col min-w-0">
<TopNav onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 py-6 pb-20 md:pb-6">
<PageContainer>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</PageContainer>
</main>
<MobileTabBar />
<OnboardingGuide />
</div>
</div>
)
}
@@ -78,6 +93,15 @@ export default function App() {
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
<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="/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>} />
<Route path="/tools/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} />
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
{/* 员工端 */}
<Route path="/portal/login" element={<PortalLayout><PortalLogin /></PortalLayout>} />
@@ -85,6 +109,7 @@ export default function App() {
<Route path="/portal/contract" element={<PortalLayout><MyContract /></PortalLayout>} />
<Route path="/portal/onboarding" element={<PortalLayout><Onboarding /></PortalLayout>} />
<Route path="/portal/contract-confirm" element={<PortalLayout><ContractConfirm /></PortalLayout>} />
<Route path="/portal/policies" element={<PortalLayout><MyPolicies /></PortalLayout>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
@@ -0,0 +1,56 @@
/**
* 面包屑导航组件
* 根据当前路由自动生成分组 > 页面 的层级面包屑
*/
import { Link, useLocation } from 'react-router-dom'
import { ChevronRight, Home } from 'lucide-react'
interface BreadcrumbItem {
group: string
label: string
}
const ROUTE_MAP: Record<string, BreadcrumbItem> = {
'/': { group: '工作台', label: '总览' },
'/roster': { group: '员工管理', label: '花名册' },
'/attendance': { group: '员工管理', label: '考勤确认' },
'/termination': { group: '员工管理', label: '解聘补偿' },
'/money': { group: '薪税社保', label: '薪税管理' },
'/social': { group: '薪税社保', label: '社保公积金' },
'/evidence': { group: '合规风控', label: '证据链' },
'/policies': { group: '合规风控', label: '规章制度' },
'/tools/health-check': { group: '合规风控', label: '用工体检' },
'/tools/medical-period': { group: '合规风控', label: '医疗期计算' },
'/tools/annual-value': { group: '合规风控', label: '年度价值' },
'/ai-assistant': { group: 'AI 辅助', label: 'AI 顾问' },
'/templates': { group: 'AI 辅助', label: '文本模板' },
'/notifications': { group: '系统', label: '通知管理' },
'/audit': { group: '系统', label: '操作日志' },
'/settings': { group: '系统', label: '设置' },
}
export default function Breadcrumb() {
const location = useLocation()
const path = location.pathname
const item = ROUTE_MAP[path]
if (!item) return null
return (
<nav className="flex items-center gap-1 text-xs text-gray-400" aria-label="面包屑导航">
<Link to="/" className="flex items-center gap-1 hover:text-gray-600 transition-colors">
<Home className="w-3 h-3" />
<span></span>
</Link>
{item.group !== '工作台' && (
<>
<ChevronRight className="w-3 h-3 text-gray-300" />
<span className="text-gray-400">{item.group}</span>
</>
)}
<ChevronRight className="w-3 h-3 text-gray-300" />
<span className="text-gray-700 font-medium">{item.label}</span>
</nav>
)
}
@@ -3,7 +3,7 @@ import clsx from 'clsx'
export default function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={clsx('max-w-content mx-auto px-4', className)}>
<div className={clsx('w-full px-4', className)}>
{children}
</div>
)
@@ -0,0 +1,179 @@
/**
* 侧边栏导航组件
* 分组式菜单结构,支持折叠/展开,移动端抽屉模式
*/
import { Link, useLocation } from 'react-router-dom'
import { useState } from 'react'
import clsx from 'clsx'
import {
LayoutDashboard, Users, CalendarCheck, UserX,
Calculator, Shield,
FileSearch, FileText, Stethoscope, HeartPulse, Award,
Bot, BookMarked,
Bell, ScrollText, Settings,
ChevronDown, ChevronRight,
Building2,
} from 'lucide-react'
interface NavItem {
path: string
label: string
icon: typeof LayoutDashboard
}
interface NavGroup {
title: string
items: NavItem[]
}
const navGroups: NavGroup[] = [
{
title: '工作台',
items: [
{ path: '/', label: '总览', icon: LayoutDashboard },
],
},
{
title: '员工管理',
items: [
{ path: '/roster', label: '花名册', icon: Users },
{ path: '/attendance', label: '考勤确认', icon: CalendarCheck },
{ path: '/termination', label: '解聘补偿', icon: UserX },
],
},
{
title: '薪税社保',
items: [
{ path: '/money', label: '薪税管理', icon: Calculator },
{ path: '/social', label: '社保公积金', icon: Shield },
],
},
{
title: '合规风控',
items: [
{ path: '/evidence', label: '证据链', icon: FileSearch },
{ path: '/policies', label: '规章制度', icon: FileText },
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
{ path: '/tools/medical-period', label: '医疗期计算', icon: HeartPulse },
{ path: '/tools/annual-value', label: '年度价值', icon: Award },
],
},
{
title: 'AI 辅助',
items: [
{ path: '/ai-assistant', label: 'AI 顾问', icon: Bot },
{ path: '/templates', label: '文本模板', icon: BookMarked },
],
},
{
title: '系统',
items: [
{ path: '/notifications', label: '通知管理', icon: Bell },
{ path: '/audit', label: '操作日志', icon: ScrollText },
{ path: '/settings', label: '设置', icon: Settings },
],
},
]
/**
* 侧边栏导航
*/
export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) {
const location = useLocation()
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(navGroups.map(g => g.title)))
const toggleGroup = (title: string) => {
setExpandedGroups(prev => {
const next = new Set(prev)
if (next.has(title)) next.delete(title)
else next.add(title)
return next
})
}
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/'
return location.pathname.startsWith(path)
}
return (
<>
{/* 移动端遮罩 */}
{mobileOpen && (
<div
className="fixed inset-0 bg-black/40 z-40 md:hidden"
onClick={onClose}
aria-label="关闭侧边栏"
/>
)}
<aside
className={clsx(
'fixed md:sticky top-0 left-0 z-50 md:z-auto',
'w-52 h-screen md:h-screen flex-shrink-0',
'bg-white border-r border-gray-200',
'flex flex-col',
'transition-transform duration-200',
mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
)}
>
{/* Logo 区 */}
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
<Building2 className="w-5 h-5 text-primary" />
<span className="font-bold text-sm text-gray-900"></span>
</div>
{/* 导航菜单 */}
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-3">
{navGroups.map((group, idx) => {
const isExpanded = expandedGroups.has(group.title)
const hasActiveItem = group.items.some(item => isActive(item.path))
return (
<div key={group.title} className={idx > 0 ? 'pt-2 border-t border-gray-100' : ''}>
{/* 分组标题 */}
<button
onClick={() => toggleGroup(group.title)}
className={clsx(
'flex items-center justify-between w-full px-2 py-1.5 text-xs font-medium rounded-md transition-colors',
hasActiveItem ? 'text-gray-800' : 'text-gray-500 hover:text-gray-700',
)}
>
<span>{group.title}</span>
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{/* 菜单项 */}
{isExpanded && (
<div className="mt-0.5 space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon
const active = isActive(item.path)
return (
<Link
key={item.path}
to={item.path}
onClick={onClose}
className={clsx(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-800',
)}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{item.label}</span>
</Link>
)
})}
</div>
)}
</div>
)
})}
</nav>
</aside>
</>
)
}
+20 -44
View File
@@ -1,22 +1,12 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Building2, ChevronDown, Settings as SettingsIcon, Bell } from 'lucide-react'
import { Link, useNavigate } from 'react-router-dom'
import { ChevronDown, Settings as SettingsIcon, Bell, Menu } from 'lucide-react'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/authStore'
import api from '../../lib/api'
import clsx from 'clsx'
import Breadcrumb from './Breadcrumb'
const tabs = [
{ path: '/', label: '总览' },
{ path: '/roster', label: '员工管理' },
{ path: '/money', label: '薪税' },
{ path: '/social', label: '社保公积金' },
{ path: '/termination', label: '解聘补偿' },
{ path: '/ai-assistant', label: 'AI顾问' },
]
export default function TopNav() {
const location = useLocation()
export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [menuOpen, setMenuOpen] = useState(false)
@@ -32,47 +22,33 @@ export default function TopNav() {
const riskCount = dashboardData?.riskSummary?.pending || 0
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<div className="max-w-content mx-auto px-4 h-14 flex items-center gap-4">
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 shrink-0">
<Building2 className="w-5 h-5 text-primary" />
<span className="hidden sm:inline"></span>
</Link>
<nav className="hidden md:flex items-center gap-1 flex-1">
{tabs.map((tab) => (
<Link
key={tab.path}
to={tab.path}
className={clsx(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors relative',
location.pathname === tab.path
? 'bg-primary/10 text-primary'
: 'text-gray-600 hover:bg-gray-100',
)}
>
{tab.label}
{tab.path === '/' && riskCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
{riskCount > 99 ? '99+' : riskCount}
</span>
)}
</Link>
))}
</nav>
<header className="sticky top-0 z-30 bg-white border-b border-gray-200">
<div className="h-14 flex items-center justify-between px-4">
{/* 左侧:hamburger(移动端)+ 面包屑 */}
<div className="flex items-center gap-2 min-w-0">
<button
onClick={onMenuClick}
className="md:hidden p-1.5 rounded-md hover:bg-gray-100"
aria-label="打开菜单"
>
<Menu className="w-5 h-5 text-gray-600" />
</button>
<Breadcrumb />
</div>
{/* 右侧:通知 + 设置 + 用户菜单 */}
<div className="flex items-center gap-2 shrink-0">
<Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
<SettingsIcon className="w-4 h-4 text-gray-600" />
</Link>
<button className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
<Link to="/notifications" className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
<Bell className="w-4 h-4 text-gray-600" />
{riskCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
{riskCount > 99 ? '99+' : riskCount}
</span>
)}
</button>
</Link>
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
@@ -0,0 +1,29 @@
/**
* 错误提示横幅组件
* 统一各页面的错误信息展示样式
*/
interface ErrorBannerProps {
/** 错误信息 */
message: string
/** 变体:错误(红) / 警告(黄) */
variant?: 'error' | 'warning'
/** 自定义 className */
className?: string
}
const variantStyles: Record<string, string> = {
error: 'bg-red-50 text-red-700',
warning: 'bg-amber-50 text-amber-700',
}
/**
* 错误/警告提示横幅
*/
export default function ErrorBanner({ message, variant = 'error', className = '' }: ErrorBannerProps) {
return (
<div className={`px-3 py-2 rounded-md text-xs ${variantStyles[variant]} ${className}`}>
{message}
</div>
)
}
@@ -0,0 +1,22 @@
/**
* 加载中组件
* 统一各页面的"加载中..."显示
*/
interface LoadingSpinnerProps {
/** 自定义文本 */
text?: string
/** 容器 padding */
className?: string
}
/**
* 加载中占位
*/
export default function LoadingSpinner({ text = '加载中...', className = 'py-16' }: LoadingSpinnerProps) {
return (
<div className={`text-center text-sm text-gray-400 ${className}`}>
{text}
</div>
)
}
@@ -0,0 +1,33 @@
/**
* 状态徽章组件
* 统一各页面的状态标签样式,替代重复的 `px-2 py-0.5 rounded text-xs` 模式
*/
interface StatusBadgeProps {
/** 状态文本 */
label: string
/** 颜色变体 */
variant?: 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'purple'
/** 自定义 className */
className?: string
}
const variantStyles: Record<string, string> = {
success: 'bg-green-50 text-safe',
warning: 'bg-yellow-50 text-yellow-700',
danger: 'bg-red-50 text-danger',
info: 'bg-blue-50 text-blue-600',
neutral: 'bg-gray-100 text-gray-500',
purple: 'bg-purple-50 text-purple-700',
}
/**
* 状态徽章
*/
export default function StatusBadge({ label, variant = 'neutral', className = '' }: StatusBadgeProps) {
return (
<span className={`px-2 py-0.5 rounded text-xs ${variantStyles[variant]} ${className}`}>
{label}
</span>
)
}
+13
View File
@@ -0,0 +1,13 @@
/** UI 组件统一导出 */
export { default as Button } from './Button'
export { default as Card } from './Card'
export { default as Modal } from './Modal'
export { default as Pagination } from './Pagination'
export { default as EmptyState } from './EmptyState'
export { default as ConfirmDialog } from './ConfirmDialog'
export { Skeleton, SkeletonText, SkeletonCard, SkeletonPage } from './Skeleton'
export { default as Signal } from './Signal'
export { default as StatusBadge } from './StatusBadge'
export { default as LoadingSpinner } from './LoadingSpinner'
export { default as ErrorBanner } from './ErrorBanner'
export { Input, Label, Select } from './Input'
+3 -3
View File
@@ -556,7 +556,7 @@ function PredictTab() {
<Card>
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-5 h-5 text-primary" />
<h2 className="font-medium">AI </h2>
<h2 className="text-sm font-medium">AI </h2>
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" /></Button>
</div>
@@ -713,7 +713,7 @@ function ReviewTab() {
<Card>
<div className="flex items-center gap-2 mb-4">
<FileSearch className="w-5 h-5 text-primary" />
<h2 className="font-medium"></h2>
<h2 className="text-sm font-medium"></h2>
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" /></Button>
</div>
@@ -908,7 +908,7 @@ function CaseTab() {
<Card>
<div className="flex items-center gap-2 mb-4">
<Scale className="w-5 h-5 text-primary" />
<h2 className="font-medium"></h2>
<h2 className="text-sm font-medium"></h2>
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" /></Button>
</div>
+119
View File
@@ -0,0 +1,119 @@
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 api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
PENDING: { label: '待确认', color: 'text-amber-700', bg: 'bg-amber-100', icon: Clock },
CONFIRMED: { label: '已确认', color: 'text-green-700', bg: 'bg-green-100', icon: CheckCircle },
DISPUTED: { label: '有异议', color: 'text-red-700', bg: 'bg-red-100', icon: AlertCircle },
}
/**
* 考勤确认管理页面
*/
export default function Attendance() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const { data: list, isLoading } = useQuery<any>({
queryKey: ['attendance', month],
queryFn: async () => {
const res = await api.get(`/attendance?month=${month}`) as any
return res.data
},
})
const { data: stats } = useQuery<any>({
queryKey: ['attendance-stats', month],
queryFn: async () => {
const res = await api.get(`/attendance/stats?month=${month}`) 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>
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-4 gap-2">
{[
{ label: '总计', value: stats.total, color: 'text-gray-700' },
{ label: '待确认', value: stats.pending, color: 'text-amber-600' },
{ label: '已确认', value: stats.confirmed, color: 'text-green-600' },
{ label: '有异议', value: stats.disputed, color: 'text-red-600' },
].map(s => (
<Card key={s.label} className="text-center">
<div className={`text-lg font-bold ${s.color}`}>{s.value}</div>
<div className="text-xs text-gray-500">{s.label}</div>
</Card>
))}
</div>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="本月暂无考勤确认记录" description="请先批量导入考勤数据" />
) : (
<div className="space-y-2">
{list.map((item: any) => {
const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING
const StatusIcon = config.icon
return (
<Card key={item.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-gray-50 flex-shrink-0">
<CalendarCheck className="w-4 h-4 text-gray-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{item.employee?.name}</span>
<span className="text-xs text-gray-500">{item.employee?.department}</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span> {item.workDays} </span>
<span> {item.weekdayHours}h</span>
<span> {item.weekendHours}h</span>
<span> {item.holidayHours}h</span>
<span className="text-gray-700"> ¥{item.overtimePay?.toFixed(2)}</span>
</div>
{item.disputeNote && (
<div className="text-xs text-red-600 mt-1">{item.disputeNote}</div>
)}
</div>
</div>
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
<StatusIcon className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{config.label}</span>
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
)
}
+196
View File
@@ -0,0 +1,196 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ScrollText, Search, Filter } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
const ACTION_LABELS: Record<string, string> = {
CREATE: '创建',
UPDATE: '更新',
DELETE: '删除',
CORRECT: '更正',
CREATE_EMPLOYEE: '创建员工',
UPDATE_EMPLOYEE: '更新员工',
DELETE_EMPLOYEE: '删除员工',
IMPORT_EMPLOYEES: '导入员工',
BATCH_RENEW: '批量续签',
ADD_CONTRACT: '添加合同',
CREATE_CONTRACT: '创建合同',
SIGN_CONTRACT: '签署合同',
TERMINATE: '解聘',
CREATE_TERMINATION: '创建解聘记录',
APPROVE_TERMINATION: '审批解聘',
REJECT_TERMINATION: '驳回解聘',
EXECUTE_TERMINATION: '执行解聘',
CANCEL_TERMINATION: '撤销解聘',
SUBMIT_TERMINATION: '提交解聘审批',
CREATE_DRAFT: '创建草稿',
REHIRE: '重新入职',
PAYROLL_GENERATE: '生成工资条',
PAYROLL_ARCHIVE: '归档发薪批次',
PAYROLL_PUBLISH: '发布工资条',
EXPORT_PAYROLL: '导出薪税',
AI_CHAT: 'AI 对话',
AI_REVIEW: 'AI 审查',
UPDATE_SETTINGS: '更新设置',
POLICY_ADVANCE: '制度流程推进',
POLICY_PUBLISH: '制度发布',
EVIDENCE_CREATE: '创建证据链',
ATTENDANCE_CONFIRM: '考勤确认',
}
const ENTITY_LABELS: Record<string, string> = {
EMPLOYEE: '员工',
CONTRACT: '合同',
TERMINATION: '解聘',
TERMINATION_RECORD: '解聘记录',
PAYROLL: '薪税',
PAYSLIP: '工资条',
SETTINGS: '设置',
POLICY: '制度',
POLICY_DOCUMENT: '制度文件',
EVIDENCE: '证据链',
AI: 'AI',
ATTENDANCE: '考勤',
DISCIPLINARY: '违纪',
EmployeeSocialInsRecord: '社保记录',
EmployeeHousingFundRecord: '公积金记录',
SocialMonthlyProcess: '社保月度流程',
RetirementPolicy: '退休政策',
NotificationSetting: '通知设置',
}
/**
* 系统操作日志页面
*/
export default function AuditLog() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [action, setAction] = useState('')
const [entity, setEntity] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const { data, isLoading } = useQuery<any>({
queryKey: ['audit-logs', page, pageSize, action, entity, dateFrom, dateTo],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (action) params.set('action', action)
if (entity) params.set('entity', entity)
if (dateFrom) params.set('dateFrom', dateFrom)
if (dateTo) params.set('dateTo', dateTo)
const res = await api.get(`/audit?${params}`) as any
return res.data
},
})
const { data: stats } = useQuery<any>({
queryKey: ['audit-stats'],
queryFn: async () => {
const res = await api.get('/audit/stats') as any
return res.data
},
})
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<ScrollText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="text-sm text-gray-500">6</p>
{/* 统计概览 */}
{stats && stats.length > 0 && (
<Card>
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="flex flex-wrap gap-2">
{stats.slice(0, 10).map((s: any) => (
<div key={s.action} className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-gray-50 text-xs">
<span className="text-gray-600">{ACTION_LABELS[s.action] || s.action}</span>
<span className="font-bold text-primary">{s._count.action}</span>
</div>
))}
</div>
</Card>
)}
{/* 筛选栏 */}
<Card>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="text-xs text-gray-500"></label>
<select value={action} onChange={e => { setAction(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary">
<option value=""></option>
{Object.entries(ACTION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-500"></label>
<select value={entity} onChange={e => { setEntity(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary">
<option value=""></option>
{Object.entries(ENTITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="date" value={dateFrom} onChange={e => { setDateFrom(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="date" value={dateTo} onChange={e => { setDateTo(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" />
</div>
{(action || entity || dateFrom || dateTo) && (
<Button size="sm" variant="secondary" onClick={() => { setAction(''); setEntity(''); setDateFrom(''); setDateTo(''); setPage(1) }}></Button>
)}
</div>
</Card>
{/* 日志列表 */}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data?.items || data.items.length === 0 ? (
<EmptyState title="暂无操作日志" description="系统操作将自动记录在此" />
) : (
<>
<Card>
<div className="space-y-1.5">
{data.items.map((log: any) => (
<div key={log.id} className="flex items-start gap-3 px-2 py-2 rounded-md hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/10 text-primary flex-shrink-0">
<ScrollText className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{ACTION_LABELS[log.action] || log.action}</span>
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{ENTITY_LABELS[log.entity] || log.entity}</span>
</div>
{log.detail && (
<div className="text-xs text-gray-500 mt-0.5 truncate">
{typeof log.detail === 'object' ? JSON.stringify(log.detail).slice(0, 100) : String(log.detail).slice(0, 100)}
</div>
)}
</div>
<div className="text-xs text-gray-400 flex-shrink-0 text-right">
<div>{new Date(log.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
{log.ip && <div className="text-gray-300">{log.ip}</div>}
</div>
</div>
))}
</div>
</Card>
<Pagination
page={page}
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</>
)}
</div>
)
}
+4 -4
View File
@@ -155,7 +155,7 @@ function SeveranceCalculator() {
return (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-3">
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
<div>
@@ -192,7 +192,7 @@ function SeveranceCalculator() {
</Card>
<Card>
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
<h2 className="text-sm font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
{result ? (
<div className="space-y-3">
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.reason}</span></div>
@@ -308,7 +308,7 @@ function DoubleSalaryCalculator() {
return (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-3">
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
<div>
@@ -336,7 +336,7 @@ function DoubleSalaryCalculator() {
</Card>
<Card>
<h2 className="font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" /></h2>
<h2 className="text-sm font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" /></h2>
{result ? (
<div className="space-y-3">
<div className="text-xs text-gray-500"><span className="text-gray-900">{hireDate}</span></div>
+1 -1
View File
@@ -421,7 +421,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<>
<div className="space-y-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{emp.name}</h2>
<h2 className="text-sm font-medium">{emp.name}</h2>
<span className="text-xs text-gray-500">{emp.department}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
+265 -12
View File
@@ -2,8 +2,8 @@ import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } 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 } from 'lucide-react'
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 api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -56,6 +56,31 @@ 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],
queryFn: async () => {
const res = await api.get(`/dashboard/cost-analysis?month=${currentMonth}`) as any
return res.data
},
})
const { data: complianceScore } = useQuery<any>({
queryKey: ['compliance-score'],
queryFn: async () => {
const res = await api.get('/dashboard/compliance-score') as any
return res.data
},
})
const resolveMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
@@ -175,6 +200,13 @@ export default function Dashboard() {
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
]
const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
URGENT: { label: '紧急', color: 'text-red-700', bg: 'bg-red-100' },
HIGH: { label: '高', color: 'text-orange-700', bg: 'bg-orange-100' },
MEDIUM: { label: '中', color: 'text-amber-700', bg: 'bg-amber-100' },
LOW: { label: '低', color: 'text-gray-600', bg: 'bg-gray-100' },
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -191,6 +223,33 @@ export default function Dashboard() {
</Button>
</div>
{/* 紧急风险横幅 */}
{data.urgentRisk && (
<Link to={data.urgentRisk.actionUrl}>
<Card className="border-red-300 bg-red-50 hover:bg-red-100 transition-colors cursor-pointer">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-200 flex-shrink-0">
<AlertTriangle className="w-5 h-5 text-red-700" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-red-800"></span>
<span className="px-1.5 py-0.5 rounded text-xs bg-red-200 text-red-800"> {fmt(data.urgentRisk.estimatedLoss)}</span>
{data.urgentRisk.daysUntilDeadline !== null && (
<span className={`px-1.5 py-0.5 rounded text-xs ${data.urgentRisk.daysUntilDeadline <= 0 ? 'bg-red-300 text-red-900' : 'bg-red-200 text-red-800'}`}>
{data.urgentRisk.daysUntilDeadline <= 0 ? '已逾期' : `${data.urgentRisk.daysUntilDeadline}天后截止`}
</span>
)}
</div>
<div className="text-sm text-red-700 mt-0.5 truncate">{data.urgentRisk.title}</div>
<div className="text-xs text-red-600 mt-0.5">{data.urgentRisk.description}</div>
</div>
<ArrowRight className="w-5 h-5 text-red-600 flex-shrink-0" />
</div>
</Card>
</Link>
)}
{/* Tab 导航 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
@@ -218,6 +277,96 @@ export default function Dashboard() {
{/* 概览 Tab */}
{activeTab === 'overview' && (
<div className="space-y-3">
{/* 合规健康度评分 + AI 建议卡片流 */}
{complianceScore && (
<div className="space-y-3">
{/* 评分环 + 5 维度 */}
<Card>
<div className="flex items-center gap-4">
{/* SVG 环形评分 */}
<div className="relative w-28 h-28 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
innerRadius="70%"
outerRadius="100%"
data={[{ value: complianceScore.overallScore, fill: complianceScore.level === 'safe' ? '#16A34A' : complianceScore.level === 'warning' ? '#D97706' : '#C00000' }]}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar background dataKey="value" cornerRadius={8} />
</RadialBarChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-2xl font-bold ${complianceScore.level === 'safe' ? 'text-safe' : complianceScore.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore.overallScore}</span>
<span className="text-xs text-gray-500">{complianceScore.levelLabel}</span>
</div>
</div>
{/* 5 维度评分 */}
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{complianceScore.dimensions.map((dim: any) => (
<Link key={dim.key} to={dim.key === 'contract' ? '/roster' : dim.key === 'policy' ? '/policies' : dim.key === 'attendance' ? '/attendance' : dim.key === 'salary' ? '/money' : '/social'} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${dim.score >= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} />
<span className="text-xs text-gray-700">{dim.name}</span>
</div>
<div className="flex items-center gap-1.5">
{dim.todoCount > 0 && <span className="text-xs text-gray-400">{dim.todoCount}</span>}
<span className={`text-sm font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
</div>
</Link>
))}
</div>
</div>
</Card>
{/* AI 建议卡片流 */}
{complianceScore.suggestions.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-700">
<Sparkles className="w-4 h-4 text-primary" />
AI
</div>
{complianceScore.suggestions.map((s: any, i: number) => {
const suggestionConfig: Record<string, { icon: typeof AlertTriangle; bg: string; border: string; iconColor: string; label: string; labelBg: string }> = {
danger: { icon: AlertTriangle, bg: 'bg-red-50', border: 'border-red-200', iconColor: 'text-red-600', label: '高风险', labelBg: 'bg-red-100 text-red-700' },
warning: { icon: AlertCircle, bg: 'bg-amber-50', border: 'border-amber-200', iconColor: 'text-amber-600', label: '中风险', labelBg: 'bg-amber-100 text-amber-700' },
value: { icon: ShieldCheck, bg: 'bg-green-50', border: 'border-green-200', iconColor: 'text-safe', label: '价值', labelBg: 'bg-green-100 text-safe' },
knowledge: { icon: BookOpen, bg: 'bg-purple-50', border: 'border-purple-200', iconColor: 'text-purple-600', label: '知识', labelBg: 'bg-purple-100 text-purple-700' },
}
const config = suggestionConfig[s.type] || { icon: Lightbulb, bg: 'bg-gray-50', border: 'border-gray-200', iconColor: 'text-gray-600', label: '', labelBg: '' }
const Icon = config.icon
return (
<Link key={i} to={s.actionUrl}>
<Card className={`${config.bg} ${config.border} border hover:shadow-md transition-shadow cursor-pointer`}>
<div className="flex items-start gap-3">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.iconColor} bg-white/60 flex-shrink-0`}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${config.labelBg}`}>{config.label}</span>
<span className="text-sm font-medium text-gray-900 truncate">{s.title}</span>
{s.estimatedLoss && s.estimatedLoss > 0 && (
<span className="text-xs text-red-600 font-bold"> {fmt(s.estimatedLoss)}</span>
)}
</div>
<p className="text-xs text-gray-600 mt-0.5">{s.description}</p>
</div>
<div className="flex items-center gap-1 text-xs text-primary flex-shrink-0">
{s.actionLabel}
<ArrowRight className="w-3 h-3" />
</div>
</div>
</Card>
</Link>
)
})}
</div>
)}
</div>
)}
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((stat) => {
@@ -239,7 +388,7 @@ export default function Dashboard() {
{/* 当月人力成本 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{payroll?.month}</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
@@ -259,7 +408,7 @@ export default function Dashboard() {
{/* 年度累计人力成本 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{yearCost?.year}</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
@@ -309,7 +458,7 @@ export default function Dashboard() {
{/* 本月工作动态 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" /></h2>
<span className="text-xs text-gray-500">{activities?.month}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
@@ -328,7 +477,7 @@ export default function Dashboard() {
{/* 风险分布 */}
<Card>
<h2 className="font-medium mb-3"></h2>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="flex items-center gap-4">
<div className="w-32 h-32 shrink-0">
<ResponsiveContainer width="100%" height="100%">
@@ -407,8 +556,21 @@ export default function Dashboard() {
<Link key={r.id} to={r.actionUrl} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${r.level === 'high' ? 'text-danger' : 'text-warning'}`} />
<div className="flex-1 min-w-0">
<div className="truncate text-gray-800">{r.title}</div>
{r.employeeName && <div className="text-gray-500">{r.employeeName}</div>}
<div className="flex items-center gap-1.5">
<span className="truncate text-gray-800">{r.title}</span>
{r.priority && priorityConfig[r.priority] && (
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[r.priority].bg} ${priorityConfig[r.priority].color}`}>
{priorityConfig[r.priority].label}
</span>
)}
</div>
<div className="flex items-center gap-2 text-gray-500">
{r.employeeName && <span>{r.employeeName}</span>}
{r.estimatedLoss > 0 && <span className="text-red-600"> {fmt(r.estimatedLoss)}</span>}
{r.daysUntilDeadline !== null && r.daysUntilDeadline <= 7 && (
<span className="text-red-600">{r.daysUntilDeadline <= 0 ? '已逾期' : `${r.daysUntilDeadline}`}</span>
)}
</div>
</div>
<ArrowRight className="w-3 h-3 text-gray-500" />
</Link>
@@ -420,6 +582,84 @@ 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>
{/* 人力成本分析 */}
<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>
<span className="text-xs text-gray-400">{currentMonth}</span>
</div>
{costAnalysis ? (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className={`p-2 rounded-lg ${costAnalysis.monthOnMonth?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
<div className="text-xs text-gray-500"></div>
<div className={`text-sm font-bold ${costAnalysis.monthOnMonth?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
{costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}%
</div>
<div className="text-xs text-gray-500">
{costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))}
</div>
</div>
<div className={`p-2 rounded-lg ${costAnalysis.yearOnYear?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
<div className="text-xs text-gray-500"></div>
<div className={`text-sm font-bold ${costAnalysis.yearOnYear?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
{costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}%
</div>
<div className="text-xs text-gray-500">
{costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))}
</div>
</div>
</div>
<div className="flex items-center justify-between border-t pt-2">
<span className="text-xs text-gray-600"></span>
<span className="text-sm font-bold text-primary">{fmt(costAnalysis.current?.perCapita || 0)}</span>
</div>
{costAnalysis.factors && costAnalysis.factors.length > 0 && (
<div className="space-y-1 border-t pt-2">
<div className="text-xs font-medium text-gray-600"></div>
{costAnalysis.factors.map((f: any, i: number) => (
<div key={i} className="flex items-start gap-1.5 text-xs">
<span className={`w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0 ${f.impact >= 0 ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-gray-600 flex-1">{f.description}</span>
</div>
))}
</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-4"></div>
)}
</Card>
</div>
</div>
)}
@@ -427,7 +667,7 @@ export default function Dashboard() {
{activeTab === 'payroll' && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" /></h2>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={handleExportPayroll} disabled={!payroll || payroll.payslipCount === 0}>
<Download className="w-4 h-4 mr-1" />
@@ -533,7 +773,7 @@ export default function Dashboard() {
{/* 待办列表 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<h2 className="text-sm font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<span className="text-xs text-gray-500">{filteredTodos.length} </span>
</div>
@@ -588,7 +828,20 @@ export default function Dashboard() {
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-xs text-gray-800">{todo.title}</span>
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-800">{todo.title}</span>
{todo.priority && priorityConfig[todo.priority] && (
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
{priorityConfig[todo.priority].label}
</span>
)}
{todo.estimatedLoss > 0 && (
<span className="text-xs text-red-600 font-medium"> {fmt(todo.estimatedLoss)}</span>
)}
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}`}</span>
)}
</div>
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div>
</Link>
@@ -622,7 +875,7 @@ export default function Dashboard() {
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" /></h2>
<span className="text-xs text-gray-500">{data.resolvedTodos.length} </span>
</div>
<div className="space-y-1.5">
+117
View File
@@ -0,0 +1,117 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ShieldCheck, FileText, AlertCircle, CheckCircle, XCircle } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import EmptyState from '../components/ui/EmptyState'
/**
* 证据链管理页面
*/
export default function Evidence() {
const [refType, setRefType] = useState<string>('')
const { data: list, isLoading } = useQuery<any>({
queryKey: ['evidence', refType],
queryFn: async () => {
const params = refType ? `?category=${refType}` : '?category=ALL'
const res = await api.get(`/evidence${params}`) as any
return res.data?.records || []
},
})
const { data: verifyResult, refetch: verifyAll } = useQuery<any>({
queryKey: ['evidence-verify-all'],
queryFn: async () => {
const res = await api.get('/evidence/verify-all') as any
return res.data
},
enabled: false,
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<ShieldCheck 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>
<button
onClick={() => verifyAll()}
className="px-3 py-1.5 text-sm rounded-lg bg-primary text-white hover:bg-primary/90"
>
</button>
</div>
{verifyResult && (
<Card className={verifyResult?.invalid === 0 ? 'border-green-300 bg-green-50' : 'border-red-300 bg-red-50'}>
<div className="flex items-center gap-2">
{verifyResult?.invalid === 0 ? (
<CheckCircle className="w-5 h-5 text-green-600" />
) : (
<XCircle className="w-5 h-5 text-red-600" />
)}
<span className="text-sm font-medium">
{verifyResult?.invalid === 0
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改`
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
</span>
</div>
</Card>
)}
<div className="flex gap-2">
{['', 'ONBOARD', 'CONTRACT_SIGN', 'PAYSLIP_CONFIRM', 'DISCIPLINARY', 'ATTENDANCE', 'TERMINATION'].map((t) => (
<button
key={t}
onClick={() => setRefType(t)}
className={`px-3 py-1 text-xs rounded-lg ${refType === t ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{t === '' ? '全部' : t === 'DISCIPLINARY' ? '违纪' : t === 'TERMINATION' ? '解聘' : t === 'CONTRACT_SIGN' ? '合同' : t === 'PAYSLIP_CONFIRM' ? '薪酬' : t === 'ONBOARD' ? '入职' : '考勤'}
</button>
))}
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无证据链记录" description="系统操作将自动生成证据链" />
) : (
<div className="space-y-2">
{list.map((item: any) => (
<Card key={item.id} className="hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-start gap-2.5 flex-1 min-w-0">
<FileText className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{item.lastAction || item.firstAction || item.category}</span>
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">
{item.category === 'DISCIPLINARY' ? '违纪' : item.category === 'TERMINATION' ? '解聘' : item.category === 'CONTRACT_SIGN' ? '合同' : item.category === 'PAYSLIP_CONFIRM' ? '薪酬' : item.category === 'ONBOARD' ? '入职' : '考勤'}
</span>
</div>
{item.employeeName && (
<div className="text-xs text-gray-500 mt-0.5">
{item.employeeName}{item.employeeDept ? ` · ${item.employeeDept}` : ''}
</div>
)}
<div className="text-xs text-gray-400 mt-0.5 flex items-center gap-2">
<span>{item.eventCount} · {new Date(item.createdAt).toLocaleString('zh-CN')}</span>
{item.hashShort && (
<span className="font-mono text-gray-400">SHA: {item.hashShort}</span>
)}
</div>
</div>
</div>
</div>
</Card>
))}
</div>
)}
</div>
)
}
+240
View File
@@ -0,0 +1,240 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Bell, CheckCircle, AlertCircle, Send, Settings as SettingsIcon, X } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
const TYPE_LABELS: Record<string, string> = {
CONTRACT_EXPIRY: '合同到期',
CONTRACT_UNSIGNED: '未签合同',
OVERTIME: '加班预警',
OVERTIME_ALERT: '加班预警',
PAYSLIP: '工资条',
PAYSLIP_READY: '工资条',
RISK_ALERT: '风险预警',
SOCIAL_INS: '社保提醒',
HOUSING_FUND: '公积金提醒',
TAX: '税务提醒',
}
const CHANNEL_LABELS: Record<string, string> = {
WECHAT: '企业微信',
EMAIL: '邮件',
IN_APP: '站内',
}
/**
* 通知管理页面
*/
export default function Notifications() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showSettings, setShowSettings] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['notification-logs', page, pageSize],
queryFn: async () => {
const res = await api.get(`/notifications/logs?page=${page}&pageSize=${pageSize}`) as any
return res.data
},
})
const { data: settings } = useQuery<any>({
queryKey: ['notification-settings'],
queryFn: async () => {
const res = await api.get('/notifications/settings') as any
return res.data
},
})
const checkContractsMutation = useMutation({
mutationFn: () => api.post('/notifications/check-contracts'),
onSuccess: () => {
toast.success('合同到期检查已触发')
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
},
onError: () => toast.error('检查失败'),
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Bell 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 gap-2">
<Button size="sm" variant="secondary" onClick={() => checkContractsMutation.mutate()} disabled={checkContractsMutation.isPending}>
<Send className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => setShowSettings(true)}>
<SettingsIcon className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 通知设置概览 */}
{settings && (
<Card>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{[
{ label: '合同到期提醒', enabled: settings.contractExpiry, days: settings.expiryDays },
{ label: '未签合同提醒', enabled: settings.contractUnsigned },
{ label: '加班预警', enabled: settings.overtimeAlert },
{ label: '工资条通知', enabled: settings.payslipReady },
].map(s => (
<div key={s.label} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
{s.enabled ? (
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-gray-400 flex-shrink-0" />
)}
<div className="min-w-0">
<div className="text-xs font-medium truncate">{s.label}</div>
{s.days && <div className="text-xs text-gray-500">{s.days}</div>}
</div>
</div>
))}
</div>
</Card>
)}
{/* 通知记录列表 */}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data?.items || data.items.length === 0 ? (
<EmptyState title="暂无通知记录" description="系统将自动发送合同到期、工资条等通知" />
) : (
<>
<Card>
<div className="space-y-1.5">
{data.items.map((log: any) => (
<div key={log.id} className="flex items-start gap-3 px-2 py-2 rounded-md hover:bg-gray-50 transition-colors">
<div className={`flex items-center justify-center w-7 h-7 rounded-lg flex-shrink-0 ${
log.status === 'SENT' ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'
}`}>
<Bell className="w-3.5 h-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{log.title}</span>
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{TYPE_LABELS[log.type] || log.type}</span>
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-600">{CHANNEL_LABELS[log.channel] || log.channel}</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">{log.content}</div>
</div>
<div className="text-xs text-gray-400 flex-shrink-0">
{new Date(log.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
))}
</div>
</Card>
<Pagination
page={page}
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</>
)}
{/* 通知设置弹窗 */}
{showSettings && settings && (
<SettingsModal settings={settings} onClose={() => setShowSettings(false)} onSuccess={() => { setShowSettings(false); queryClient.invalidateQueries({ queryKey: ['notification-settings'] }) }} />
)}
</div>
)
}
function SettingsModal({ settings, onClose, onSuccess }: { settings: any; onClose: () => void; onSuccess: () => void }) {
const queryClient = useQueryClient()
const [form, setForm] = useState({
contractExpiry: settings.contractExpiry ?? true,
expiryDays: settings.expiryDays ?? 30,
contractUnsigned: settings.contractUnsigned ?? true,
overtimeAlert: settings.overtimeAlert ?? true,
payslipReady: settings.payslipReady ?? true,
payrollDay: settings.payrollDay ?? 10,
socialInsDay: settings.socialInsDay ?? 15,
housingFundDay: settings.housingFundDay ?? 15,
taxDay: settings.taxDay ?? 15,
wechatWebhook: settings.wechatWebhook ?? '',
emailNotify: settings.emailNotify ?? false,
email: settings.email ?? '',
})
const saveMutation = useMutation({
mutationFn: () => api.put('/notifications/settings', form),
onSuccess: () => { toast.success('通知设置已保存'); onSuccess() },
onError: () => toast.error('保存失败'),
})
const toggleItem = (key: string) => setForm(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
{[
{ key: 'contractExpiry', label: '合同到期提醒', desc: '提前提醒即将到期的合同' },
{ key: 'contractUnsigned', label: '未签合同提醒', desc: '入职超过30天未签合同' },
{ key: 'overtimeAlert', label: '加班预警', desc: '加班时长超过法定上限' },
{ key: 'payslipReady', label: '工资条通知', desc: '工资条生成后通知员工' },
{ key: 'emailNotify', label: '邮件通知', desc: '通过邮件发送通知' },
].map(item => (
<div key={item.key} className="flex items-center justify-between p-2 rounded-lg bg-gray-50">
<div>
<div className="text-sm font-medium">{item.label}</div>
<div className="text-xs text-gray-500">{item.desc}</div>
</div>
<button
onClick={() => toggleItem(item.key)}
className={`relative w-10 h-5 rounded-full transition-colors ${form[item.key as keyof typeof form] ? 'bg-primary' : 'bg-gray-300'}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${form[item.key as keyof typeof form] ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
))}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" value={form.expiryDays} onChange={e => setForm(prev => ({ ...prev, expiryDays: parseInt(e.target.value) || 30 }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" min={1} max={365} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" value={form.payrollDay} onChange={e => setForm(prev => ({ ...prev, payrollDay: parseInt(e.target.value) || 10 }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" min={1} max={28} />
</div>
</div>
<div>
<label className="text-xs text-gray-500"> Webhook URL</label>
<input type="url" value={form.wechatWebhook} onChange={e => setForm(prev => ({ ...prev, wechatWebhook: e.target.value }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="email" value={form.email} onChange={e => setForm(prev => ({ ...prev, email: e.target.value }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" placeholder="hr@example.com" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}></Button>
</div>
</div>
</div>
</Card>
</div>
)
}
+273
View File
@@ -0,0 +1,273 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
const STEP_LABELS: Record<string, string> = {
DRAFTING: '起草',
DISCUSSION: '讨论',
CONSULTATION: '协商',
PUBLICATION: '公示',
}
const STEP_ORDER = ['DRAFTING', 'DISCUSSION', 'CONSULTATION', 'PUBLICATION']
/**
* 规章制度民主程序管理页面
*/
export default function Policies() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedPolicy, setSelectedPolicy] = useState<any>(null)
const { data: list, isLoading } = useQuery<any>({
queryKey: ['policies'],
queryFn: async () => {
const res = await api.get('/policies') as any
return res.data
},
})
const advanceMutation = useMutation({
mutationFn: ({ id, step, note }: { id: string; step: number; note?: string }) => api.post(`/policies/${id}/advance-step`, { step, note }),
onSuccess: () => {
toast.success('流程步骤已推进')
queryClient.invalidateQueries({ queryKey: ['policies'] })
// 刷新选中制度详情
if (selectedPolicy) {
api.get(`/policies/${selectedPolicy.id}`).then((res: any) => setSelectedPolicy(res.data))
}
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<FileText 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>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无规章制度" description="点击右上角新建制度" />
) : (
<div className="space-y-2">
{list.map((p: any) => (
<Card key={p.id} className="hover:shadow-md transition-shadow cursor-pointer" >
<div onClick={() => setSelectedPolicy(p)}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{p.title}</span>
{p.status === 'PUBLISHED' ? (
<span className="px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700"></span>
) : (
<span className="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700">{STEP_LABELS[STEP_ORDER[(p.democracyProgress?.currentStep || 1) - 1]] || '起草中'}</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1 truncate">{p.content?.slice(0, 80) || '暂无内容'}</div>
<div className="flex items-center gap-1 mt-2">
{STEP_ORDER.map((step, i) => {
const currentStep = p.democracyProgress?.currentStep || 1
const isDone = i < currentStep - 1 || p.status === 'PUBLISHED'
const isCurrent = i === currentStep - 1 && p.status !== 'PUBLISHED'
return (
<div key={step} className="flex items-center">
<div className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
isDone ? 'bg-green-100 text-green-700' : isCurrent ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-400'
}`}>
{isDone ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
{STEP_LABELS[step]}
</div>
{i < STEP_ORDER.length - 1 && <ChevronRight className="w-3 h-3 text-gray-300" />}
</div>
)
})}
</div>
{p.status === 'PUBLISHED' && p.totalEmployees > 0 && (
<div className="flex items-center gap-2 mt-2 text-xs">
<span className="text-gray-500"></span>
<div className="flex-1 bg-gray-100 rounded-full h-1.5 overflow-hidden max-w-32">
<div className="bg-green-500 h-full rounded-full" style={{ width: `${p.totalEmployees > 0 ? Math.round((p.readCount / p.totalEmployees) * 100) : 0}%` }} />
</div>
<span className="text-gray-600">{p.readCount}/{p.totalEmployees}</span>
</div>
)}
</div>
</div>
</div>
</Card>
))}
</div>
)}
{/* 详情弹窗 */}
{selectedPolicy && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelectedPolicy(null)}>
<Card className="max-w-2xl w-full max-h-[80vh] overflow-y-auto" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{selectedPolicy.title}</h2>
<button onClick={() => setSelectedPolicy(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
<div className="text-sm text-gray-600 whitespace-pre-wrap mb-4">{selectedPolicy.content || '暂无内容'}</div>
<div className="space-y-2 border-t pt-3">
<div className="text-xs font-medium text-gray-600"></div>
{(selectedPolicy.democracyProgress?.steps || []).map((s: any, i: number) => {
const isDone = s.status === 'COMPLETED' || (selectedPolicy.status === 'PUBLISHED')
const isCurrent = s.status === 'IN_PROGRESS' && selectedPolicy.status !== 'PUBLISHED'
return (
<div key={i} className={`p-2 rounded-lg ${isDone ? 'bg-green-50' : isCurrent ? 'bg-primary/5' : 'bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="text-sm flex items-center gap-1.5">
{isDone ? <CheckCircle className="w-4 h-4 text-green-600" /> : <Clock className="w-4 h-4 text-gray-400" />}
{s.name}
<span className="text-xs text-gray-400 font-normal">· {s.description}</span>
</span>
{isCurrent && (
<Button
size="sm"
onClick={() => {
const note = window.prompt('请输入本步骤备注(可选)') || ''
advanceMutation.mutate({ id: selectedPolicy.id, step: i + 1, note })
}}
>
</Button>
)}
</div>
{s.date && (
<div className="text-xs text-gray-500 mt-1">
{s.status === 'COMPLETED' ? '完成时间' : '开始时间'}{s.date}
{s.note && <span className="ml-2 text-gray-400">{s.note}</span>}
</div>
)}
</div>
)
})}
</div>
{/* 阅读签收统计(仅已公示制度显示) */}
{selectedPolicy.status === 'PUBLISHED' && (
<ReadStats policyId={selectedPolicy.id} />
)}
</div>
</Card>
</div>
)}
{/* 新建弹窗 */}
{showCreate && (
<CreatePolicyModal onClose={() => setShowCreate(false)} onSuccess={() => { setShowCreate(false); queryClient.invalidateQueries({ queryKey: ['policies'] }) }} />
)}
</div>
)
}
function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [type, setType] = useState('RULES')
const createMutation = useMutation({
mutationFn: () => api.post('/policies', { title, content, type }),
onSuccess: () => { toast.success('制度已创建'); onSuccess() },
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="max-w-lg w-full" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-600"></label>
<input value={title} onChange={e => setTitle(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="如:考勤管理制度" />
</div>
<div>
<label className="text-xs text-gray-600"></label>
<select value={type} onChange={e => setType(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option value="RULES"></option>
<option value="NOTICE"></option>
</select>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文..." />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => createMutation.mutate()} disabled={!title || !content || createMutation.isPending}></Button>
</div>
</div>
</div>
</Card>
</div>
)
}
/**
* 阅读签收统计组件
*/
function ReadStats({ policyId }: { policyId: string }) {
const { data, isLoading } = useQuery<any>({
queryKey: ['policy-read-stats', policyId],
queryFn: async () => {
const res = await api.get(`/policies/${policyId}/read-stats`) as any
return res.data
},
})
if (isLoading) return <div className="text-xs text-gray-400 mt-3">...</div>
if (!data) return null
const percent = data.total > 0 ? Math.round((data.readCount / data.total) * 100) : 0
return (
<div className="mt-4 border-t pt-3">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="flex items-center gap-3 mb-3">
<div className="flex-1 bg-gray-100 rounded-full h-2 overflow-hidden">
<div className="bg-green-500 h-full rounded-full transition-all" style={{ width: `${percent}%` }} />
</div>
<span className="text-xs text-gray-600 shrink-0">
{data.readCount}/{data.total} {percent}%
</span>
</div>
{data.unreadCount > 0 && (
<div className="text-xs text-amber-600 mb-2">
{data.unreadCount}
</div>
)}
{data.records && data.records.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1">
{data.records.map((r: any) => (
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-gray-50 text-xs">
<span className="text-gray-700">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
<span className="text-gray-400">{r.readAt?.slice(0, 16).replace('T', ' ')}</span>
</div>
))}
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -80,7 +80,7 @@ export default function Settings() {
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && (
<Card>
<h2 className="font-medium mb-4"></h2>
<h2 className="text-sm font-medium mb-4"></h2>
<ExportSettings />
</Card>
)}
@@ -111,7 +111,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
return (
<Card>
<h2 className="font-medium mb-4"></h2>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-3">
<div>
<Label></Label>
@@ -283,7 +283,7 @@ function UserSettings({ usersData }: { usersData: any }) {
return (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-medium"></h2>
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
@@ -673,7 +673,7 @@ function NotificationSettings() {
return (
<div className="space-y-3">
<Card>
<h2 className="font-medium mb-4"></h2>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-3">
<label className="flex items-center justify-between">
<span className="text-xs"></span>
@@ -749,7 +749,7 @@ function NotificationSettings() {
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-medium"></h2>
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => checkMutation.mutate()} disabled={checkMutation.isPending}>
{checkMutation.isPending ? '检查中...' : '立即检查'}
</Button>
+208
View File
@@ -0,0 +1,208 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { FileText, Copy, X, ChevronRight } from 'lucide-react'
import { toast } from 'sonner'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
const CATEGORY_LABELS: Record<string, string> = {
CONTRACT: '合同',
RULES: '规章制度',
NOTICE: '通知',
AGREEMENT: '协议',
OTHER: '其他',
}
const VARIABLE_LABELS: Record<string, string> = {
companyName: '公司名称',
employeeName: '员工姓名',
idCard: '身份证号',
address: '地址',
startDate: '开始日期',
position: '岗位',
endDate: '结束日期',
terminationDate: '解除日期',
publishDate: '公示日期',
effectiveDate: '生效日期',
meetingDate: '会议日期',
meetingLocation: '会议地点',
policyTitle: '制度名称',
department: '部门',
violationDate: '违纪日期',
violationDescription: '违纪描述',
probationEndDate: '试用期截止',
regularDate: '转正日期',
contractEndDate: '合同到期日',
salary: '工资',
phone: '手机号',
hireDate: '入职日期',
gender: '性别',
birthDate: '出生日期',
bankAccount: '银行账号',
bankName: '开户银行',
emergencyContact: '紧急联系人',
emergencyPhone: '紧急联系电话',
workYears: '工作年限',
compAmount: '补偿金额',
noticeDate: '通知日期',
reason: '原因',
overtimeHours: '加班时长',
overtimePay: '加班费',
socialInsBase: '社保基数',
housingFundBase: '公积金基数',
compensation: '经济补偿',
lastWorkDay: '最后工作日',
socialInsEndMonth: '社保截止月',
housingFundEndMonth: '公积金截止月',
probationMonths: '试用期月数',
monthlySalary: '月薪',
workplace: '工作地点',
disciplineType: '处分类型',
policyBasis: '制度依据',
}
/**
* 用工文本模板库页面
*/
export default function Templates() {
const [category, setCategory] = useState<string>('')
const [selected, setSelected] = useState<any>(null)
const [rendered, setRendered] = useState<string>('')
const [variables, setVariables] = useState<Record<string, string>>({})
const { data: list, isLoading } = useQuery<any>({
queryKey: ['templates', category],
queryFn: async () => {
const params = category ? `?category=${category}` : ''
const res = await api.get(`/templates${params}`) as any
return res.data
},
})
const { data: detail } = useQuery<any>({
queryKey: ['template-detail', selected?.id],
queryFn: async () => {
const res = await api.get(`/templates/${selected.id}`) as any
return res.data
},
enabled: !!selected,
})
const handleRender = async () => {
if (!selected) return
try {
const res = await api.post(`/templates/${selected.id}/render`, { variables }) as any
setRendered(res.data.content)
} catch (err: any) {
toast.error('渲染失败')
}
}
const handleCopy = () => {
navigator.clipboard.writeText(rendered)
toast.success('已复制到剪贴板')
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="text-sm text-gray-500"></p>
<div className="flex gap-2">
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => (
<button
key={c}
onClick={() => setCategory(c)}
className={`px-3 py-1 text-xs rounded-lg ${category === c ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{c === '' ? '全部' : CATEGORY_LABELS[c]}
</button>
))}
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无模板" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{list.map((t: any) => (
<Card key={t.id} className="hover:shadow-md transition-shadow cursor-pointer" >
<div onClick={() => { setSelected(t); setRendered(''); setVariables({}) }}>
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 rounded text-xs bg-primary/10 text-primary">{CATEGORY_LABELS[t.category]}</span>
<span className="text-sm font-medium truncate">{t.name}</span>
</div>
<p className="text-xs text-gray-500 mt-1">{t.description}</p>
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
{t.variables?.slice(0, 4).map((v: string) => (
<span key={v} className="px-1 py-0.5 rounded bg-gray-100">{VARIABLE_LABELS[v] || v}</span>
))}
{t.variables?.length > 4 && <span>+{t.variables.length - 4}</span>}
</div>
</div>
</Card>
))}
</div>
)}
{/* 模板详情弹窗 */}
{selected && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelected(null)}>
<Card className="max-w-3xl w-full max-h-[85vh] overflow-y-auto" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{selected.name}</h2>
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
{/* 变量输入 */}
{detail?.variables && detail.variables.length > 0 && (
<div className="mb-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
<div className="grid grid-cols-2 gap-2">
{detail.variables.map((v: string) => (
<div key={v}>
<label className="text-xs text-gray-500">{VARIABLE_LABELS[v] || v}</label>
<input
value={variables[v] || ''}
onChange={e => setVariables(prev => ({ ...prev, [v]: e.target.value }))}
className="w-full px-2 py-1 text-sm border rounded focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={`输入${VARIABLE_LABELS[v] || v}`}
/>
</div>
))}
</div>
<Button size="sm" onClick={handleRender}></Button>
</div>
)}
{/* 渲染结果 */}
{rendered ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600"></span>
<button onClick={handleCopy} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Copy className="w-3 h-3" />
</button>
</div>
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{rendered}</pre>
</div>
) : detail?.content ? (
<div>
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{detail.content}</pre>
</div>
) : null}
</div>
</Card>
</div>
)}
</div>
)
}
+1 -1
View File
@@ -1602,7 +1602,7 @@ export default function Termination() {
<div className="w-72 shrink-0 hidden md:block">
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-medium flex items-center gap-1.5">
<h2 className="text-sm font-medium flex items-center gap-1.5">
<List className="w-4 h-4" />
</h2>
<div className="flex gap-1">
+160
View File
@@ -0,0 +1,160 @@
/**
* 员工端 — 规章制度公示页面
* 展示已公示制度列表,员工可阅读并签收确认
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, CheckCircle, Clock, ArrowLeft, ChevronRight } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState'
import PortalNav from './PortalNav'
const portalApi = api.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export default function MyPolicies() {
const queryClient = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
/** 已公示制度列表 */
const { data: list, isLoading } = useQuery<any>({
queryKey: ['portal-policies'],
queryFn: async () => {
const res = await portalApi.get('/policies') as any
return res.data ?? []
},
})
/** 制度详情 */
const { data: detail, isLoading: detailLoading } = useQuery<any>({
queryKey: ['portal-policy', selectedId],
queryFn: async () => {
if (!selectedId) return null
const res = await portalApi.get(`/policies/${selectedId}`) as any
return res.data ?? null
},
enabled: !!selectedId,
})
/** 阅读确认 */
const readMutation = useMutation({
mutationFn: (id: string) => portalApi.post(`/policies/${id}/read`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-policies'] })
queryClient.invalidateQueries({ queryKey: ['portal-policy', selectedId] })
},
})
if (selectedId) {
return (
<div className="min-h-screen bg-surface">
<div className="max-w-md mx-auto py-6 px-4">
<PortalNav />
<button
onClick={() => setSelectedId(null)}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm mb-4"
>
<ArrowLeft className="w-4 h-4" />
</button>
{detailLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : detail ? (
<Card>
<div className="flex items-center gap-2 mb-3">
<FileText className="w-5 h-5 text-primary" />
<h1 className="text-base font-semibold">{detail.title}</h1>
</div>
<div className="flex items-center gap-2 mb-4">
<span className="text-xs text-gray-500">
{detail.publishedAt?.slice(0, 10) || '-'}
</span>
{detail.hasRead ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-100 text-green-700">
<CheckCircle className="w-3 h-3" />
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
</span>
)}
</div>
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed mb-6">
{detail.content || '暂无内容'}
</div>
<div className="border-t pt-4">
{detail.hasRead ? (
<div className="text-center text-xs text-gray-500">
{detail.readAt?.slice(0, 19).replace('T', ' ')}
</div>
) : (
<Button
className="w-full"
onClick={() => readMutation.mutate(detail.id)}
disabled={readMutation.isPending}
>
{readMutation.isPending ? '确认中...' : '我已阅读并理解,确认签收'}
</Button>
)}
</div>
</Card>
) : (
<EmptyState title="制度不存在" description="该制度可能已被撤回" />
)}
</div>
</div>
)
}
return (
<div className="min-h-screen bg-surface">
<div className="max-w-md mx-auto py-6 px-4">
<PortalNav />
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无公示制度" description="公司尚未公示任何规章制度" />
) : (
<div className="space-y-2">
{list.map((p: any) => (
<Card key={p.id} className="cursor-pointer hover:shadow-md transition-shadow" >
<div onClick={() => setSelectedId(p.id)} className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{p.title}</span>
{p.hasRead ? (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700">
<CheckCircle className="w-3 h-3" />
</span>
) : (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{p.publishedAt?.slice(0, 10) || '-'}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
</div>
</Card>
))}
</div>
)}
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 员工端底部导航栏
*/
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
const navItems = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
{ path: '/portal/contract', label: '我的合同', icon: FileText },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
]
export default function PortalNav() {
const location = useLocation()
const navigate = useNavigate()
const handleLogout = () => {
localStorage.removeItem('portalToken')
localStorage.removeItem('portalEmployee')
navigate('/portal/login')
}
return (
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<div className="flex items-center gap-4">
{navItems.map((item) => {
const Icon = item.icon
const active = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-1 text-sm ${active ? 'text-primary font-medium' : 'text-gray-500 hover:text-gray-700'}`}
>
<Icon className="w-4 h-4" />
{item.label}
</Link>
)
})}
</div>
<button
onClick={handleLogout}
className="flex items-center gap-1 text-sm text-gray-400 hover:text-gray-600"
>
<LogOut className="w-4 h-4" />
退
</button>
</div>
)
}
@@ -0,0 +1,112 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => api.post('/attachments', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
const deleteAttachmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 文件类型校验
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
return
}
// 文件大小校验(10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)}`)
return
}
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addAttachmentMutation.mutate({ employeeId, fileName: file.name, fileType, fileUrl, fileSize: file.size })
}
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
const formatSize = (bytes: number) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
return (
<div className="space-y-3">
<h2 className="text-xs font-medium">{attachments?.length || 0}</h2>
<Card>
<div className="flex gap-2 mb-3 flex-nowrap items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-32 shrink-0">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
</Button>
<span className="text-gray-400 text-xs"> PDF/JPG/PNG 10MB</span>
</div>
{attachments?.length ? (
<div className="space-y-2">
{attachments.map((att) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2.5 text-xs hover:bg-gray-100">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
<span className="text-gray-400">{new Date(att.createdAt).toLocaleDateString('zh-CN')}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
<Eye className="w-4 h-4" />
</button>
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
<Download className="w-4 h-4" />
</a>
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
) : <div className="text-gray-400 text-xs text-center py-4"></div>}
</Card>
</div>
)
}
@@ -0,0 +1,236 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
/** 考勤/加班/培训合并组件 */
export default function AttendanceOvertimeInfo({ employeeId, attendanceRecords, overtimeRecords, trainingRecords }: { employeeId: string; attendanceRecords: any[]; overtimeRecords: any[]; trainingRecords: any[] }) {
const queryClient = useQueryClient()
const [subTab, setSubTab] = useState<'attendance' | 'overtime' | 'training'>('attendance')
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ date: '', checkInTime: '', checkOutTime: '', status: 'NORMAL', lateMinutes: 0, earlyMinutes: 0, workHours: 8, overtimeHours: 0, remark: '' })
const [trainingForm, setTrainingForm] = useState({ trainingDate: '', topic: '', content: '', trainer: '', duration: 1, ackStatus: 'PENDING', ackDate: '', remark: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/attendance`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/attendance/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const createTrainingMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/training`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteTrainingMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/training/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
const statusMap: Record<string, string> = { NORMAL: '正常', LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
const statusColor: Record<string, string> = { NORMAL: 'bg-green-50 text-safe', LATE: 'bg-amber-50 text-warning', EARLY_LEAVE: 'bg-amber-50 text-warning', ABSENT: 'bg-red-50 text-danger', LEAVE: 'bg-blue-50 text-blue-600', BUSINESS_TRIP: 'bg-blue-50 text-blue-600' }
const totalPay = (overtimeRecords || []).reduce((sum, o) => sum + (o.totalPay || 0), 0)
const totalWeekday = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekdayHours || 0), 0)
const totalWeekend = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekendHours || 0), 0)
const totalHoliday = (overtimeRecords || []).reduce((sum, o) => sum + (o.holidayHours || 0), 0)
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<div className="flex gap-1">
<button
onClick={() => setSubTab('attendance')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'attendance' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{attendanceRecords?.length || 0}
</button>
<button
onClick={() => setSubTab('overtime')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'overtime' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{overtimeRecords?.length || 0}
</button>
<button
onClick={() => setSubTab('training')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'training' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{trainingRecords?.length || 0}
</button>
</div>
{subTab === 'attendance' && (
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
)}
{subTab === 'training' && (
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
)}
</div>
{subTab === 'attendance' && (
<>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} /></div>
<div><Label></Label>
<Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label><Input type="time" value={form.checkInTime} onChange={(e) => setForm({ ...form, checkInTime: e.target.value })} /></div>
<div><Label>退</Label><Input type="time" value={form.checkOutTime} onChange={(e) => setForm({ ...form, checkOutTime: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.lateMinutes} onChange={(e) => setForm({ ...form, lateMinutes: Number(e.target.value) })} /></div>
<div><Label>退</Label><Input type="number" value={form.earlyMinutes} onChange={(e) => setForm({ ...form, earlyMinutes: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={form.workHours} onChange={(e) => setForm({ ...form, workHours: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={form.overtimeHours} onChange={(e) => setForm({ ...form, overtimeHours: Number(e.target.value) })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} /></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.date}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
{attendanceRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">退</th>
<th className="py-2 text-center"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{attendanceRecords?.map((a) => (
<tr key={a.id} className="border-b last:border-0">
<td className="py-2">{a.date?.toString().slice(0, 10)}</td>
<td className="py-2 text-gray-500">{a.checkInTime || '-'}</td>
<td className="py-2 text-gray-500">{a.checkOutTime || '-'}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${statusColor[a.status] || 'bg-gray-100'}`}>{statusMap[a.status] || a.status}</span></td>
<td className="py-2 text-right">{a.workHours}h</td>
<td className="py-2 text-right">{a.overtimeHours > 0 ? `${a.overtimeHours}h` : '-'}</td>
<td className="py-2"><button onClick={() => deleteMutation.mutate(a.id)} className="text-xs text-gray-400 hover:text-danger"></button></td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</>
)}
{subTab === 'overtime' && (
<>
{overtimeRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{overtimeRecords.map((o) => (
<tr key={o.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{o.month}</td>
<td className="py-2 text-right text-gray-600">{o.weekdayHours || '-'}</td>
<td className="py-2 text-right text-gray-600">{o.weekendHours || '-'}</td>
<td className="py-2 text-right text-gray-600">{o.holidayHours || '-'}</td>
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(o.totalPay)}</td>
</tr>
))}
<tr className="border-t-2 bg-gray-50">
<td className="py-2 font-medium"></td>
<td className="py-2 text-right font-medium text-gray-600">{totalWeekday}</td>
<td className="py-2 text-right font-medium text-gray-600">{totalWeekend}</td>
<td className="py-2 text-right font-medium text-gray-600">{totalHoliday}</td>
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(totalPay)}</td>
</tr>
</tbody>
</table>
</Card>
)}
</>
)}
{subTab === 'training' && (
<>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input type="date" value={trainingForm.trainingDate} onChange={(e) => setTrainingForm({ ...trainingForm, trainingDate: e.target.value })} /></div>
<div><Label>/</Label><Input value={trainingForm.topic} onChange={(e) => setTrainingForm({ ...trainingForm, topic: e.target.value })} placeholder="如《员工手册》培训" /></div>
<div className="md:col-span-2"><Label></Label><Input value={trainingForm.content} onChange={(e) => setTrainingForm({ ...trainingForm, content: e.target.value })} /></div>
<div><Label></Label><Input value={trainingForm.trainer} onChange={(e) => setTrainingForm({ ...trainingForm, trainer: e.target.value })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={trainingForm.duration} onChange={(e) => setTrainingForm({ ...trainingForm, duration: Number(e.target.value) })} /></div>
<div><Label></Label>
<Select value={trainingForm.ackStatus} onChange={(e) => setTrainingForm({ ...trainingForm, ackStatus: e.target.value })}>
{Object.entries(ackMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
{trainingForm.ackStatus === 'SIGNED' && <div><Label></Label><Input type="date" value={trainingForm.ackDate} onChange={(e) => setTrainingForm({ ...trainingForm, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2"><Label></Label><Input value={trainingForm.remark} onChange={(e) => setTrainingForm({ ...trainingForm, remark: e.target.value })} /></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createTrainingMutation.mutate(trainingForm)} disabled={createTrainingMutation.isPending || !trainingForm.trainingDate || !trainingForm.topic}>{createTrainingMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
{trainingRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : trainingRecords?.map((r) => (
<Card key={r.id} className="p-4">
<div className="flex justify-between items-start gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-400">{r.trainingDate?.toString().slice(0, 10)}</span>
<span className={`px-2 py-0.5 rounded text-xs ${r.ackStatus === 'SIGNED' ? 'bg-green-50 text-safe' : r.ackStatus === 'REFUSED' ? 'bg-red-50 text-danger' : 'bg-amber-50 text-amber-600'}`}>
{r.ackStatus === 'SIGNED' ? '已签收' : r.ackStatus === 'REFUSED' ? '拒绝签收' : '待签收'}
</span>
</div>
<div className="text-xs font-medium text-gray-700">{r.topic}</div>
{r.content && <div className="text-xs text-gray-500 leading-relaxed">{r.content}</div>}
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
<span> {r.duration}h</span>
{r.trainer && <span>{r.trainer}</span>}
{r.ackDate && <span className="text-safe">{r.ackDate.toString().slice(0, 10)}</span>}
{r.remark && <span className="text-gray-400">{r.remark}</span>}
</div>
</div>
<button onClick={() => deleteTrainingMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</Card>
))}
</>
)}
</div>
)
}
+395
View File
@@ -0,0 +1,395 @@
import { QRCodeSVG } from "qrcode.react"
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => api.post('/attachments', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
const deleteAttachmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
return
}
if (file.size > 10 * 1024 * 1024) {
toast.error('文件过大,请上传小于 10MB 的文件')
return
}
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addAttachmentMutation.mutate({ employeeId, fileName: file.name, fileType, fileUrl, fileSize: file.size })
}
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
const formatSize = (bytes: number) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
const [form, setForm] = useState({
department: profile.department || '',
gender: profile.gender || '男',
femaleWorkerType: profile.femaleWorkerType || '',
phone: profile.phone || '',
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
monthlySalary: profile.monthlySalary || '',
emergencyContact: profile.emergencyContact || '',
emergencyPhone: profile.emergencyPhone || '',
address: profile.address || '',
bankName: profile.bankName || '',
bankAccount: profile.bankAccount || '',
isPregnant: profile.isPregnant || false,
isInMedicalPeriod: profile.isInMedicalPeriod || false,
isWorkInjured: profile.isWorkInjured || false,
socialInsBase: profile.socialInsBase ?? '',
housingFundBase: profile.housingFundBase ?? '',
specialDeduction: profile.specialDeduction ?? 0,
city: profile.city || '',
cityChangeReason: '',
})
const updateMutation = useMutation({
mutationFn: (data: any) => api.put(`/employees/${profile.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setEditing(false)
},
})
const handleSave = () => {
if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) {
toast.error('参保城市变更必须填写变更原因')
return
}
const data: any = {
department: form.department,
gender: form.gender,
femaleWorkerType: form.femaleWorkerType || undefined,
phone: form.phone || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: String(form.monthlySalary),
emergencyContact: form.emergencyContact || undefined,
emergencyPhone: form.emergencyPhone || undefined,
address: form.address || undefined,
bankName: form.bankName || undefined,
bankAccount: form.bankAccount || undefined,
isPregnant: form.isPregnant,
isInMedicalPeriod: form.isInMedicalPeriod,
isWorkInjured: form.isWorkInjured,
socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase),
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
specialDeduction: Number(form.specialDeduction) || 0,
city: form.city || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
updateMutation.mutate(data)
}
const personalFields = [
{ label: '姓名', value: profile.name },
{ label: '部门', value: profile.department },
{ label: '性别', value: profile.gender || '未填写' },
...(profile.gender === '女'
? [{ label: '女性岗位', value: profile.femaleWorkerType === 'CADRE' ? '干部/管理岗' : profile.femaleWorkerType === 'WORKER' ? '工人/操作岗' : '未填写' }]
: []),
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
? (() => {
if (profile.retirementDaysLeft <= 0) return [{ label: '距退休', value: '已到退休年龄' }]
if (profile.birthDate) {
const bd = new Date(profile.birthDate)
const gender = profile.gender || '男'
const fwt = profile.femaleWorkerType || null
const baseAge = gender === '男' ? 60 : (fwt === 'WORKER' ? 50 : 55)
const delayInterval = gender === '男' ? 4 : (fwt === 'WORKER' ? 2 : 4)
const maxDelay = gender === '男' ? 36 : (fwt === 'WORKER' ? 60 : 36)
const baseRetireDate = new Date(bd)
baseRetireDate.setFullYear(baseRetireDate.getFullYear() + baseAge)
const reformStart = new Date(2025, 0, 1)
const monthsSince = Math.max(0, (baseRetireDate.getFullYear() - reformStart.getFullYear()) * 12 + (baseRetireDate.getMonth() - reformStart.getMonth()))
const delayMonths = Math.min(maxDelay, Math.floor(monthsSince / delayInterval))
const retireDate = new Date(baseRetireDate)
retireDate.setMonth(retireDate.getMonth() + delayMonths)
return [{ label: '退休日期', value: `${retireDate.getFullYear()}${retireDate.getMonth() + 1}${retireDate.getDate()}` }]
}
return [{ label: '距退休', value: `${profile.retirementDaysLeft}` }]
})()
: []),
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
? [{ label: '离职日期', value: profile.terminations
.map((t: any) => t.terminationDate?.toString().slice(0, 10))
.filter(Boolean)
.sort()
.reverse()[0] || '未记录' }]
: []),
]
const salaryFields = [
{ label: '月工资', value: `¥${fmt(profile.monthlySalary)}` },
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
{ label: '住址', value: profile.address || '未填写' },
{ label: '开户行', value: profile.bankName || '未填写' },
{ label: '银行账号', value: profile.bankAccount || '未填写' },
]
const special = [
{ label: '孕期', value: profile.isPregnant },
{ label: '医疗期', value: profile.isInMedicalPeriod },
{ label: '工伤', value: profile.isWorkInjured },
]
return (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-medium"></h2>
{!editing ? (
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}></Button>
) : (
<div className="flex gap-2">
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}></Button>
</div>
)}
</div>
{!editing ? (
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-gray-600 mb-2"></h3>
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3">
{personalFields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2">{f.value}</span>
</div>
))}
</div>
</div>
<div className="pt-3 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-2"></h3>
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3">
{salaryFields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2">{f.value}</span>
</div>
))}
</div>
</div>
</div>
) : (
<div className="grid md:grid-cols-2 gap-3">
<div><Label></Label><Input value={profile.name} disabled /></div>
<div><Label></Label><Input value={profile.idCardNumber || ''} disabled /></div>
<div><Label></Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} /></div>
<div><Label></Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男"></option><option value="女"></option></Select></div>
{form.gender === '女' && (
<div><Label></Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value=""></option><option value="CADRE">/</option><option value="WORKER">/</option></Select></div>
)}
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></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>
<div><Label></Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
<div><Label></Label><Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" /></div>
<div><Label></Label><Input value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} placeholder="选填" /></div>
</div>
)}
{/* 薪税信息 */}
<div className="mt-4 pt-4 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-3"></h3>
{!editing ? (
<div className="grid md:grid-cols-4 gap-4">
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.city || '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
</div>
</div>
) : (
<div className="grid md:grid-cols-4 gap-4">
<div>
<Label></Label>
<Input placeholder="如 北京" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
</div>
<div>
<Label>/</Label>
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
</div>
{form.city !== (profile.city || '') && (
<div className="md:col-span-4">
<Label></Label>
<Input placeholder="如:员工从北京调往上海工作" value={form.cityChangeReason} onChange={(e) => setForm({ ...form, cityChangeReason: e.target.value })} />
</div>
)}
</div>
)}
<p className="text-xs text-gray-400 mt-2">/7portal端填报0</p>
</div>
{/* 特殊状态 */}
<div className="mt-4 pt-4 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-3"></h3>
{!editing ? (
<div className="flex gap-4">
{special.map((s) => (
<span key={s.label} className={`px-3 py-1 rounded text-xs ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
{s.label}{s.value ? '是' : '否'}
</span>
))}
</div>
) : (
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
/
</label>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
</label>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
</label>
</div>
)}
{(profile.isPregnant || profile.isInMedicalPeriod || profile.isWorkInjured) && !editing && (
<p className="text-xs text-amber-600 mt-2"> </p>
)}
</div>
{/* 员工端二维码 */}
{!editing && profile.phone && (
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-medium text-gray-600"></h3>
<Button size="sm" variant="secondary" onClick={() => {
const url = `${window.location.origin}/portal/login`
navigator.clipboard?.writeText(url)
}}>
</Button>
</div>
<div className="flex items-center gap-4">
<div className="bg-white p-3 rounded-lg border">
<QRCodeSVG
value={`${window.location.origin}/portal/login`}
size={120}
level="M"
/>
</div>
<div className="text-xs text-gray-500 space-y-1">
<p>使</p>
<p></p>
<p className="text-gray-400">{window.location.origin}/portal/login</p>
</div>
</div>
</div>
)}
{/* 附件管理 */}
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-medium text-gray-600">{attachments?.length || 0}</h3>
{!editing && (
<div className="flex gap-2 items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
</Button>
</div>
)}
</div>
{!editing && attachments?.length ? (
<div className="space-y-1.5">
{attachments.map((att) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
<Eye className="w-3.5 h-3.5" />
</button>
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
<Download className="w-3.5 h-3.5" />
</a>
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
) : !editing ? <div className="text-gray-400 text-xs text-center py-3"></div> : null}
</div>
</Card>
)
}
@@ -0,0 +1,140 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt, terminateReasonMap } from "./shared"
import { CityHistoryTab } from "./PayslipSocialInfo"
/** 变更历史Tab:分组显示各类变更记录 */
export default function ChangeHistoryTab({ profile }: { profile: any }) {
const salaryChanges = profile.salaryChanges || []
const departmentRecords = profile.departmentRecords || []
const socialInsRecords = profile.socialInsRecords || []
const housingFundRecords = profile.housingFundRecords || []
const terminations = profile.terminations || []
const changeTypeMap: Record<string, string> = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', SALARY_CHANGE: '调薪', TRANSFER: '调部门', CITY_CHANGE: '城市变更' }
const changeTypeColor: Record<string, string> = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', SALARY_CHANGE: 'bg-indigo-50 text-indigo-600', TRANSFER: 'bg-purple-50 text-purple-600', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' }
const totalChanges = salaryChanges.length + departmentRecords.length + socialInsRecords.length + housingFundRecords.length + terminations.length
if (totalChanges === 0) {
return <Card><div className="text-center py-8 text-gray-400"></div></Card>
}
return (
<div className="space-y-4">
{/* 薪资变更 */}
{salaryChanges.length > 0 && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-indigo-600" />
{salaryChanges.length}
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{salaryChanges.map((r: any, idx: number) => (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{new Date(r.effectiveDate).toLocaleDateString('zh-CN')}</td>
<td className="py-2 text-right text-gray-600">¥{fmt(r.oldSalary)}</td>
<td className="py-2 text-right text-gray-600">¥{fmt(r.newSalary)}</td>
<td className="py-2 text-right font-medium text-primary">{r.newSalary >= r.oldSalary ? '+' : ''}¥{fmt(r.newSalary - r.oldSalary)}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
<td className="py-2 text-gray-400 text-xs">{r.reason || '-'}</td>
<td className="py-2 text-gray-400 text-xs">{r.endMonth || '至今'}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{/* 部门变更 */}
{departmentRecords.length > 0 && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4 text-purple-600" />
{departmentRecords.length}
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{departmentRecords.map((r: any, idx: number) => (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{r.effectiveMonth}</td>
<td className="py-2 text-gray-600">{r.oldDepartment || '无'}</td>
<td className="py-2 font-medium">{r.newDepartment}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
<td className="py-2 text-gray-400 text-xs">{r.reason || '-'}</td>
<td className="py-2 text-gray-400 text-xs">{r.endMonth || '至今'}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{/* 参保城市变更(社保+公积金合并) */}
{(socialInsRecords.length > 0 || housingFundRecords.length > 0) && (
<CityHistoryTab socialInsRecords={socialInsRecords} housingFundRecords={housingFundRecords} changeTypeMap={changeTypeMap} changeTypeColor={changeTypeColor} />
)}
{/* 离职/解聘记录 */}
{terminations.length > 0 && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<UserX className="w-4 h-4 text-danger" />
/{terminations.length}
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{terminations.map((r: any, idx: number) => (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{new Date(r.terminationDate).toLocaleDateString('zh-CN')}</td>
<td className="py-2 text-center"><span className="px-2 py-0.5 rounded text-xs bg-red-50 text-danger">{terminateReasonMap[r.terminationType] || r.terminationType}</span></td>
<td className="py-2 text-gray-600 text-xs">{r.reason || '-'}</td>
<td className="py-2 text-gray-400 text-xs">{r.remark || '-'}</td>
<td className="py-2 text-gray-400 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', electronicContractNo: '', electronicContractUrl: '' })
const contractFileRef = useRef<HTMLInputElement>(null)
const addContractMutation = useMutation({
mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 文件类型校验
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
return
}
// 文件大小校验(10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
const formatSize = (bytes: number) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)}KB` : `${(bytes / 1024 / 1024).toFixed(1)}MB`
toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)}`)
return
}
const reader = new FileReader()
reader.onload = (event) => {
setForm({ ...form, attachmentUrl: event.target?.result as string })
}
reader.readAsDataURL(file)
}
const typeMap: Record<string, string> = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' }
// 按劳动合同法自动判断续签和合同类型建议
const contractAdvice = (() => {
if (!contracts?.length) return null
const fixedContracts = contracts.filter((c: any) => c.contractType === 'FIXED')
const latestContract = contracts[0]
const isRenewal = !!latestContract?.endDate
const renewalCount = (latestContract?.renewalCount || 0)
// 连续订立二次固定期限劳动合同,第三次应订立无固定期限
const shouldUnfixed = fixedContracts.length >= 2
// 连续工作满十年
const yearsSinceHire = hireDate ? (Date.now() - new Date(hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000) : 0
const shouldUnfixedByTenure = yearsSinceHire >= 10
if (shouldUnfixed || shouldUnfixedByTenure) {
return {
isRenewal,
renewalCount: isRenewal ? renewalCount + 1 : renewalCount,
suggestedType: 'UNFIXED',
reason: shouldUnfixed
? `已连续签订${fixedContracts.length}次固定期限合同,按《劳动合同法》第十四条应订立无固定期限合同`
: `连续工作满${Math.floor(yearsSinceHire)}年,按《劳动合同法》第十四条应订立无固定期限合同`,
}
}
if (isRenewal) {
return {
isRenewal: true,
renewalCount: renewalCount + 1,
suggestedType: 'FIXED',
reason: `本次为第${renewalCount + 1}次续签`,
}
}
return null
})()
const handleShowForm = () => {
if (contractAdvice?.suggestedType) {
setForm({
...form,
contractType: contractAdvice.suggestedType,
signDate: new Date().toISOString().slice(0, 10),
startDate: contractAdvice.isRenewal && contracts[0]?.endDate
? contracts[0].endDate.toString().slice(0, 10)
: new Date().toISOString().slice(0, 10),
endDate: '',
probationMonths: 0,
probationSalary: 0,
signMethod: 'PAPER',
attachmentUrl: '',
electronicContractNo: '',
electronicContractUrl: '',
})
}
setShowForm(!showForm)
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="text-xs font-medium">{contracts?.length || 0}</h2>
<Button size="sm" onClick={handleShowForm}></Button>
</div>
{contractAdvice && !showForm && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>{contractAdvice.reason}{contractAdvice.suggestedType === 'UNFIXED' ? '无固定期限' : '固定期限'}</span>
</div>
)}
{showForm && (
<Card>
{contractAdvice && (
<div className="px-3 py-2 mb-3 rounded-md bg-amber-50 text-amber-700 text-xs flex items-start gap-2">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>{contractAdvice.reason}</span>
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value })}>
<option value="FIXED"></option>
<option value="UNFIXED"></option>
</Select>
</div>
<div><Label></Label><Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} /></div>
<div><Label> *</Label><Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} /></div>
{form.contractType === 'FIXED' && (
<div><Label></Label><Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} /></div>
)}
{form.contractType === 'FIXED' && !contractAdvice?.isRenewal && (
<>
<div><Label>()</Label><Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} /></div>
<div><Label></Label><Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} /></div>
</>
)}
<div className="md:col-span-2 border-t pt-3">
<Label></Label>
<Select value={form.signMethod} onChange={(e) => setForm({ ...form, signMethod: e.target.value as 'PAPER' | 'ELECTRONIC' })}>
<option value="PAPER"></option>
<option value="ELECTRONIC"></option>
</Select>
</div>
{form.signMethod === 'PAPER' && (
<div className="md:col-span-2">
<Label> *</Label>
<input ref={contractFileRef} type="file" className="hidden" onChange={handleContractFileUpload} />
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
<Paperclip className="w-4 h-4 mr-1" />
</Button>
{form.attachmentUrl && <span className="text-xs text-safe"> </span>}
</div>
</div>
)}
{form.signMethod === 'ELECTRONIC' && (
<>
<div><Label> *</Label><Input value={form.electronicContractNo} onChange={(e) => setForm({ ...form, electronicContractNo: e.target.value })} placeholder="如 E-2026-001" /></div>
<div><Label> *</Label><Input value={form.electronicContractUrl} onChange={(e) => setForm({ ...form, electronicContractUrl: e.target.value })} placeholder="https://..." /></div>
</>
)}
<div className="md:col-span-2 flex gap-2">
<Button onClick={() => addContractMutation.mutate(form)} disabled={
addContractMutation.isPending || !form.startDate ||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
}>
{addContractMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" onClick={() => setShowForm(false)}></Button>
</div>
</div>
</Card>
)}
{!contracts?.length ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : contracts.map((c) => (
<Card key={c.id}>
<div className="flex items-start justify-between">
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 flex-1 text-xs">
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{typeMap[c.contractType] || c.contractType}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.startDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.contractYears}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.probationMonths}¥{c.probationSalary}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.renewalCount}</span></div>
{c.signMethod === 'PAPER' && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
{c.attachmentUrl ? (
<a href={c.attachmentUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<Paperclip className="w-3 h-3" />
</a>
) : (
<span className="text-gray-400"></span>
)}
</div>
)}
{c.signMethod === 'ELECTRONIC' && (
<>
{c.electronicContractNo && <div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.electronicContractNo}</span></div>}
{c.electronicContractUrl && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
<a href={c.electronicContractUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<FileText className="w-3 h-3" />
</a>
</div>
)}
</>
)}
</div>
</div>
</Card>
))}
</div>
)
}
@@ -0,0 +1,116 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
// ========== 违纪记录管理 ==========
export default function DisciplinaryInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ violationDate: '', violationType: 'LATE', description: '', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '', employeeAck: false, ackDate: '', ackMethod: 'SIGN', witness: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/disciplinary`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/disciplinary/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' }
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="text-xs font-medium">{records?.length || 0}</h2>
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
</div>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input type="date" value={form.violationDate} onChange={(e) => setForm({ ...form, violationDate: e.target.value })} /></div>
<div><Label></Label>
<Select value={form.violationType} onChange={(e) => setForm({ ...form, violationType: e.target.value })}>
{Object.entries(typeMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div className="md:col-span-2"><Label></Label><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="详细描述违纪事实" /></div>
<div><Label></Label>
<Select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })}>
{Object.entries(severityMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label>
<Select value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value })}>
{Object.entries(actionMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label><Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="扣款金额/降职说明等" /></div>
<div><Label></Label><Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} /></div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="empAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<label htmlFor="empAck" className="text-xs"></label>
</div>
{form.employeeAck && <div><Label></Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2 flex gap-2">
<Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.violationDate || !form.description}>
{createMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" onClick={() => setShowForm(false)}></Button>
</div>
</div>
</Card>
)}
{records?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : records?.map((r) => (
<Card key={r.id} className="p-4">
<div className="flex justify-between items-start gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-700">{r.violationDate?.toString().slice(0, 10)}</span>
<span className={`px-2 py-0.5 rounded text-xs ${r.severity === 'SEVERE' ? 'bg-red-100 text-red-700' : r.severity === 'SERIOUS' ? 'bg-orange-50 text-orange-600' : 'bg-amber-50 text-amber-600'}`}>
{severityMap[r.severity] || r.severity}
</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{typeMap[r.violationType] || r.violationType}</span>
</div>
<div className="text-xs text-gray-600 leading-relaxed">{r.description}</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-400"></span>
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-600">{actionMap[r.action] || r.action}</span>
{r.actionDetail && <span className="text-gray-500">{r.actionDetail}</span>}
</div>
<div className="flex items-center gap-4 text-xs pt-1 border-t">
{r.employeeAck ? (
<span className="text-safe flex items-center gap-1">
<Check className="w-3 h-3" />{r.ackDate?.toString().slice(0, 10)}
</span>
) : (
<span className="text-warning flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
</span>
)}
{r.witness && <span className="text-gray-400">{r.witness}</span>}
{r.ackMethod && <span className="text-gray-400">{r.ackMethod === 'SIGN' ? '签字' : r.ackMethod === 'ELECTRONIC' ? '电子' : '拒绝'}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</Card>
))}
</div>
)
}
@@ -0,0 +1,106 @@
/** EmployeeProfile 组件 - 员工详情页 */
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { X } from 'lucide-react'
import api from '../../lib/api'
import { fmt, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './shared'
import BasicInfo from './BasicInfo'
import ContractInfo from './ContractInfo'
import PayslipSocialInfo from './PayslipSocialInfo'
import DisciplinaryInfo from './DisciplinaryInfo'
import AttendanceOvertimeInfo from './AttendanceOvertimeInfo'
import PerformanceInfo from './PerformanceInfo'
import TerminationInfo from './TerminationInfo'
import ChangeHistoryTab from './ChangeHistoryTab'
/**
* 员工详情档案页
*/
export default function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: () => void }) {
const [tab, setTab] = useState<DetailTab>('basic')
const { data: profile, isLoading } = useQuery<any>({
queryKey: ['roster-profile', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/profile`) as any
return res.data
},
})
const isActive = profile?.status === 'ACTIVE'
const HIDDEN_FOR_RESIGNED: DetailTab[] = ['attendance', 'performance']
const getTabCount = (key: DetailTab): number => {
const dataKey = TAB_COUNT_KEYS[key]
if (!dataKey || !profile) return 0
const data = (profile as any)[dataKey]
if (!Array.isArray(data)) return 0
if (key === 'payslip') {
const monthly = (profile as any).monthlyProcessRecords
return data.length + (Array.isArray(monthly) ? monthly.length : 0)
}
if (key === 'attendance') {
const training = (profile as any).trainingRecords
return data.length + (Array.isArray(training) ? training.length : 0)
}
return data.length
}
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!profile) return <div className="text-center py-8 text-gray-400"></div>
return (
<div className="flex flex-col h-[calc(100vh-120px)]">
<div className="flex items-center gap-3 shrink-0 pb-3">
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
<h1 className="text-xs font-medium">{profile.name} - </h1>
<span className={`px-2 py-0.5 rounded text-xs ${profile.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
{profile.status === 'ACTIVE' ? '在职' : '离职'}
</span>
</div>
<div className="flex gap-1 border-b overflow-x-auto shrink-0">
{TAB_GROUPS.map((group) => (
<div key={group.group} className="flex items-center">
{group.tabs
.filter((t) => isActive || !HIDDEN_FOR_RESIGNED.includes(t.key))
.map((t) => {
const count = getTabCount(t.key)
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap flex items-center gap-1 ${
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{t.label}
{count > 0 && (
<span className={`ml-0.5 px-1.5 py-0.5 rounded-full text-[10px] leading-none ${
tab === t.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'
}`}>
{count}
</span>
)}
</button>
)
})}
</div>
))}
</div>
<div className="flex-1 overflow-y-auto pt-3">
{tab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
{tab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
{tab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
{tab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
{tab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
{tab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
{tab === 'history' && <ChangeHistoryTab profile={profile} />}
</div>
</div>
)
}
+173
View File
@@ -0,0 +1,173 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
// ========== 仲裁证据链 ==========
export default function EvidenceChain({ employeeId }: { employeeId: string }) {
const { data, isLoading } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
return res.data
},
})
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!data) return <div className="text-center py-8 text-gray-400"></div>
const categoryColor: Record<string, string> = {
'劳动关系': 'bg-blue-50 text-blue-700 border-blue-200',
'薪酬发放': 'bg-green-50 text-green-700 border-green-200',
'考勤记录': 'bg-amber-50 text-amber-700 border-amber-200',
'违纪处理': 'bg-red-50 text-red-700 border-red-200',
'培训签收': 'bg-purple-50 text-purple-700 border-purple-200',
'绩效考核': 'bg-indigo-50 text-indigo-700 border-indigo-200',
'解聘记录': 'bg-gray-100 text-gray-700 border-gray-300',
}
const handleExport = () => {
const text = generateEvidenceText(data)
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `仲裁证据链_${data.employee.name}_${new Date().toISOString().slice(0, 10)}.txt`
a.click()
URL.revokeObjectURL(url)
}
const riskStyle: Record<string, string> = {
DANGER: 'bg-red-50 border-red-300 text-red-700',
HIGH: 'bg-orange-50 border-orange-300 text-orange-700',
MEDIUM: 'bg-amber-50 border-amber-300 text-amber-700',
}
const riskIcon: Record<string, string> = {
DANGER: '🔴',
HIGH: '🟠',
MEDIUM: '🟡',
}
return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xs font-medium flex items-center gap-2"><Scale className="w-4 h-4" /></h2>
<div className="text-xs text-gray-500 mt-1">
{data.employee.name} · {data.employee.department} · {data.employee.hireDate}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold">{data.summary.total}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-safe">{data.summary.signed}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-warning">{data.summary.unsigned}</div>
</div>
{data.summary.riskCount > 0 && (
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-danger">{data.summary.riskCount}</div>
</div>
)}
<Button onClick={handleExport}></Button>
</div>
</div>
</Card>
{data.risks && data.risks.length > 0 && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-danger" />
{data.risks.length}
</h3>
<div className="space-y-2">
{data.risks.map((r: any, i: number) => (
<div key={i} className={`border rounded-lg p-3 ${riskStyle[r.level] || 'bg-gray-50 border-gray-200 text-gray-600'}`}>
<div className="flex items-center gap-2">
<span>{riskIcon[r.level] || '⚠'}</span>
<span className="font-medium text-xs">{r.title}</span>
<span className="text-xs opacity-70">{r.category}</span>
</div>
<div className="text-xs mt-1 opacity-90">{r.description}</div>
</div>
))}
</div>
</Card>
)}
<div className="space-y-2">
{data.evidence.map((e: any, i: number) => (
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
<div className="flex items-start gap-3">
<span className={`px-2 py-0.5 rounded border text-xs shrink-0 ${categoryColor[e.category] || 'bg-gray-50 text-gray-600 border-gray-200'}`}>
{e.category}
</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-xs">{e.title}</span>
<span className="text-xs text-gray-400">{e.date}</span>
{e.acknowledged === true && <span className="text-xs text-safe"> </span>}
{e.acknowledged === false && <span className="text-xs text-warning"> </span>}
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger"> </span>}
</div>
<div className="text-xs text-gray-600 mt-1">{e.description}</div>
</div>
</div>
</Card>
))}
</div>
</div>
)
}
export function generateEvidenceText(data: any): string {
const lines: string[] = []
lines.push('========================================')
lines.push(' 劳动仲裁证据链')
lines.push('========================================')
lines.push('')
lines.push(`员工姓名:${data.employee.name}`)
lines.push(`部门:${data.employee.department}`)
lines.push(`入职日期:${data.employee.hireDate}`)
lines.push(`状态:${data.employee.status === 'ACTIVE' ? '在职' : '离职'}`)
lines.push('')
lines.push(`证据总数:${data.summary.total}`)
lines.push(`已签字:${data.summary.signed}`)
lines.push(`未签字:${data.summary.unsigned}`)
lines.push('')
lines.push('----------------------------------------')
lines.push('')
let currentCategory = ''
data.evidence.forEach((e: any, i: number) => {
if (e.category !== currentCategory) {
currentCategory = e.category
lines.push(`${currentCategory}`)
lines.push('')
}
lines.push(`${i + 1}. ${e.title}${e.date}`)
lines.push(` ${e.description}`)
if (e.acknowledged === true) lines.push(' [已签字确认]')
if (e.acknowledged === false) lines.push(' [未签字]')
lines.push('')
})
lines.push('----------------------------------------')
lines.push(`导出时间:${new Date().toLocaleString('zh-CN')}`)
return lines.join('\n')
}
@@ -0,0 +1,282 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
const changeTypeMap: Record<string, string> = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', CITY_CHANGE: '城市变更' }
const changeTypeColor: Record<string, string> = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' }
return (
<div className="space-y-3">
<div className="flex gap-1">
<button
onClick={() => setSubTab('payslip')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{payslips?.length || 0}
</button>
<button
onClick={() => setSubTab('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{monthlyProcessRecords?.length || 0}
</button>
</div>
{subTab === 'payslip' && (
<>
{!payslips?.length ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{payslips.map((p) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{p.month}</td>
<td className="py-2 text-right text-gray-600">¥{fmt(p.baseSalary)}</td>
<td className="py-2 text-right text-gray-600">{p.overtimePay > 0 ? `¥${fmt(p.overtimePay)}` : '-'}</td>
<td className="py-2 text-right text-gray-600">{p.allowance > 0 ? `¥${fmt(p.allowance)}` : '-'}</td>
<td className="py-2 text-right text-gray-600">{p.deduction > 0 ? `${fmt(p.deduction)}` : '-'}</td>
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(p.totalPay)}</td>
<td className="py-2 text-center">
{p.confirmedAt ? (
<span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs"></span>
) : (
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-600 text-xs"></span>
)}
</td>
</tr>
))}
<tr className="border-t-2 bg-gray-50">
<td className="py-2 font-medium"></td>
<td className="py-2 text-right font-medium text-gray-600">¥{fmt(payslips.reduce((s, p) => s + (p.baseSalary || 0), 0))}</td>
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.overtimePay || 0), 0) > 0 ? `¥${fmt(payslips.reduce((s, p) => s + (p.overtimePay || 0), 0))}` : '-'}</td>
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.allowance || 0), 0) > 0 ? `¥${fmt(payslips.reduce((s, p) => s + (p.allowance || 0), 0))}` : '-'}</td>
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.deduction || 0), 0) > 0 ? `${fmt(payslips.reduce((s, p) => s + (p.deduction || 0), 0))}` : '-'}</td>
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(payslips.reduce((s, p) => s + (p.totalPay || 0), 0))}</td>
<td></td>
</tr>
</tbody>
</table>
</Card>
)}
</>
)}
{subTab === 'monthly' && (
<>
{!monthlyProcessRecords?.length ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{monthlyProcessRecords.map((r, idx) => {
const d = r.detail
const isSocial = r.type === 'SOCIAL'
const orgAmt = isSocial ? d?.totalOrg : d?.orgAmount
const empAmt = isSocial ? d?.totalEmp : d?.empAmount
const total = isSocial ? d?.total : d?.total
return (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{r.month}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${isSocial ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>{isSocial ? '社保' : '公积金'}</span></td>
<td className="py-2 text-center text-xs text-gray-600">{r.city || '-'}</td>
<td className="py-2 text-center"><span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs"></span></td>
<td className="py-2 text-right text-gray-600">¥{fmt(r.base)}</td>
<td className="py-2 text-right text-danger">{orgAmt != null ? `¥${fmt(orgAmt)}` : '-'}</td>
<td className="py-2 text-right text-warning">{empAmt != null ? `¥${fmt(empAmt)}` : '-'}</td>
<td className="py-2 text-right font-medium text-primary">{total != null ? `¥${fmt(total)}` : '-'}</td>
<td className="py-2 text-center text-xs text-gray-500">{r.changeType}</td>
<td className="py-2 text-gray-400 text-xs">{new Date(r.processedAt).toLocaleString('zh-CN')}</td>
</tr>
)
})}
</tbody>
</table>
</Card>
)}
</>
)}
</div>
)
}
/** 参保城市变更历史组件(含修正功能) */
export function CityHistoryTab({ socialInsRecords, housingFundRecords, changeTypeMap, changeTypeColor }: { socialInsRecords: any[]; housingFundRecords: any[]; changeTypeMap: Record<string, string>; changeTypeColor: Record<string, string> }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<any>(null)
const [correctForm, setCorrectForm] = useState({ city: '', base: '', startMonth: '', endMonth: '', changeType: '', remark: '', reason: '' })
const correctMutation = useMutation({
mutationFn: async (data: any) => {
const cat = editing.cat
const url = cat === '社保'
? `/social/records/social/${editing.id}/correct`
: `/social/records/housing/${editing.id}/correct`
const res = await api.put(url, data) as any
return res.data
},
onSuccess: () => {
toast.success('记录已修正')
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setEditing(null)
},
onError: () => toast.error('修正失败'),
})
const handleCorrect = () => {
correctMutation.mutate({
city: correctForm.city || undefined,
base: correctForm.base ? Number(correctForm.base) : undefined,
startMonth: correctForm.startMonth || undefined,
endMonth: correctForm.endMonth || undefined,
changeType: correctForm.changeType || undefined,
remark: correctForm.remark || undefined,
reason: correctForm.reason || '数据修正',
})
}
const openCorrect = (r: any) => {
setEditing(r)
setCorrectForm({
city: r.city || '',
base: String(r.base || ''),
startMonth: r.startMonth || '',
endMonth: r.endMonth || '',
changeType: r.changeType || '',
remark: r.remark || '',
reason: '',
})
}
const allRecords = [
...(socialInsRecords || []).map((r: any) => ({ ...r, cat: '社保' })),
...(housingFundRecords || []).map((r: any) => ({ ...r, cat: '公积金' })),
].sort((a, b) => b.startMonth.localeCompare(a.startMonth))
if (!allRecords.length) return <Card><div className="text-center py-8 text-gray-400"></div></Card>
return (
<>
<Card>
<div className="text-xs text-gray-500 mb-3"> + </div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{allRecords.map((r, idx) => (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{r.startMonth}</td>
<td className="py-2 text-gray-400">{r.endMonth || '至今'}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${r.cat === '社保' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>{r.cat}</span></td>
<td className="py-2"><span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs">{r.city || '-'}</span></td>
<td className="py-2 text-right text-gray-600">¥{fmt(r.base)}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
<td className="py-2 text-gray-400 text-xs">{r.remark || '-'}</td>
<td className="py-2 text-center"><button onClick={() => openCorrect(r)} className="text-xs text-primary hover:underline"></button></td>
</tr>
))}
</tbody>
</table>
</Card>
{editing && (
<Modal open={true} onClose={() => setEditing(null)} title={`修正${editing.cat}记录`}>
<div className="space-y-3">
<div className="bg-amber-50 text-amber-700 text-xs px-3 py-2 rounded-md">
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={correctForm.city} onChange={(e) => setCorrectForm({ ...correctForm, city: e.target.value })} placeholder="如 北京" />
</div>
<div>
<Label></Label>
<Input type="number" value={correctForm.base} onChange={(e) => setCorrectForm({ ...correctForm, base: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="month" value={correctForm.startMonth} onChange={(e) => setCorrectForm({ ...correctForm, startMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="month" value={correctForm.endMonth} onChange={(e) => setCorrectForm({ ...correctForm, endMonth: e.target.value })} placeholder="留空=至今" />
</div>
<div>
<Label></Label>
<Select value={correctForm.changeType} onChange={(e) => setCorrectForm({ ...correctForm, changeType: e.target.value })}>
<option value="ONBOARDING"></option>
<option value="REHIRE"></option>
<option value="ADJUST"></option>
<option value="CITY_CHANGE"></option>
<option value="TERMINATION">/</option>
</Select>
</div>
<div>
<Label></Label>
<Input value={correctForm.remark} onChange={(e) => setCorrectForm({ ...correctForm, remark: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input value={correctForm.reason} onChange={(e) => setCorrectForm({ ...correctForm, reason: e.target.value })} placeholder="如:城市录入错误" />
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="secondary" size="sm" onClick={() => setEditing(null)}></Button>
<Button size="sm" onClick={handleCorrect} disabled={correctMutation.isPending}>{correctMutation.isPending ? '保存中...' : '确认修正'}</Button>
</div>
</div>
</Modal>
)}
</>
)
}
@@ -0,0 +1,100 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
// ========== 绩效记录管理 ==========
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ period: '', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/performance`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/performance/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="text-xs font-medium">{records?.length || 0}</h2>
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
</div>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder="如 2026-07 或 2026-Q3" /></div>
<div><Label></Label><Input type="number" value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} /></div>
<div><Label></Label>
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
</Select>
</div>
<div><Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div className="md:col-span-2"><Label></Label><Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="如:调岗至XX岗位,培训XX技能" /></div>
<div><Label></Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="perfAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<label htmlFor="perfAck" className="text-xs"></label>
</div>
{form.employeeAck && <div><Label></Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
{records?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : records?.map((r) => (
<Card key={r.id} className="p-4">
<div className="flex justify-between items-start gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-700">{r.period}</span>
<span className={`px-2 py-0.5 rounded text-xs ${r.result === 'EXCELLENT' ? 'bg-green-50 text-safe' : r.result === 'QUALIFIED' ? 'bg-blue-50 text-blue-600' : r.result === 'NEED_IMPROVE' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
{resultMap[r.result] || r.result}
</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs"> {r.score} · {r.grade}</span>
</div>
{r.summary && <div className="text-xs text-gray-600 leading-relaxed">{r.summary}</div>}
{r.improvementPlan && (
<div className="text-xs bg-amber-50 text-amber-700 px-2 py-1.5 rounded leading-relaxed">
<span className="font-medium"></span>{r.improvementPlan}
</div>
)}
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
{r.employeeAck ? (
<span className="text-safe flex items-center gap-1"><Check className="w-3 h-3" />{r.ackDate?.toString().slice(0, 10)}</span>
) : (
<span className="text-warning flex items-center gap-1"><AlertTriangle className="w-3 h-3" /></span>
)}
{r.reviewer && <span>{r.reviewer}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</Card>
))}
</div>
)
}
@@ -0,0 +1,323 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
import { generateEvidenceText } from "./EvidenceChain"
export default function TerminationInfo({ employeeId, profile, records }: { employeeId: string; profile: any; records: any[] }) {
const [printRecord, setPrintRecord] = useState<any | null>(null)
const [showEvidence, setShowEvidence] = useState(false)
const reasonMap: Record<string, string> = {
NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除',
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除',
RESIGNATION: '员工主动离职',
}
const legalBasisMap: Record<string, string> = {
NEGOTIATED: '《劳动合同法》第36条', FAULT: '《劳动合同法》第39条',
NONFAULT: '《劳动合同法》第40条', LAYOFF: '《劳动合同法》第41条',
EXPIRED: '《劳动合同法》第44条、第46条', ILLEGAL: '《劳动合同法》第87条',
}
const { data: evidenceChain, isLoading: evidenceLoading } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
return res.data
},
enabled: !!printRecord || showEvidence,
})
const validRecords = (records || []).filter((t: any) => t.status !== 'CANCELLED')
const cancelledRecords = (records || []).filter((t: any) => t.status === 'CANCELLED')
if (!validRecords.length && !cancelledRecords.length) return <Card><div className="text-center py-8 text-gray-400">/</div></Card>
if (printRecord) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<button onClick={() => setPrintRecord(null)} className="text-gray-400 hover:text-gray-600 flex items-center gap-1 text-xs">
<X className="w-4 h-4" />
</button>
<Button variant="secondary" size="sm" onClick={() => window.print()}>
<Printer className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 1. 解聘通知书 */}
<div className="border rounded-lg p-6 space-y-3 print:shadow-none">
<div className="text-center">
<h2 className="text-base font-bold"></h2>
</div>
<div className="text-xs text-gray-700 space-y-3">
<p><strong>{profile.name}</strong> /</p>
<p>
<strong>{profile.hireDate?.toString().slice(0, 10)}</strong> {profile.department}
<strong>{reasonMap[printRecord.reason] || printRecord.reason}</strong> <strong>{printRecord.terminationDate?.toString().slice(0, 10)}</strong>
</p>
<p>{legalBasisMap[printRecord.reason] || ''}</p>
<p><strong>¥{fmt(printRecord.compensation)}</strong></p>
{printRecord.remark && <p>{printRecord.remark}</p>}
<p></p>
<div className="text-right mt-6 space-y-1">
<p></p>
<p className="text-gray-400">{printRecord.terminationDate?.toString().slice(0, 10)}</p>
</div>
</div>
</div>
{/* 2. 费用结算明细 */}
<div className="border rounded-lg p-4 space-y-2">
<h3 className="text-xs font-medium flex items-center gap-2"><Calculator className="w-4 h-4" /></h3>
<div className="text-xs space-y-1">
<div className="flex justify-between"><span></span><span>{profile.name}{profile.department}</span></div>
<div className="flex justify-between"><span></span><span>{profile.hireDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span></span><span>¥{fmt(profile.monthlySalary)}/</span></div>
<div className="flex justify-between"><span></span><span>{printRecord.terminationDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span></span><span>{reasonMap[printRecord.reason] || printRecord.reason}</span></div>
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span></span><span>¥{fmt(printRecord.compensation)}</span></div>
</div>
</div>
{/* 3. 合规检查清单 */}
{printRecord.checklist && Object.keys(printRecord.checklist).length > 0 && (
<div className="border rounded-lg p-4 space-y-2">
<h3 className="text-xs font-medium flex items-center gap-2"><Shield className="w-4 h-4" /></h3>
<div className="text-xs space-y-1">
{Object.entries(printRecord.checklist).map(([key, passed]: [string, any]) => (
<div key={key} className="flex items-center gap-2">
<span className={passed ? 'text-safe' : 'text-danger'}>{passed ? '✓' : '✗'}</span>
<span className={passed ? '' : 'text-gray-500'}>{key}</span>
</div>
))}
</div>
</div>
)}
{/* 4. 风险评估 */}
{printRecord.riskLevel && printRecord.riskLevel !== 'SAFE' && (
<div className="border rounded-lg p-4 space-y-2">
<h3 className="text-xs font-medium flex items-center gap-2"><AlertTriangle className="w-4 h-4" /></h3>
<div className="text-xs">
<div className={`px-3 py-2 rounded-md ${printRecord.riskLevel === 'DANGER' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-800'}`}>
{printRecord.riskLevel === 'DANGER' ? '高风险' : '注意'}
</div>
</div>
</div>
)}
{/* 5. 仲裁证据链 */}
<div className="border rounded-lg p-4 space-y-3">
<h3 className="text-xs font-medium flex items-center gap-2"><FileText className="w-4 h-4" /></h3>
{evidenceChain ? (
<>
<div className="text-xs text-gray-500">
{evidenceChain.summary?.total || 0}
{evidenceChain.summary?.signed || 0}
{evidenceChain.summary?.unsigned || 0}
</div>
{(() => {
const grouped = (evidenceChain.evidence || []).reduce((acc: Record<string, any[]>, e: any) => {
(acc[e.category] = acc[e.category] || []).push(e)
return acc
}, {})
return Object.entries(grouped).map(([category, items]) => (
<div key={category} className="space-y-1">
<div className="text-xs font-medium text-gray-700">{category}</div>
{(items as any[]).map((e: any, i: number) => (
<div key={i} className="text-xs text-gray-600 pl-4 border-l-2 border-gray-200 ml-1">
<div className="flex items-center gap-2">
<span>{e.title}</span>
{e.acknowledged === true && <span className="text-safe"></span>}
{e.acknowledged === false && <span className="text-danger"></span>}
</div>
<div className="text-gray-400">{e.description}</div>
</div>
))}
</div>
))
})()}
</>
) : (
<div className="text-xs text-gray-400">...</div>
)}
</div>
</div>
)
}
return (
<div className="space-y-3">
{validRecords.map((t) => (
<Card key={t.id} className="p-4">
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-700">{t.terminationDate?.toString().slice(0, 10)}</span>
<span className={`px-2 py-0.5 rounded text-xs ${t.type === 'RESIGNATION' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>{t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'}</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{reasonMap[t.reason] || t.reason}</span>
{t.type !== 'RESIGNATION' && (
<span className={`px-2 py-0.5 rounded text-xs ${t.riskLevel === 'SAFE' ? 'bg-green-50 text-safe' : t.riskLevel === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
{t.riskLevel === 'SAFE' ? '风险低' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}
</span>
)}
{t.status && (
<span className={`px-2 py-0.5 rounded text-xs ${t.status === 'COMPLETED' ? 'bg-green-50 text-safe' : t.status === 'PENDING_APPROVAL' ? 'bg-amber-50 text-amber-700' : t.status === 'APPROVED' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{t.status === 'DRAFT' ? '草稿' : t.status === 'PENDING_APPROVAL' ? '待审批' : t.status === 'APPROVED' ? '已审批' : t.status === 'COMPLETED' ? '已完成' : t.status === 'REJECTED' ? '已驳回' : t.status === 'EXECUTING' ? '执行中' : t.status}
</span>
)}
</div>
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 text-xs">
{t.type === 'RESIGNATION' ? (
<>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-700 text-right truncate ml-2">{t.resignationReason || '-'}</span>
</div>
</>
) : (
<>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-700 text-right truncate ml-2">¥{fmt(t.compensation)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-700 text-right truncate ml-2">{legalBasisMap[t.reason] || '-'}</span>
</div>
</>
)}
</div>
{t.remark && <div className="text-xs text-gray-500 bg-gray-50 px-2 py-1.5 rounded">{t.remark}</div>}
<div className="flex justify-end">
{t.type !== 'RESIGNATION' && (
<Button variant="secondary" size="sm" onClick={() => setPrintRecord(t)}>
<Printer className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</div>
</Card>
))}
{cancelledRecords.length > 0 && (
<>
{validRecords.length > 0 && <div className="text-xs text-gray-400 pt-2"></div>}
{cancelledRecords.map((t) => (
<Card key={t.id} className="p-4 opacity-60">
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-500">{t.terminationDate?.toString().slice(0, 10)}</span>
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">{t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'}</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{reasonMap[t.reason] || t.reason}</span>
<span className="px-2 py-0.5 rounded text-xs bg-red-50 text-red-500 line-through"></span>
</div>
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 text-xs">
{t.type === 'RESIGNATION' ? (
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-500 text-right truncate ml-2">{t.resignationReason || '-'}</span>
</div>
) : (
<>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-500 text-right truncate ml-2">¥{fmt(t.compensation)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-500 text-right truncate ml-2">{legalBasisMap[t.reason] || '-'}</span>
</div>
</>
)}
</div>
{t.remark && <div className="text-xs text-gray-400 bg-gray-50 px-2 py-1.5 rounded">{t.remark}</div>}
</div>
</Card>
))}
</>
)}
{/* 仲裁证据链 */}
<div className="pt-3 border-t">
<button
onClick={() => setShowEvidence(!showEvidence)}
className="flex items-center gap-2 text-xs font-medium text-gray-600 hover:text-gray-800"
>
<Scale className="w-4 h-4" />
{evidenceChain && (
<span className="text-gray-400">
{evidenceChain.summary?.total || 0}{evidenceChain.summary?.signed || 0}{evidenceChain.summary?.unsigned || 0}
</span>
)}
<span className="text-gray-400">{showEvidence ? '▾' : '▸'}</span>
</button>
{showEvidence && (
<div className="mt-3 space-y-2">
{evidenceLoading ? (
<div className="text-xs text-gray-400 text-center py-4">...</div>
) : evidenceChain ? (
<>
{evidenceChain.risks && evidenceChain.risks.length > 0 && (
<Card className="p-3">
<div className="text-xs font-medium mb-2 flex items-center gap-1 text-danger">
<AlertTriangle className="w-3.5 h-3.5" />{evidenceChain.risks.length}
</div>
<div className="space-y-1.5">
{evidenceChain.risks.map((r: any, i: number) => (
<div key={i} className="text-xs border rounded p-2 bg-red-50 border-red-200 text-red-700">
<span className="font-medium">{r.title}</span>
<span className="text-xs opacity-70 ml-2">{r.category}</span>
<div className="opacity-90 mt-0.5">{r.description}</div>
</div>
))}
</div>
</Card>
)}
{evidenceChain.evidence?.map((e: any, i: number) => (
<Card key={i} className="p-3">
<div className="flex items-start gap-2">
<span className="px-1.5 py-0.5 rounded border text-xs shrink-0 bg-gray-50 text-gray-600 border-gray-200">{e.category}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-xs">{e.title}</span>
<span className="text-xs text-gray-400">{e.date}</span>
{e.acknowledged === true && <span className="text-xs text-safe"></span>}
{e.acknowledged === false && <span className="text-xs text-warning"></span>}
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger"></span>}
</div>
<div className="text-xs text-gray-600 mt-0.5">{e.description}</div>
</div>
</div>
</Card>
))}
<button
onClick={() => {
const text = generateEvidenceText(evidenceChain)
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `仲裁证据链_${evidenceChain.employee?.name}_${new Date().toISOString().slice(0, 10)}.txt`
a.click()
URL.revokeObjectURL(url)
}}
className="text-xs text-primary hover:underline"
>
</button>
</>
) : (
<div className="text-xs text-gray-400 text-center py-4"></div>
)}
</div>
)}
</div>
</div>
)
}
+769
View File
@@ -0,0 +1,769 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
import { fmt } from "./shared"
export function SalaryChangeModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({
newSalary: '',
effectiveDate: todayStr,
reason: '',
})
const handleSubmit = () => {
onSubmit({
newSalary: parseFloat(form.newSalary),
effectiveDate: new Date(form.effectiveDate).toISOString(),
reason: form.reason || undefined,
})
}
const canSubmit = form.newSalary && parseFloat(form.newSalary) > 0 && form.effectiveDate
return (
<Modal open onClose={onClose} title={`调薪 - ${employee.name}`}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.name} - {employee.department}</div>
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">¥{fmt(employee.monthlySalary)}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input type="number" value={form.newSalary} onChange={(e) => setForm({ ...form, newSalary: e.target.value })} placeholder="元" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:年度调薪、晋升加薪" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调薪'}</Button>
</div>
</div>
</Modal>
)
}
export function DeptChangeModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({
newDepartment: employee.department || '',
effectiveDate: todayStr,
reason: '',
})
const handleSubmit = () => {
onSubmit({
newDepartment: form.newDepartment,
effectiveDate: new Date(form.effectiveDate).toISOString(),
reason: form.reason || undefined,
})
}
const canSubmit = form.newDepartment && form.effectiveDate && form.newDepartment !== employee.department
return (
<Modal open onClose={onClose} title={`调部门 - ${employee.name}`}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.department}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input value={form.newDepartment} onChange={(e) => setForm({ ...form, newDepartment: e.target.value })} placeholder="如:市场部" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:组织架构调整" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调部门'}</Button>
</div>
</div>
</Modal>
)
}
export function ResignModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const [form, setForm] = useState({
terminationDate: new Date().toISOString().slice(0, 10),
resignationReason: '个人原因',
remark: '',
socialInsEndMonth: '',
housingFundEndMonth: '',
})
const terminationMonth = form.terminationDate ? form.terminationDate.slice(0, 7) : ''
const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他']
const handleSubmit = () => {
onSubmit({
employeeId: employee.id,
terminationDate: new Date(form.terminationDate).toISOString(),
resignationReason: form.resignationReason,
remark: form.remark || undefined,
socialInsEndMonth: form.socialInsEndMonth || terminationMonth,
housingFundEndMonth: form.housingFundEndMonth || terminationMonth,
})
}
return (
<Modal open onClose={onClose} title={`办理离职 - ${employee.name}`}>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600">{employee.name} - {employee.department}</div>
</div>
<div>
<Label></Label>
<Input
type="date"
value={form.terminationDate}
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
/>
</div>
<div>
<Label></Label>
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
{reasons.map((r) => <option key={r} value={r}>{r}</option>)}
</Select>
</div>
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="month" value={form.socialInsEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, socialInsEndMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, housingFundEndMonth: e.target.value })} />
</div>
</div>
{((form.socialInsEndMonth && form.socialInsEndMonth !== terminationMonth) || (form.housingFundEndMonth && form.housingFundEndMonth !== terminationMonth)) && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs mt-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
/
</div>
)}
</div>
<div>
<Label></Label>
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="补充说明" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? '提交中...' : '确认离职'}
</Button>
</div>
</div>
</Modal>
)
}
export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
queryKey: ['contract-types'],
queryFn: async () => {
const res = await api.get('/roster/contract-types') as any
return res.data || []
},
staleTime: Infinity,
})
const defaultEndDate = (() => {
const d = new Date()
d.setFullYear(d.getFullYear() + 3)
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
hireDate: todayStr,
department: employee.department || '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '',
startDate: todayStr,
endDate: defaultEndDate,
contractYears: 3,
probationMonths: 0,
probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
})
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
// 计算合同月数
const contractMonths = (() => {
if (form.contractType !== 'FIXED' || !form.startDate) return 0
if (form.endDate) {
const start = new Date(form.startDate)
const end = new Date(form.endDate)
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
}
return form.contractYears * 12
})()
// 试用期上限(劳动合同法第19条)
const probationMax = (() => {
if (contractMonths >= 36) return 6
if (contractMonths >= 12) return 2
if (contractMonths >= 3) return 1
return 0
})()
const probationError = (() => {
if (form.probationMonths <= 0) return ''
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
return ''
})()
const monthlySalaryNum = employee?.monthlySalary || 0
const probationSalaryError = (() => {
if (form.probationMonths <= 0) return ''
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)}`
}
return ''
})()
// 合同结束日期自动计算
const handleContractYearsChange = (years: number) => {
if (!form.startDate || years <= 0) {
setForm({ ...form, contractYears: years, endDate: '' })
return
}
const start = new Date(form.startDate)
const end = new Date(start)
end.setFullYear(end.getFullYear() + years)
end.setDate(end.getDate() - 1)
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
}
// 合同结束日期变更 → 自动计算签约年限
const handleEndDateChange = (endDate: string) => {
if (!form.startDate || !endDate) {
setForm({ ...form, endDate, contractYears: 0 })
return
}
const start = new Date(form.startDate)
const end = new Date(endDate)
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
}
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
const handleHireDateChange = (hireDate: string) => {
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
const start = new Date(hireDate)
const end = new Date(start)
end.setFullYear(end.getFullYear() + form.contractYears)
end.setDate(end.getDate() - 1)
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
} else {
setForm({ ...form, hireDate, startDate: hireDate })
}
}
const handleSubmit = () => {
const data: any = {
hireDate: new Date(form.hireDate).toISOString(),
department: form.department,
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
housingFundStartMonth: form.housingFundStartMonth || undefined,
}
if (form.contractType !== 'UNSIGNED' && form.startDate) {
data.contract = {
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
contractType: form.contractType,
contractYears: form.contractYears,
probationMonths: form.probationMonths,
probationSalary: form.probationSalary,
}
}
onSubmit(data)
}
const canSubmit = form.hireDate
&& (form.contractType === 'UNSIGNED' || form.startDate)
&& !probationError && !probationSalaryError
return (
<Modal open onClose={onClose} title={`重新入职 - ${employee.name}`}>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
</div>
<div>
<Label> *</Label>
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" />
</div>
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
</div>
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
</div>
</div>
</div>
<div className="border-t pt-3">
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<Label></Label>
<Select value={form.contractType} onChange={(e) => {
const ct = contractTypes.find(t => t.value === e.target.value)
setForm({ ...form, contractType: e.target.value as any, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
}}>
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</div>
</div>
</div>
{form.contractType !== 'UNSIGNED' && (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
<div className="text-xs text-gray-400 mt-0.5"></div>
</div>
<div><Label></Label><div className="text-xs text-gray-600 py-1.5">{form.startDate || '随入职日期'}</div></div>
{form.contractType === 'FIXED' && (
<div>
<Label></Label>
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
</div>
)}
{form.contractType === 'FIXED' && (
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
</div>
)}
</div>
{form.contractType === 'FIXED' && (
<div className="text-xs text-gray-400"></div>
)}
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
{contractMonths > 0 && (
<div className="text-xs text-gray-400 mt-0.5">{probationMax}</div>
)}
{probationError && <div className="text-xs text-danger mt-0.5">{probationError}</div>}
</div>
<div>
<Label>{form.probationMonths > 0 ? ' *' : ''}</Label>
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
<div className="text-xs text-gray-400 mt-0.5">80%¥{(monthlySalaryNum * 0.8).toFixed(0)}</div>
)}
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
</div>
</div>
</div>
)}
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '提交中...' : '确认入职'}</Button>
</div>
</div>
</Modal>
)
}
export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const { data: cities = ['北京'] } = useQuery<string[]>({
queryKey: ['social-config-cities'],
queryFn: async () => {
const res = await api.get('/social/config/cities') as any
return res.data?.length ? res.data : ['北京']
},
})
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
queryKey: ['contract-types'],
queryFn: async () => {
const res = await api.get('/roster/contract-types') as any
return res.data || []
},
staleTime: Infinity,
})
const defaultEndDate = (() => {
const d = new Date()
d.setFullYear(d.getFullYear() + 3)
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
name: '', department: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
})
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
const handleHireDateChange = (hireDate: string) => {
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
const start = new Date(hireDate)
const end = new Date(start)
end.setFullYear(end.getFullYear() + form.contractYears)
end.setDate(end.getDate() - 1)
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
} else {
setForm({ ...form, hireDate, startDate: hireDate })
}
}
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)
const handleIdCardChange = (idCard: string) => {
let gender = form.gender
if (idCard.length >= 17) {
const digit = parseInt(idCard[16])
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
}
setForm({ ...form, idCardNumber: idCard, gender })
}
// 计算合同月数
const contractMonths = (() => {
if (form.contractType !== 'FIXED' || !form.startDate) return 0
if (form.endDate) {
const start = new Date(form.startDate)
const end = new Date(form.endDate)
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
}
return form.contractYears * 12
})()
// 试用期上限(劳动合同法第19条)
const probationMax = (() => {
if (contractMonths >= 36) return 6
if (contractMonths >= 12) return 2
if (contractMonths >= 3) return 1
return 0
})()
const probationError = (() => {
if (form.probationMonths <= 0) return ''
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
return ''
})()
const monthlySalaryNum = parseFloat(form.monthlySalary) || 0
const probationSalaryError = (() => {
if (form.probationMonths <= 0) return ''
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)}`
}
return ''
})()
// 合同结束日期自动计算
const handleContractYearsChange = (years: number) => {
if (!form.startDate || years <= 0) {
setForm({ ...form, contractYears: years, endDate: '' })
return
}
const start = new Date(form.startDate)
const end = new Date(start)
end.setFullYear(end.getFullYear() + years)
end.setDate(end.getDate() - 1)
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
}
// 合同结束日期变更 → 自动计算签约年限
const handleEndDateChange = (endDate: string) => {
if (!form.startDate || !endDate) {
setForm({ ...form, endDate, contractYears: 0 })
return
}
const start = new Date(form.startDate)
const end = new Date(endDate)
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
}
const handleSubmit = () => {
const data: any = {
name: form.name, department: form.department,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary, gender: form.gender,
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
idCardNumber: form.idCardNumber || undefined,
phone: form.phone || undefined,
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
housingFundStartMonth: form.housingFundStartMonth || undefined,
}
if (form.contractType !== 'UNSIGNED' && form.startDate) {
data.contract = {
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
contractType: form.contractType, contractYears: form.contractYears,
probationMonths: form.probationMonths, probationSalary: form.probationSalary,
}
}
onSubmit(data)
}
const canSubmit = form.name && form.department && form.hireDate && form.monthlySalary
&& form.idCardNumber.length >= 18
&& (form.contractType === 'UNSIGNED' || form.startDate)
&& !probationError && !probationSalaryError
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
useUnsavedChanges(isDirty)
return (
<Modal open onClose={onClose} title="添加员工" size="xl">
<div className="space-y-4">
{error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
{error.response?.data?.error?.message || '操作失败'}
</div>
)}
{/* 基本信息 */}
<div className="grid grid-cols-4 gap-4">
<div><Label> *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
<div><Label> *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
<div><Label> *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
<div><Label></Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
{form.gender === '女' && (
<div><Label></Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value=""></option><option value="CADRE">/</option><option value="WORKER">/</option></Select></div>
)}
</div>
<div className="grid grid-cols-4 gap-4">
<div><Label> *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
<div><Label> *</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /></div>
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
</div>
<div className="grid grid-cols-4 gap-4">
<div><Label></Label><Select value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
</div>
{/* 社保公积金 */}
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center gap-2 mb-3">
<Briefcase className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-xs text-gray-500"></span>
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || form.monthlySalary} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || form.monthlySalary} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
</div>
</div>
</div>
{/* 合同信息 */}
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center gap-2 mb-3">
<FileSignature className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700"></span>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="col-span-1">
<Label></Label>
<Select value={form.contractType} onChange={(e) => {
const ct = contractTypes.find(t => t.value === e.target.value)
setForm({ ...form, contractType: e.target.value as any, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
}}>
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</div>
</div>
</div>
{form.contractType !== 'UNSIGNED' && (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
<div className="text-xs text-gray-500 mt-1"></div>
</div>
<div><Label></Label><div className="text-sm text-gray-600 py-2">{form.startDate || '随入职日期'}</div></div>
{form.contractType === 'FIXED' && (
<div>
<Label></Label>
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
</div>
)}
{form.contractType === 'FIXED' && (
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
</div>
)}
</div>
{form.contractType === 'FIXED' && (
<div className="text-xs text-gray-500"></div>
)}
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
{contractMonths > 0 && (
<div className="text-xs text-gray-500 mt-1">{probationMax}</div>
)}
{probationError && <div className="text-xs text-danger mt-1">{probationError}</div>}
</div>
<div>
<Label>{form.probationMonths > 0 ? ' *' : ''}</Label>
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
<div className="text-xs text-gray-500 mt-1">80%¥{(monthlySalaryNum * 0.8).toFixed(0)}</div>
)}
{probationSalaryError && <div className="text-xs text-danger mt-1">{probationSalaryError}</div>}
</div>
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-3 border-t border-gray-200">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
)
}
+66
View File
@@ -0,0 +1,66 @@
/** Roster 共享类型与常量 */
/** 金额格式化:保留两位小数 + 千分位 */
export const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 解聘原因映射 */
export const terminateReasonMap: Record<string, string> = {
NEGOTIATED: '协商解除',
FAULT: '员工过错',
NONFAULT: '非过错解除',
LAYOFF: '经济性裁员',
EXPIRED: '合同到期不续签',
}
/** 详情页 Tab 类型 */
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'history'
/** Tab 分组 */
export type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史'
/** Tab 分组配置 */
export const TAB_GROUPS: { group: TabGroup; tabs: { key: DetailTab; label: string; icon: any }[] }[] = [
{
group: '人事信息',
tabs: [
{ key: 'basic', label: '基本信息', icon: null },
{ key: 'contract', label: '劳动合同', icon: null },
],
},
{
group: '薪酬',
tabs: [
{ key: 'payslip', label: '薪酬社保', icon: null },
],
},
{
group: '考勤绩效',
tabs: [
{ key: 'attendance', label: '考勤培训', icon: null },
{ key: 'performance', label: '绩效考核', icon: null },
],
},
{
group: '风险合规',
tabs: [
{ key: 'disciplinary', label: '违纪记录', icon: null },
{ key: 'termination', label: '离职/解聘', icon: null },
],
},
{
group: '变更历史',
tabs: [
{ key: 'history', label: '变更历史', icon: null },
],
},
]
/** Tab 计数字段映射 */
export const TAB_COUNT_KEYS: Record<string, string> = {
contract: 'contracts',
payslip: 'payslips',
attendance: 'attendanceRecords',
disciplinary: 'disciplinaryRecords',
performance: 'performanceRecords',
termination: 'terminations',
}
@@ -0,0 +1,251 @@
/**
* 年度价值报告页面
* 量化系统为企业创造的价值:ROI + 规避损失 + 节约工时 + 月度时间轴
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { TrendingUp, Save, History, Download, ShieldCheck, Clock, DollarSign, Sparkles, Users, FileText, Calculator, Award } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
function fmtMoney(n: number): string {
if (n >= 10000) return `¥${(n / 10000).toFixed(1)}`
return `¥${n.toLocaleString('zh-CN')}`
}
/**
* 年度价值报告页面
*/
export default function AnnualValueReport() {
const queryClient = useQueryClient()
const currentYear = new Date().getFullYear()
const [year, setYear] = useState(currentYear)
const [showHistory, setShowHistory] = useState(false)
const { data: report, isLoading } = useQuery<any>({
queryKey: ['annual-value', year],
queryFn: async () => {
const res = await api.get(`/dashboard/annual-value?year=${year}`) as any
return res.data
},
})
const { data: history } = useQuery<any>({
queryKey: ['annual-value-history'],
queryFn: async () => {
const res = await api.get('/dashboard/annual-value/history') as any
return res.data
},
enabled: showHistory,
})
const saveMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/dashboard/annual-value/save', { year }) as any
return res.data
},
onSuccess: () => {
toast.success('报告已保存')
queryClient.invalidateQueries({ queryKey: ['annual-value-history'] })
},
onError: () => toast.error('保存失败'),
})
if (isLoading) {
return <div className="text-center py-8 text-gray-500">...</div>
}
if (!report) return null
const metricCards = [
{ label: '规避损失', value: fmtMoney(report.lossAvoided || 0), icon: ShieldCheck, color: 'text-safe', bg: 'bg-green-50' },
{ label: '节约工时', value: `${report.timeSaved || 0}h`, icon: Clock, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '节约成本', value: fmtMoney(report.costSaved || 0), icon: DollarSign, color: 'text-primary', bg: 'bg-indigo-50' },
{ label: '总价值', value: fmtMoney(report.totalValue || 0), icon: TrendingUp, color: 'text-amber-600', bg: 'bg-amber-50' },
]
const stats = [
{ label: '处理风险', value: report.metrics?.risksResolved ?? 0, icon: ShieldCheck, color: 'text-danger' },
{ label: 'AI 咨询', value: report.metrics?.aiQueries ?? 0, icon: Sparkles, color: 'text-primary' },
{ label: '合同审查', value: report.metrics?.contractsReviewed ?? 0, icon: FileText, color: 'text-blue-600' },
{ label: '签订合同', value: report.metrics?.contractsSigned ?? 0, icon: FileText, color: 'text-safe' },
{ label: '算薪批次', value: report.metrics?.payrollProcessed ?? 0, icon: Calculator, color: 'text-amber-600' },
{ label: '在管员工', value: report.metrics?.employeesManaged ?? 0, icon: Users, color: 'text-gray-700' },
{ label: '解聘处理', value: report.metrics?.terminations ?? 0, icon: Users, color: 'text-orange-600' },
{ label: '制度公示', value: report.metrics?.policiesPublished ?? 0, icon: FileText, color: 'text-purple-600' },
]
const timelineData = (report.timeline || []).map((t: any) => ({
name: `${t.month}`,
risks: t.value,
event: t.event,
}))
return (
<div className="space-y-3">
{/* 标题栏 */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500">{year} · </p>
</div>
<div className="flex items-center gap-2">
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="px-2 py-1 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map((y) => (
<option key={y} value={y}>{y} </option>
))}
</select>
<Button variant="secondary" size="sm" onClick={() => setShowHistory(!showHistory)}>
<History className="w-4 h-4 mr-1" />
{showHistory ? '收起' : '历史'}
</Button>
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
<Save className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* ROI 横幅 */}
<Card className="bg-gradient-to-r from-primary/10 to-amber-50 border-primary/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-primary/10">
<TrendingUp className="w-8 h-8 text-primary" />
</div>
<div>
<div className="text-xs text-gray-500"> (ROI)</div>
<div className="text-3xl font-bold text-primary">{report.roi}%</div>
</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-amber-600">{fmtMoney(report.totalValue)}</div>
</div>
</div>
<p className="mt-3 text-sm text-gray-700">{report.summary}</p>
</Card>
{/* 4 核心指标 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{metricCards.map((m) => {
const Icon = m.icon
return (
<Card key={m.label} className="flex items-center gap-2.5">
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${m.bg}`}>
<Icon className={`w-5 h-5 ${m.color}`} />
</div>
<div>
<div className={`text-base font-bold ${m.color}`}>{m.value}</div>
<div className="text-xs text-gray-500">{m.label}</div>
</div>
</Card>
)
})}
</div>
{/* 历史报告 */}
{showHistory && (
<Card>
<h2 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<History className="w-4 h-4 text-gray-500" />
</h2>
{history && history.length > 0 ? (
<div className="space-y-1.5">
{history.map((r: any) => (
<div key={r.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50 text-xs">
<div className="flex items-center gap-2">
<span className="font-medium">{r.year} </span>
<span className="font-bold text-primary">ROI {r.roi}%</span>
<span className="text-gray-500">{r.summary}</span>
</div>
<span className="text-gray-400">{new Date(r.createdAt).toLocaleDateString('zh-CN')}</span>
</div>
))}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-3"></div>
)}
</Card>
)}
{/* 月度风险趋势 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={timelineData}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip
formatter={(v: any) => [`${v}`, '风险']}
contentStyle={{ fontSize: 12, borderRadius: 6, border: '1px solid #e5e7eb' }}
/>
<Bar dataKey="risks" radius={[4, 4, 0, 0]}>
{timelineData.map((d: any, i: number) => (
<Cell key={i} fill={d.risks > 5 ? '#EF4444' : d.risks > 0 ? '#F59E0B' : '#16A34A'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* 8 项运营统计 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((s) => {
const Icon = s.icon
return (
<div key={s.label} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
<Icon className={`w-4 h-4 ${s.color}`} />
<div>
<div className="text-sm font-bold">{s.value}</div>
<div className="text-xs text-gray-500">{s.label}</div>
</div>
</div>
)
})}
</div>
</Card>
{/* 价值计算说明 */}
<Card className="bg-gray-50">
<h2 className="text-sm font-medium mb-2"></h2>
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-safe mt-1 flex-shrink-0" />
<span><b></b></span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 mt-1 flex-shrink-0" />
<span><b></b>AI × 0.5h + × 1h + × 2h + × 1h</span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
<span><b></b> × 100 /hHR </span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1 flex-shrink-0" />
<span><b></b> + </span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-1 flex-shrink-0" />
<span><b>ROI</b> ÷ ¥12,000× 100%</span>
</div>
</div>
</Card>
</div>
)
}
+215
View File
@@ -0,0 +1,215 @@
/**
* 用工体检诊断页面
* 6 维度深度诊断 + 历史报告
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Stethoscope, Save, History, CheckCircle2, AlertCircle, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'
import { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } from 'recharts'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
/**
* 用工体检诊断页面
*/
export default function HealthCheck() {
const queryClient = useQueryClient()
const [showHistory, setShowHistory] = useState(false)
const [expandedDim, setExpandedDim] = useState<string | null>(null)
const { data: healthCheck, isLoading } = useQuery<any>({
queryKey: ['health-check'],
queryFn: async () => {
const res = await api.get('/dashboard/health-check') as any
return res.data
},
})
const { data: history } = useQuery<any>({
queryKey: ['health-check-history'],
queryFn: async () => {
const res = await api.get('/dashboard/health-check/history') as any
return res.data
},
enabled: showHistory,
})
const saveMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/dashboard/health-check/save') as any
return res.data
},
onSuccess: () => {
toast.success('诊断报告已保存')
queryClient.invalidateQueries({ queryKey: ['health-check-history'] })
},
onError: () => toast.error('保存失败'),
})
if (isLoading) {
return <div className="text-center py-8 text-gray-500">...</div>
}
if (!healthCheck) return null
const levelConfig: Record<string, { color: string; bg: string; text: string; icon: typeof CheckCircle2; label: string }> = {
safe: { color: '#16A34A', bg: 'bg-green-50', text: 'text-safe', icon: CheckCircle2, label: '健康' },
warning: { color: '#D97706', bg: 'bg-amber-50', text: 'text-warning', icon: AlertCircle, label: '中等风险' },
danger: { color: '#C00000', bg: 'bg-red-50', text: 'text-danger', icon: AlertTriangle, label: '高风险' },
}
const level = levelConfig[healthCheck.level] || levelConfig.warning
const LevelIcon = level.icon
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Stethoscope className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500">{healthCheck.year} · 6 </p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowHistory(!showHistory)}>
<History className="w-4 h-4 mr-1" />
{showHistory ? '收起历史' : '历史报告'}
</Button>
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
<Save className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 总评分 */}
<Card className={level.bg}>
<div className="flex items-center gap-4">
<div className="relative w-28 h-28 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
innerRadius="70%"
outerRadius="100%"
data={[{ value: healthCheck.totalScore, fill: level.color }]}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar background dataKey="value" cornerRadius={8} />
</RadialBarChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-2xl font-bold ${level.text}`}>{healthCheck.totalScore}</span>
<span className="text-xs text-gray-500">{level.label}</span>
</div>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<LevelIcon className={`w-4 h-4 ${level.text}`} />
<span className="text-sm font-medium"></span>
</div>
<p className="text-sm text-gray-700">{healthCheck.summary}</p>
</div>
</div>
</Card>
{/* 历史报告 */}
{showHistory && (
<Card>
<h2 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<History className="w-4 h-4 text-gray-500" />
</h2>
{history && history.length > 0 ? (
<div className="space-y-1.5">
{history.map((r: any) => {
const lc = levelConfig[r.level] || levelConfig.warning
return (
<div key={r.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50 text-xs">
<div className="flex items-center gap-2">
<span className="font-medium">{r.year} </span>
<span className={`font-bold ${lc.text}`}>{r.totalScore} </span>
<span className="text-gray-500">{r.summary}</span>
</div>
<span className="text-gray-400">
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
</span>
</div>
)
})}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-3"></div>
)}
</Card>
)}
{/* 6 维度诊断 */}
<div className="space-y-2">
{healthCheck.dimensions.map((dim: any) => {
const dimLevel = dim.score >= 85 ? 'safe' : dim.score >= 60 ? 'warning' : 'danger'
const dc = levelConfig[dimLevel] || levelConfig.warning
const DimIcon = dc.icon
const isExpanded = expandedDim === dim.key
return (
<Card key={dim.key}>
<button
onClick={() => setExpandedDim(isExpanded ? null : dim.key)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center gap-2.5">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${dc.bg}`}>
<DimIcon className={`w-4 h-4 ${dc.text}`} />
</div>
<div>
<div className="text-sm font-medium">{dim.name}</div>
<div className="text-xs text-gray-500">{dim.findings[0]}</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-lg font-bold ${dc.text}`}>{dim.score}</span>
{isExpanded ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
</div>
</button>
{isExpanded && (
<div className="mt-3 pt-3 border-t space-y-3">
{/* 发现的问题 */}
<div>
<div className="text-xs font-medium text-gray-700 mb-1"></div>
<div className="space-y-1">
{dim.findings.map((f: string, i: number) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
<span className={`w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0 ${dim.score >= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} />
{f}
</div>
))}
</div>
</div>
{/* 建议 */}
{dim.recommendations.length > 0 && (
<div>
<div className="text-xs font-medium text-primary mb-1"></div>
<div className="space-y-1">
{dim.recommendations.map((r: string, i: number) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
{r}
</div>
))}
</div>
</div>
)}
</div>
)}
</Card>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,279 @@
/**
* 医疗期计算器
* 根据员工工龄和地区计算法定医疗期天数
* 法律依据:《企业职工患病或非因工负伤医疗期规定》(劳部发[1994]479号)
* 上海特殊规定:沪府发[2015]40号
*/
import { useState } from 'react'
import { Calculator, HeartPulse, Info } from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
interface MedicalPeriodResult {
totalMonths: number
cumulativeDays: number
actualDays: number
endDate: string
legalBasis: string
notes: string[]
}
/**
* 计算医疗期
* @param workYears 本单位工作年限
* @param region 地区(上海/全国)
* @param sickDays 累计病休天数
* @param startDate 开始病休日期
*/
function calculateMedicalPeriod(
workYears: number,
region: 'shanghai' | 'national',
sickDays: number,
startDate: string,
): MedicalPeriodResult | null {
if (!startDate || workYears < 0) return null
let totalMonths: number
let cumulativeDays: number
let legalBasis: string
const notes: string[] = []
if (region === 'shanghai') {
// 上海特殊规定:直接按工龄分档
if (workYears < 1) {
totalMonths = 3
cumulativeDays = 6 * 30 // 6个月周期
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else if (workYears < 4) {
totalMonths = 3
cumulativeDays = 6 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else if (workYears < 10) {
totalMonths = 6
cumulativeDays = 12 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else {
totalMonths = 9
cumulativeDays = 18 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
}
notes.push('上海地区适用特殊规定,医疗期不按累计病休天数折算')
} else {
// 全国通用规定:劳部发[1994]479号
if (workYears < 5) {
totalMonths = 3
cumulativeDays = 6 * 30 // 6个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 10) {
totalMonths = 6
cumulativeDays = 12 * 30 // 12个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 15) {
totalMonths = 9
cumulativeDays = 15 * 30 // 15个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 20) {
totalMonths = 12
cumulativeDays = 18 * 30 // 18个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else {
totalMonths = 24
cumulativeDays = 30 * 30 // 30个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
}
notes.push(`${cumulativeDays / 30} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`)
}
// 计算实际可用天数
const actualDays = Math.max(0, totalMonths * 30 - sickDays)
// 计算医疗期结束日期
const start = new Date(startDate)
const endDate = new Date(start)
endDate.setMonth(endDate.getMonth() + totalMonths)
notes.push('医疗期内企业不得解除劳动合同(法定情形除外)')
notes.push('医疗期满后不能从事原工作也不能从事另行安排的工作,企业可提前30天通知或支付代通知金解除')
return {
totalMonths,
cumulativeDays,
actualDays,
endDate: endDate.toISOString().slice(0, 10),
legalBasis,
notes,
}
}
/**
* 医疗期计算器页面
*/
export default function MedicalPeriodCalculator() {
const [region, setRegion] = useState<'national' | 'shanghai'>('national')
const [workYears, setWorkYears] = useState('')
const [sickDays, setSickDays] = useState('0')
const [startDate, setStartDate] = useState('')
const [result, setResult] = useState<MedicalPeriodResult | null>(null)
const handleCalculate = () => {
const years = parseFloat(workYears) || 0
const days = parseInt(sickDays) || 0
const r = calculateMedicalPeriod(years, region, days, startDate)
setResult(r)
}
const handleReset = () => {
setRegion('national')
setWorkYears('')
setSickDays('0')
setStartDate('')
setResult(null)
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<HeartPulse className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<Card>
<div className="space-y-3">
{/* 地区选择 */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={region}
onChange={(e) => setRegion(e.target.value as 'national' | 'shanghai')}
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="national"></option>
<option value="shanghai"></option>
</select>
</div>
{/* 工龄输入 */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<input
type="number"
value={workYears}
onChange={(e) => setWorkYears(e.target.value)}
placeholder="如:5.5"
step="0.5"
min="0"
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{/* 病休开始日期 */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{/* 累计病休天数 */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<input
type="number"
value={sickDays}
onChange={(e) => setSickDays(e.target.value)}
placeholder="0"
min="0"
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleCalculate} className="flex-1">
<Calculator className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" onClick={handleReset}>
</Button>
</div>
</div>
</Card>
{/* 计算结果 */}
{result && (
<Card className="border-primary/20 bg-primary/5">
<div className="space-y-3">
<h2 className="text-sm font-medium flex items-center gap-1.5">
<Info className="w-4 h-4 text-primary" />
</h2>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-white">
<div className="text-xs text-gray-500"></div>
<div className="text-lg font-bold text-primary">{result.totalMonths} </div>
</div>
<div className="p-3 rounded-lg bg-white">
<div className="text-xs text-gray-500"></div>
<div className="text-lg font-bold text-primary">{result.cumulativeDays / 30} </div>
</div>
<div className="p-3 rounded-lg bg-white">
<div className="text-xs text-gray-500"></div>
<div className={`text-lg font-bold ${result.actualDays > 0 ? 'text-safe' : 'text-danger'}`}>
{result.actualDays}
</div>
</div>
<div className="p-3 rounded-lg bg-white">
<div className="text-xs text-gray-500"></div>
<div className="text-sm font-bold text-gray-800">{result.endDate}</div>
</div>
</div>
{/* 法律依据 */}
<div className="p-2 rounded-md bg-amber-50 text-xs text-amber-800">
<span className="font-medium"></span>{result.legalBasis}
</div>
{/* 注意事项 */}
<div className="space-y-1">
<div className="text-xs font-medium text-gray-700"></div>
{result.notes.map((note, i) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
{note}
</div>
))}
</div>
</div>
</Card>
)}
{/* 工龄分档表 */}
<Card>
<h2 className="text-sm font-medium mb-2"></h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
</tr>
</thead>
<tbody className="divide-y">
<tr><td className="py-2 pr-3"> 5 </td><td className="py-2 pr-3">3 </td><td className="py-2 pr-3">6 </td></tr>
<tr><td className="py-2 pr-3">5-10 </td><td className="py-2 pr-3">6 </td><td className="py-2 pr-3">12 </td></tr>
<tr><td className="py-2 pr-3">10-15 </td><td className="py-2 pr-3">9 </td><td className="py-2 pr-3">15 </td></tr>
<tr><td className="py-2 pr-3">15-20 </td><td className="py-2 pr-3">12 </td><td className="py-2 pr-3">18 </td></tr>
<tr><td className="py-2 pr-3">20 </td><td className="py-2 pr-3">24 </td><td className="py-2 pr-3">30 </td></tr>
</tbody>
</table>
</div>
</Card>
</div>
)
}
+27
View File
@@ -83,13 +83,31 @@ export interface DashboardData {
todoCount: number
monthlyOvertimePay: number
}
urgentRisk: {
id: string
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING'
level: 'high' | 'medium' | 'low'
priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW'
title: string
description: string
actionUrl: string
estimatedLoss: number
lossRange: [number, number] | null
deadline: string | null
daysUntilDeadline: number | null
} | null
todos: {
id: string
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING'
level: 'high' | 'medium' | 'low'
priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW'
title: string
description: string
actionUrl: string
estimatedLoss: number
lossRange: [number, number] | null
deadline: string | null
daysUntilDeadline: number | null
}[]
resolvedTodos: {
id: string
@@ -105,13 +123,22 @@ export interface DashboardData {
salary: number
termination: number
}
riskTrend: {
thisMonth: number
lastMonth: number
change: number
}
topRisks: {
id: string
type: string
level: string
priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW'
title: string
description: string
employeeName: string | null
estimatedLoss: number
deadline: string | null
daysUntilDeadline: number | null
actionUrl: string
}[]
aiPrediction: {
+154
View File
@@ -0,0 +1,154 @@
# TurboHR 运行手册
## 技术栈
| 层 | 技术 | 版本 |
|---|------|------|
| 前端 | React + Vite + TypeScript + TailwindCSS | React 18, Vite 5 |
| 后端 | Express + TypeScript + Prisma | Node 18+, Express 4 |
| 数据库 | PostgreSQL | 14+ |
| 缓存 | Redis(可选,降级内存 Map | 6+ |
| AI | OpenAI / 通义千问(SSE 流式) | — |
## 首次准备
```bash
# 1. 安装后端依赖
cd backend
npm install
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env 填写 DATABASE_URL、JWT_SECRET、AI_API_KEY 等
# 3. 同步数据库
npx prisma db push
npx prisma generate
# 4. 安装前端依赖
cd ../frontend
npm install
```
## 应用启停
### 启动后端
```bash
cd backend
npx tsx watch src/index.ts
```
- 默认端口:**3000**
- 启动成功输出:`Server running on http://[::]:3000`
- 入口文件:`src/index.ts`(不是 `src/app.ts``app.ts` 仅 export app 实例)
### 启动前端
```bash
cd frontend
npx vite --port 5173
```
- 默认端口:**5173**
- 启动成功输出:`VITE v5.x ready`
- 访问地址:`http://localhost:5173`
### 停止服务
```bash
# 查找占用端口的进程
lsof -i :3000 -i :5173
# 杀掉进程
lsof -ti :3000 -ti :5173 | xargs kill -9
```
## DB 命令
```bash
cd backend
# 同步 schema 到数据库(增量,不丢数据)
npx prisma db push
# 生成 Prisma Client
npx prisma generate
# 创建迁移文件并应用
npx prisma migrate dev --name <migration_name>
# 重置数据库(危险!会清空数据)
npx prisma migrate reset
# 打开 Prisma Studio 可视化管理
npx prisma studio
# 验证 schema
npx prisma validate
```
## 排错
| 问题 | 解决方案 |
|------|---------|
| 端口被占用 | `lsof -ti :3000 \| xargs kill -9` |
| Prisma Client 找不到新 model | `npx prisma generate` 后重启 TS Server |
| IDE 报模块找不到但 tsc 通过 | `Cmd+Shift+P``TypeScript: Restart TS Server` |
| 后端启动无输出 | 确认入口是 `src/index.ts` 不是 `src/app.ts` |
| Redis 连接失败 | 自动降级内存 Map,不影响功能;设置 `REDIS_URL` 启用 |
| 前端 API 404 | 确认后端已启动在 3000 端口,Vite 代理配置在 `vite.config.ts` |
## 端口表
| 服务 | 端口 | 说明 |
|------|------|------|
| 后端 API | 3000 | Express + Prisma |
| 前端 Dev | 5173 | Vite dev server |
| PostgreSQL | 5432 | 数据库 |
| Redis | 6379 | 验证码缓存(可选) |
## 环境变量(.env
```env
# 后端 backend/.env
DATABASE_URL="postgresql://user:pass@localhost:5432/hr_compliance"
JWT_SECRET="your-secret-key"
AI_API_KEY="your-ai-api-key"
AI_BASE_URL="https://api.openai.com/v1"
AI_MODEL="gpt-4o-mini"
REDIS_URL="redis://localhost:6379" # 可选
PORT=3000
# 前端 frontend/.env
VITE_API_BASE_URL="http://localhost:3000/api/v1"
```
## 部署备份
```bash
# 前端构建
cd frontend && npm run build
# 产物在 frontend/dist/
# 后端编译
cd backend && npx tsc
# 产物在 backend/dist/
# 数据库备份
pg_dump -U user hr_compliance > backup_$(date +%Y%m%d).sql
# 数据库恢复
psql -U user hr_compliance < backup_20260726.sql
```
## FAQ
**Q: 为什么后端启动用 `src/index.ts` 而不是 `src/app.ts`**
A: `app.ts` 只创建并 export Express app 实例(供测试使用),`index.ts` 才是启动入口,包含 `app.listen()`
**Q: `prisma migrate dev` 和 `prisma db push` 的区别?**
A: `migrate dev` 创建迁移文件并应用,适合生产环境;`db push` 直接同步 schema 不生成迁移文件,适合开发环境快速迭代。
**Q: 新增 Prisma model 后 IDE 报类型不存在?**
A: 执行 `npx prisma generate`,然后 `Cmd+Shift+P``TypeScript: Restart TS Server`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,813 @@
# 白话用工侠_测试页面.html 详细分析报告
> 文件路径:`/Users/freedak/Documents/AIDashboard/UTMS/TurboHR/白话用工侠_测试页面.html`
> 文件总行数:6370 行
> 分析日期:2026-07-26
---
## 一、文件总体结构
| 区域 | 行范围 | 说明 |
|------|--------|------|
| `<head>` + CSS 变量 | 1-60 | 全局 CSS 变量定义(主色/字体/圆角等) |
| CSS 组件样式 | 60-2899 | 所有 UI 组件的样式定义 |
| `<body>` HTML 结构 | 2900-5751 | Sidebar + Topbar + 26 个页面模块 |
| 浮动白小侠按钮 | 5753-5759 | 右下角悬浮 AI 入口 |
| `<script>` 交互逻辑 | 5761-6366 | 页面切换 + 后端 API 对接 |
### 技术栈
- 纯原生 HTML + CSS + JS(无框架)
- 图标库:Tabler Icons (`ti ti-*`)
- 字体:系统字体栈
- 后端对接:Fetch API + SSE 流式
---
## 二、CSS 设计系统(行 1-2899
### 2.1 全局变量
```css
:root {
--primary: #2E75B6; /* 主蓝色 */
--danger: #C00000; /* 高风险红 */
--warning: #D97706; /* 中风险黄 */
--safe: #16A34A; /* 安全绿 */
--purple: #6D28D9; /* 知识紫 */
--bg: #F8FAFC; /* 页面背景 */
--card-bg: #FFFFFF; /* 卡片背景 */
--text: #1E2761; /* 主文字 */
--text-sub: #6B7280; /* 次要文字 */
--border: #E5E7EB; /* 边框 */
--radius: 8px; /* 圆角 */
}
```
### 2.2 核心组件样式清单
| 组件类名 | 用途 | 关键样式特征 |
|----------|------|-------------|
| `.sidebar` | 左侧导航栏 | `width:220px; fixed; bg:#1E2761` 深色背景 |
| `.nav-group` / `.nav-sub` | 分组导航 | 支持 `collapsed` 折叠状态 |
| `.topbar` | 顶部栏 | `h-56px; sticky; bg:white` 含 AI 搜索框 |
| `.ai-search-input` | AI 搜索输入 | placeholder 轮播动画 |
| `.content` | 主内容区 | `margin-left:220px; padding:20px` |
| `.page` / `.page.active` | 页面容器 | `display:none``display:block` 切换 |
| `.alert-banner` | AI 预警横幅 | 渐变背景 + 图标 + 操作按钮 |
| `.score-card` | 风险评分卡 | SVG 环形进度 + 评分数字 |
| `.score-detail-card` | 评分细项卡 | 标签 + 进度条 + 分数 |
| `.value-card` | 价值看板卡 | 深色渐变背景 + 金额 + 统计 |
| `.suggestion-card` | AI 建议卡片 | `sg-high/sg-med/sg-val/sg-know` 四种风格 |
| `.apply-card` | 业务流程入口 | 图标 + 名称 + 描述,点击高亮 |
| `.chat-layout` | AI 对话布局 | 左侧会话列表 + 右侧聊天区 |
| `.chat-session` | 历史会话项 | 标题 + 时间 + 元信息 |
| `.msg-user` / `.msg-ai` | 消息气泡 | 用户右对齐 / AI 左对齐 |
| `.ai-cite` | AI 引用标签 | 蓝色小标签(法律条款/判例数) |
| `.prediction-mini` | 预测迷你卡 | 三档方案横排(红/黄/绿) |
| `.pred-result-card` | 预测结果卡 | 大卡片:方案名+金额+判例+步骤+按钮 |
| `.scene-chip` | 场景选择芯片 | 图标+文字,`active` 高亮 |
| `.staff` | 风险地图表格 | 员工信息+风险评分+风险标签 |
| `.score-cell` | 评分单元格 | 数字+进度条+等级文字 |
| `.tag` / `.tag-r/y/g/b/p` | 风险标签 | 各颜色变体 |
| `.fr-body` | 文件审查布局 | 左右分栏(原文 + 风险报告) |
| `.hl` / `.hl-r/y/g` | 原文高亮 | 红/黄/绿 三色高亮 |
| `.risk-card` | 风险卡片 | 等级标签+原文+问题+建议+操作 |
| `.cc-top` | 合规中心顶部 | 深色渐变 + 评分环 + 操作按钮 |
| `.cc-dimensions` | 5 维评分卡 | 网格布局,每维度含进度条 |
| `.cc-dim` | 评分维度卡 | 图标+名称+分数+进度条+待办数 |
| `.todo-list` / `.todo-item` | 待办清单 | P0/P1/P2 优先级 + 操作按钮 |
| `.es-stats` | 电子签统计 | 四宫格指标 |
| `.es-item` | 待签合同项 | 图标+标题+元信息+进度+操作 |
| `.data-table` | 数据表格 | 通用表格样式 |
| `.data-toolbar` | 数据工具栏 | 筛选字段 + 搜索 + 操作按钮 |
| `.pagination` | 分页 | 页码 + 上/下页 |
| `.form-row` / `.form-grid-2` | 表单布局 | 标签 + 输入框,两列网格 |
| `.floating-ai` | 浮动 AI 按钮 | 固定右下角 + 语音 + badge |
| `.bg-hero` | 背景英雄区 | 渐变背景 + 图标 + 标题 + 描述 |
| `.video-card` | 视频课程卡 | 缩略图 + 标题 + 描述 + 按钮 |
| `.cc-template-card` | 模板卡片 | 图标 + 名称 + 元信息 |
---
## 三、HTML 结构详解(行 2900-5751
### 3.1 顶部栏 Topbar(行 2880-2910
```
顶栏结构:
├── Logo "白话用工侠"
├── AI 搜索框(placeholder 轮播 5 个热门问题)
├── 通知铃铛(含红点)
└── 用户信息(头像 + 姓名 + 公司)
```
- AI 搜索框 placeholder 每 3.5 秒轮播:`员工怀孕能开除吗?` / `合同到期不续签要赔多少钱?` / `试用期不通过的解除流程是怎样?` / `上传规章制度,AI 帮您审查合规风险...` / `调岗调薪没员工同意,能强制吗?`
### 3.2 左侧导航 Sidebar(行 2800-2879
**分组结构**`nav-group`):
| 分组 | 包含页面 |
|------|---------|
| 首页 | 风险驾驶舱 |
| 白小侠 AI | AI 对话、判赔预测器、文件 AI 审查、仲裁应急通道、律师咨询 |
| 员工管理 | 全员风险地图、个人风险档案、入职管理、合同管理、考勤确认、工资条发布 |
| 合规中心 | 合规中心、规章制度民主程序、企业文本库、用工文本模板库、用工体检诊断、背景调查 |
| 电子签 | 电子签中心 |
| 我要办理 | 申请草稿、已发申请 |
| 工具中心 | 视频中心、五险一金计算器、医疗期计算器 |
| 系统 | 通知管理、用户管理、系统日志 |
- 默认仅"白小侠 AI"分组展开,其余折叠
- 二级导航项 `nav-sub``data-page` 属性用于页面跳转
---
### 3.3 26 个页面模块详细分析
---
#### PAGE 1: 风险驾驶舱 `#page-cockpit`(行 2920-3171
**定位**:系统首页 / 核心仪表盘
**布局结构**
```
├── 页面标题 + 数据更新时间
├── AI 预警横幅(alert-banner
│ ├── 员工"刘云"合同到期预警
│ ├── 仲裁判赔区间 ¥18,400—¥36,800
│ └── [咨询白小侠] [立即处理] 按钮
├── 三栏布局(cols-3
│ ├── 风险评分卡(score-card
│ │ ├── SVG 环形进度(78分/中等风险)
│ │ ├── 趋势:较上月 ↑ +5 分
│ │ └── 统计:4 风险点 / 17 合规检查 / 3 待处理
│ ├── 评分细项卡(score-detail-card
│ │ ├── 合同管理 78(黄)
│ │ ├── 规章制度 65(黄)
│ │ ├── 考勤工资 82(绿)
│ │ ├── 员工档案 45(红)
│ │ └── 社保公积金 88(绿)
│ └── 价值看板(value-card
│ ├── ¥128,400 累计规避损失
│ └── 17 拦截 / 42 咨询 / 4 条款优化
├── AI 主动建议卡片流(4 张 suggestion-card
│ ├── 🔴 高风险:张三入职未签合同 → 双倍工资风险
│ ├── 🟡 中风险:考勤制度未民主公示 → 败诉率 87%
│ ├── 🟢 价值:30 天拦截 4 处风险,避免 ¥12.8 万
│ └── 🟣 知识:同行业 5 起仲裁判例 + 3 条避坑建议
└── 我要办理(业务流程申请)
├── 招聘入职:员工录用/入职/自定义合同签署/培训任务/员工信息提交
├── 在职管理:转正/合同变更/续签/中止/收入证明
├── 离职管理:合同终止/解除(先咨询AI)/离职证明
└── 多元化用工:灵活用工
```
**关键数据点**
- 合规健康度:78 分(中等风险)
- 年度规避损失:¥128,400
- AI 建议按 4 类分级:高风险(红)/ 中风险(黄)/ 价值(绿)/ 知识(紫)
- 业务流程入口按 4 大场景分组,共 14 个申请项
---
#### PAGE 2: 白小侠 AI 对话 `#page-ai`(行 3176-3297
**定位**AI 智能问答核心页面
**布局结构**
```
├── 页面标题(AI 90% · 专家 9% · 律师 1% 三层兜底)
├── chat-layout(左右分栏)
│ ├── chat-sidebar(左侧历史会话)
│ │ ├── [新对话] 按钮
│ │ └── 4 条历史会话(怀孕员工迟到/试用期不通过/未签合同/竞业限制)
│ └── chat-area(右侧对话区)
│ ├── chat-headAI 头像 + 在线状态 + [转专家][约律师])
│ ├── chat-messages
│ │ ├── 用户消息:"想开除怀孕员工..."
│ │ └── AI 回复(bubble warn
│ │ ├── 🚨 强风险提示(法律条款引用)
│ │ ├── ⚖️ AI 判例分析(38 起判例,企业胜诉率 22%)
│ │ └── 💰 判赔预测(三档迷你卡:强行解除¥113k+ / 协商¥25k / 调岗¥0
│ └── chat-input-area
│ ├── 4 个快捷问题按钮
│ └── 输入框 + [发送] 按钮
```
**AI 回复结构化组件**
- `ai-section`:分段展示(风险提示/判例分析/判赔预测)
- `ai-cite`:引用标签(法律条款/判例数)
- `prediction-mini`:三档预测迷你卡片
- `msg-actions`:操作按钮组(查看判例/生成方案/场景推演/转人工)
---
#### PAGE 3: 判赔预测器 `#page-prediction`(行 3302-3488
**定位**:核心 AI 能力 — 基于判例库的仲裁赔偿预测
**布局结构**
```
├── 页面标题 + AI 核心能力标签
├── annotation(免责声明:基于真实仲裁判例 AI 大模型)
├── pred-input-card(输入区)
│ ├── 第一步:12 类争议场景选择(scene-chip
│ │ ├── 员工严重违纪解除 / 试用期不录用 / 客观情况变化
│ │ ├── 经济性裁员 / 协商一致解除 / 到期不续签
│ │ ├── 调岗调薪 / 未签书面合同 / 加班费争议
│ │ └── 社保争议 / 女职工三期 / 工伤认定
│ ├── 第二步:案件信息表单
│ │ ├── 员工姓名(含工龄信息)
│ │ ├── 违纪事实
│ │ ├── 所在地区
│ │ ├── 员工月薪
│ │ ├── 制度民主公示状态
│ │ └── 违纪记录留痕
│ └── [AI 预测仲裁结果] 按钮
└── pred-results(三档方案结果)
├── 原方案(pred-red):直接解除
│ ├── ¥86,000 预测赔偿(中位数)
│ ├── 区间 ¥68k-¥102k
│ ├── 42 起判例 · 败诉率 81%
│ └── 败诉主因:制度未民主公示/迟到未达严重/未给改正机会
├── 方案 A(pred-amber):补正后协商 ⭐推荐
│ ├── ¥28,000 预测赔偿
│ ├── 27 起替代方案 · 胜诉率 78%
│ └── 3 步操作:发起民主程序 → 补全书面记录 → 协商解除
└── 方案 B(pred-green):调岗改善 最优
├── ¥0 预测赔偿
├── 19 起预防案 · 0 赔付率 89%
└── 3 步操作:3 个月调岗改善 → 每月绩效追踪 → 达不到要求再协商
```
---
#### PAGE 4: 全员风险地图 `#page-staff`(行 3493-3723
**定位**:花名册升级版 — 每位员工 7 维仲裁风险评分
**布局结构**
```
├── 筛选栏(filter-bar
│ ├── 部门/风险等级/特殊群体 下拉
│ ├── 员工姓名搜索
│ ├── 统计:全部 47 / 高风险 4 / 中风险 12 / 健康 31
│ └── [添加人员] [导出] 按钮
└── staff 表格
├── 列:员工 | 部门/岗位 | 合同到期 | 风险评分 | 风险标签 | 操作
└── 7 行示例数据
├── 王明(销售部)- 38 分高风险 - 违纪5次/迟到/绩效不达标
├── 刘云(市场部)- 45 分高风险 - 合同到期/哺乳期/考勤未签字
├── 张三(研发部)- 28 分极高 - 合同未签14天/双倍工资风险
├── 李雪梅(财务部)- 68 分中风险 - 工资条未确认/调岗中
├── 陈军(生产部)- 72 分中风险 - 工伤恢复期/医疗期管理
├── 周丽(人事部)- 88 分健康 - 已签竞业/已完成培训
└── 高敏(运营部)- 94 分优秀 - 全维度合规
```
**关键特征**
- 风险评分用颜色分级:红(<50)/ 黄(50-80/ 绿(>80
- 风险标签多颜色:`tag-r`(红)/ `tag-y`(黄)/ `tag-b`(蓝)/ `tag-p`(紫,特殊群体)
- 合同到期列有预警样式(红色文字 + ⚠️)
---
#### PAGE 5: 个人风险档案 `#page-archive`(行 3728-3863
**定位**:单个员工的深度风险分析
**三栏布局**`grid-template-columns: 280px 1fr 320px`):
```
├── 左栏:员工基本信息
│ ├── 头像 + 姓名 + 部门/岗位/工龄
│ ├── 入职日期/合同期限/月薪/社保/竞业协议
│ └── [AI 预测:若今日离职会怎样] 按钮
├── 中栏:7 维仲裁风险评分
│ ├── ① 合同合规 90(绿)
│ ├── ② 工时考勤 30(红)
│ ├── ③ 薪酬异常 85(绿)
│ ├── ④ 社保公积金 95(绿)
│ ├── ⑤ 特殊群体 N/A(绿)
│ ├── ⑥ 培训背调 65(黄)
│ ├── ⑦ 行为异常 25(红)
│ ├── 总分 38/100 高风险
│ └── 评分趋势 SVG 折线图(6 个月下降趋势)
└── 右栏:风险标签 & 修复路径
├── 违纪 5 次未存证 → [补全书面记录]
├── 考勤未月度签字 → [发起补签]
├── 绩效连续不达标 → [生成 PIP]
└── 白小侠建议:不建议直接解除,建议补齐流程再协商
```
---
#### PAGE 6: 年度 AI 价值报告 `#page-report`(行 3864-3948
**定位**:续约谈判材料 — AI 价值量化
**布局结构**
```
├── 价值横幅(深色渐变背景)
│ ├── 规避 ¥128,400 潜在赔偿
│ ├── 投资回报率 1,330%(14.3 倍价值返还)
│ ├── 服务年费 ¥8,980
│ └── [立即续约(升级 AI 防御版)]
├── 价值四宫格
│ ├── 17 拦截高风险事件
│ ├── 142 AI 咨询次数
│ ├── 23 文书审改次数
│ └── +13 合规健康度提升
└── 年度关键防御事件(时间轴)
├── 2026-03-15 🔴 AI 阻止违法解除孕期员工(规避 ¥113,000)
├── 2026-06-22 🟡 AI 修正考勤制度民主程序
├── 2026-08-08 🟢 AI 优化销售提成方案
└── 2026-10-30 ⚖️ 仲裁应急通道(律师接管)
```
---
#### PAGE 7: 文件 AI 审查 `#page-file-review`(行 3953-4193
**定位**:核心 AI 能力 — 合同/制度/通知/协议的 AI 审查
**布局结构**
```
├── fr-header(顶部信息条)
│ ├── 文件名 + 大小/页数/审查状态
│ ├── 风险统计:高风险 3 / 中风险 5 / 建议优化 2
│ └── [重新上传] [导出修改版] 按钮
├── fr-body(左右分栏)
│ ├── fr-left(左侧合同原文)
│ │ ├── fr-doc-toolbar(工具栏 + 操作提示)
│ │ └── fr-doc(合同原文,9 条条款)
│ │ └── 高亮标记:hl-r(红)/ hl-y(黄)/ hl-g(绿)
│ └── fr-right(右侧 AI 风险报告)
│ ├── fr-risk-tabs(风险等级筛选 Tab
│ └── 10 张 risk-card
│ ├── 🔴 合同期限 6 年 → 建议 3 年
│ ├── 🔴 试用期 6 个月 → 违法,建议 3 个月
│ ├── 🔴 随时调岗调薪 → 败诉率 87%,建议协商一致
│ ├── 🟡 试用期工资 70% → 低于法定 80%
│ ├── 🟡 不定时工作制未审批
│ ├── 🟡 竞业限制 3 年无补偿 → 建议 2 年 + 月补 30%
│ ├── 🟡 绩效工资"自行决定"过于宽泛
│ ├── 🟢 录用条件未明确 → 建议附说明书
│ └── 🟢 违约金 5 万 → 建议优化
└── fr-foot(底部律师兜底提示)
├── AI 审查仅供参考,重要合同请律师终审
├── 采纳所有建议预估降低风险 ¥56,000+
└── [历史版本] [请律师终审]
```
**交互特性**
- 点击右侧风险卡片 → 左侧原文自动滚动定位并闪烁高亮
- 点击"一键采纳" → 原文被替换为绿色标记的建议文本
- 风险等级 Tab 筛选(全部/高/中/低)
---
#### PAGE 8: 合规中心 `#page-compliance`(行 4194-4652
**定位**:一站式合规健康度管理 — AI 持续巡检
**布局结构**
```
├── cc-top(顶部综合健康度)
│ ├── SVG 环形评分 78(中等风险)
│ ├── 较上月 ↑ +5 分 · 最近巡检 1 小时前
│ └── [历史趋势] [立即发起 AI 全面巡检]
├── cc-dimensions5 维评分卡)
│ ├── 合同管理 78(黄)- 3 件待办
│ ├── 规章制度 65(红)- 4 件待办·紧急
│ ├── 考勤工时 82(绿)- 5 件待办
│ ├── 薪酬工资 85(绿)- 0 件待办
│ └── 社保公积金 88(绿)- 0 件待办
├── 本周合规待办(12 件,按 P0/P1/P2 排序)
│ ├── 🔴 P0:4 项制度未民主公示(预估降低 ¥18.6 万风险)
│ ├── 🔴 P0:张三入职未签合同(30 天内)
│ ├── 🟡 P1:3 名员工合同 30 天内到期
│ ├── 🟡 P1:5 名员工考勤未签字
│ └── 🟢 P2:17 份文件未经 AI 审查
└── Tab 工作区(5 个 Tab)
├── 合同管理:47 份合同表格(员工/类型/期限/状态/AI审改/风险/操作)
├── 制度公示:12 项制度(民主程序进度条 4 步:起草→讨论→协商→公示)
├── 考勤工时:4 指标卡 + AI 检测发现 + 一键发起签字
├── 员工档案:4 指标卡(完整度/背调/培训/竞业)
└── 文本模板:6 类模板卡片(劳动合同/违纪通知/解除通知/协商协议/保密竞业/规章制度)
```
**制度公示进度条**
- 4 步流程:起草 → 职工讨论 → 平等协商 → 公示
- `progress-step`done/current/未开始)+ `progress-line`done/未完成)
---
#### PAGE 9: 电子签中心 `#page-esign`(行 4653-4860
**定位**:客户咨询 TOP 1 功能
**布局结构**
```
├── es-stats4 指标)
│ ├── 5 待签合同
│ ├── 23 本月已签
│ ├── 2.3 min 平均签署用时
│ └── 100% 证据链合规率
├── es-quickactions3 快捷入口)
│ ├── 发起新签(最常用)- AI 预审 + 自动催签
│ ├── 从模板签 - 12 套法律模板
│ └── 语音发起 - 语音指令自动调起
├── es-list-card(待签合同列表 5 项)
│ ├── 张三劳动合同(⚠ 已 AI 审出 3 处高风险)→ [先 AI 审改]
│ ├── 王红保密协议(✓ AI 已通过)→ 1/2 签 → [催签]
│ ├── 李华协商解除(✓ AI 已通过)→ 0/2 签 → [发起]
│ ├── 考勤制度公示确认书(47 人)→ [批量发起]
│ └── 10 月工资条(42/47 已签)→ [催 5 人]
└── es-capabilitiesAI 增强能力 4 项)
├── 签前 AI 审查
├── 证据链 5 维留痕(时间戳/IP/设备/生物/区块链)
├── 多渠道签署(PC/App/小程序/短信)
└── 智能催签(6h/24h/3天 多频率)
```
---
#### PAGE 10: 申请草稿 `#page-apply-saved`(行 4861-4885
- 筛选:申请项下拉 + 关键词搜索
- 表格:序号/申请项/接收人/保存时间/状态(草稿)/操作(继续编辑/删除)
- 3 条示例数据
---
#### PAGE 11: 已发申请 `#page-apply-sent`(行 4890-4918
- 筛选:申请项/接收人/状态/发起时间范围
- 表格:序号/申请项/接收人/发起时间/状态/签署状态/操作(详情/撤销)
- 5 条示例数据,含进行中/已撤销/已完成状态
---
#### PAGE 12: 入职管理 `#page-onboard`(行 4919-4950
- 筛选:姓名/身份证号
- 表格:姓名/性别/手机号/身份证号/员工状态(在职/待入职)/操作
- 8 条示例数据
---
#### PAGE 13: 合同管理 `#page-contract`(行 4955-4994
- 统计条:全部 125 / 劳动合同 73 / 劳务合同 7 / 非全日制 0 / 其他协议 45 / 试用期即将到期 0 / 劳动合同即将到期 0
- 筛选:合同类型/合同状态/创建时间
- 表格:合同名称/类型/归属人/起始/到期/状态/操作(查看/下载/删除)
---
#### PAGE 14: 考勤确认 `#page-attendance`(行 4999-5030
- 使用说明:仅支持标准模板 / 员工在小程序确认
- 筛选:姓名 + 日期范围
- 表格:姓名/员工确认情况(已反馈/已读)/发布时间/操作
---
#### PAGE 15: 工资条发布 `#page-payslip`(行 5035-5072
- 使用说明:标准模板 / 小程序查看 / 定时发送可取消
- 筛选:姓名 + 日期范围
- 操作:[定时发送记录] [发布工资条]
- 表格:姓名/标题/确认情况(未读/已读/已反馈)/发布状态/发布时间/操作
---
#### PAGE 16: 规章制度民主程序 `#page-democracy`(行 5077-5157
- 使用说明:上传到企业文本库后发起 / 按部门发布 / 半数无异议视为通过
- 发起表单:选择文件/标题/表决人员(全体或按部门)/截止日期/说明
- Tab 切换:民主记录 / 公示记录
- 民主记录表格:序号/文本名称/发起时间/表决人员/民主状态/操作(详情/发起公示)
- 公示记录表格:序号/文本名称/公示开始/公示结束/状态/操作
---
#### PAGE 17: 企业文本库 `#page-enterprise-text`(行 5162-5199
- 使用说明:新建企业文本 / 参考模板库 / 当前仅支持规章制度类型
- 筛选:文本名称/文本类型
- 表格:文本编号/名称/类型/创建人/创建时间/操作(预览/民主公示/更多)
---
#### PAGE 18: 用工文本模板库 `#page-template-text`(行 5204-5236
- 使用说明:法律团队审核,遵循最新劳动法规
- 筛选:分类/关键词
- 表格:模板名称/分类/更新时间/说明/操作(预览/下载/引用为企业文本)
- 8 个模板:员工手册/固定期限劳动合同/非全日制/劳务合同/培训服务协议/保密竞业/解除通知书/离职证明
---
#### PAGE 19: 用工体检诊断 `#page-health-check`(行 5241-5301
- 英雄区:年度合规体检 + 诊断报告说明
- 左栏:6 个诊断维度卡片(合同管理/规章制度/社会保险/背景调查/工作时间/保密竞业)+ [开始诊断]
- 右栏:3 份年度诊断报告列表(2025/2024/2023),含分数和风险等级
---
#### PAGE 20: 背景调查 `#page-bgcheck`(行 5306-5417
- 英雄区:温馨提示 + 免费 22 次 / 付费 0 次
- Tab:发起背调 / 背调记录
- 发起背调:
- 5 项背调内容:身份信息/不良记录/吸毒记录/诉讼记录/网贷黑名单
- 候选人信息表单 + 额度说明 + 知情同意
- 背调记录:筛选 + 表格(序号/候选人/身份证/订单状态/创建时间/操作)
---
#### PAGE 21: 视频中心 `#page-video`(行 5422-5500
- 搜索:关键词 + 分类下拉(8 类)
- 8 个视频课程卡片:
- 模块一:招聘与试用期风险管理(28 分钟)
- 模块二:培训与服务期风控管理(32 分钟)
- 模块三:保密与竞业限制风控管理(26 分钟)
- 模块四:调岗调薪风险管理(24 分钟)
- 模块五:规章制度民主程序(30 分钟)
- 模块六:考勤与加班管理(22 分钟)
- 模块七:解除与离职管理(35 分钟)
- 专题:仲裁应对实务专题(48 分钟)
---
#### PAGE 22: 五险一金计算器 `#page-tool-shebao`(行 5505-5573
- 使用说明:按城市最新缴费比例计算
- 表单:缴纳城市/是否社保/社保类型/社保基数/工伤保险/是否公积金/公积金比例/公积金基数
- [立即计算] [重置] 按钮
- 计算结果区域(空状态提示)
---
#### PAGE 23: 医疗期计算器 `#page-tool-medical`(行 5578-5629
- 表单:所在地区(上海/全国)/入职日期/截止日期/累计病休天数
- [立即计算] [重置] 按钮
- 计算规则 & 法律依据(3 条法规原文)
---
#### PAGE 24: 通知管理 `#page-notice`(行 5634-5662
- 筛选:类型(系统/合同到期/试用期/民主程序/背调结果)/状态/关键词
- [全部标为已读] 按钮
- 表格:类型/标题/接收人/发送时间/状态/操作
---
#### PAGE 25: 用户管理 `#page-user-mgmt`(行 5667-5712
- 左右布局:组织架构树 + 用户列表
- 组织架构:公司 → 部门 → 子部门(2 级展开)
- 筛选:关键词/状态
- 表格:复选框/账号/姓名/性别/手机/状态/操作(编辑/更多)
---
#### PAGE 26: 系统日志 `#page-syslog`(行 5717-5747
- 筛选:操作人/操作类型/模块/时间范围
- 表格:操作时间/操作人/账号/操作类型/模块/操作内容/IP
- 1,247 条记录,125 页分页
---
## 四、JavaScript 交互逻辑(行 5761-6366
### 4.1 页面切换机制
```javascript
function switchPage(pid) {
// 1. 清除所有 nav-item / nav-sub 的 active
// 2. 匹配 data-page 的导航项加 active
// 3. 展开所在分组
// 4. 切换 page 的 active 类
// 5. 滚动到顶部
}
```
- 一级导航 `nav-item[data-page]` 点击切换
- 二级导航 `nav-sub[data-page]` 点击切换(`stopPropagation`
- 分组头 `nav-group-head` 点击折叠/展开
- 默认仅"白小侠 AI"分组展开
### 4.2 Tab 切换逻辑
| Tab 位置 | 切换逻辑 |
|----------|---------|
| 合规中心 | `cc-tab[data-tab]` → 切换 `cc-panel``active` |
| 民主程序 | `data-democracy-tab` → 切换 `democracy-panel``display` |
| 背景调查 | `data-bg-tab` → 切换 `bg-panel``display` |
| 风险报告 | `fr-risk-tab` → 切换 `active` + 筛选风险卡片 |
### 4.3 文件审查左右联动
- **点击风险卡片** → 左侧原文 `scrollIntoView({behavior:'smooth'})` + `boxShadow` 闪烁 1.5s
- **点击"一键采纳"** → 原文高亮 `<span>` 被替换为绿色 `✓ 建议文本`,卡片半透明,按钮变为"已采纳"
### 4.4 后端 API 对接
#### API 基础配置
```javascript
var API_BASE = 'http://127.0.0.1:8000';
```
#### 4.4.1 白小侠对话(SSE 流式)
| 项目 | 内容 |
|------|------|
| 端点 | `POST /api/chat` |
| 请求体 | `{ message, user_id, chat_id }` |
| 响应 | SSE 流,每事件含 `content.parts[]` |
| 事件解析 | 按 `\n\n` 分割事件,提取 `data:` 行 JSON |
| 渲染 | `aiText.textContent = full` 实时更新 |
| 错误处理 | 连接失败显示提示信息 |
| 转专家 | `POST /api/transfer_expert` → 返回 `ws_path` + `redis_ready` |
| 新对话 | 清空消息 + 重新生成 `chat_id` |
#### 4.4.2 判赔预测
| 项目 | 内容 |
|------|------|
| 端点 | `POST /api/case/predict` |
| 请求体 | `{ question, monthly_salary, work_years, area }` |
| 响应 | `{ success, options: [{ key, amount_min, amount_max }], estimated, area }` |
| 方案映射 | `forced_terminate` → 红卡 / `negotiate` → 黄卡 / `adjust` → 绿卡 |
| 金额展示 | 中位数 = `(min+max)/2`,区间用 `_moneyK()` 格式化 |
#### 4.4.3 文件审查
| 项目 | 内容 |
|------|------|
| 上传 | `POST /api/upload`FormData)→ `{ file_id, size_bytes, page_count }` |
| 审查 | `POST /api/contract/review`JSON)→ `{ success, risks[], summary, elapsed_ms }` |
| 导出 | `POST /api/contract/export`JSON)→ `{ success, original_text, replacements[] }` |
**风险对象结构**
```json
{
"risk_id": "r1",
"risk_level": "high|medium|low",
"clause_no": "第一条",
"clause_title": "合同期限",
"original_text": "合同期限为六年",
"highlight": "六年",
"risk_reason": "超长固定期限...",
"legal_basis": "《劳动合同法》第14条",
"replacement": "本合同期限为三年...",
"tag": "判例 12 起"
}
```
**渲染逻辑**
- `renderDoc(risks)`:左侧原文,每条款用 `hlText()` 高亮关键词
- `renderCards(risks)`:右侧风险卡片,含等级标签/原文/问题/建议/操作
- `setTabs(summary)`:风险等级筛选 Tab + 计数
- 点击卡片 → 左侧定位闪烁
- 导出 → 替换原文生成 `.txt` 下载
### 4.5 辅助函数
| 函数 | 用途 |
|------|------|
| `_esc(s)` | HTML 转义(防 XSS |
| `_money(n)` | 金额格式化 `¥12,000` |
| `_moneyK(n)` | 金额缩略 `¥12k` |
| `num(s)` | 从字符串提取数字 |
| `years(s)` | 从字符串提取工龄年数 |
### 4.6 其他交互
- **浮动 AI 按钮**hover 显示提示文字
- **AI 搜索框 placeholder**:每 3.5 秒轮播 5 个热门问题
- **业务卡片点击**:高亮选中 + alert 提示(原型演示)
- **申请草稿/已发申请跳转**`apply-jump[data-page]` 点击 `switchPage()`
- **光标闪烁动画**`@keyframes bxblink` + loader 旋转 `@keyframes bxspin`
---
## 五、设计模式与数据流总结
### 5.1 风险分级体系
| 等级 | 颜色 | 色值 | 使用场景 |
|------|------|------|---------|
| 极高/高 | 红 | `#C00000` | 合同未签/双倍工资风险/败诉率高 |
| 中 | 黄 | `#D97706` | 即将到期/制度未公示/绩效不达标 |
| 安全 | 绿 | `#16A34A` | 已签合同/已公示/合规 |
| 知识 | 紫 | `#6D28D9` | 行业判例/知识推荐 |
| 信息 | 蓝 | `#2E75B6` | 一般信息/操作按钮 |
### 5.2 AI 能力分层
```
AI 90% → 自动检测/建议/预测/审查
专家 9% → 转人工专家(复杂问题)
律师 1% → 仲裁应急/律师终审
```
### 5.3 核心业务流程
```
风险驾驶舱(首页)
├── AI 预警 → 点击"立即处理" → 跳转对应模块
├── AI 建议 → 点击操作按钮 → 跳转对应功能
└── 我要办理 → 点击业务卡片 → 打开起草表单
白小侠 AI 对话
├── 用户提问 → SSE 流式回复
├── 查看判例 → 判例详情
├── 生成协商方案 → 方案文档
├── 场景推演 → 判赔预测器
└── 转人工专家 → WebSocket 通道
判赔预测器
├── 选择场景 → 填写信息 → AI 预测
└── 三档方案对比 → 采用方案 → 执行步骤
文件 AI 审查
├── 上传文件 → AI 解析 → 风险报告
├── 查看原文高亮 → 一键采纳 → 替换文本
└── 导出修改版 → 下载 / 请律师终审
合规中心
├── AI 巡检 → 5 维评分 → 待办清单
├── 合同管理 → AI 审改 → 签署
├── 制度公示 → 民主程序 4 步 → 公示
└── 文本模板 → 引用为企业文本 → 民主公示
电子签中心
├── 发起新签 → AI 预审 → 催签
├── 从模板签 → 选择模板 → 填写 → 发起
└── 语音发起 → 语音识别 → 自动调起
```
---
## 六、与 React 实现的对应关系
| HTML 原型页面 | React 路由 | React 组件 | 实现状态 |
|-------------|-----------|-----------|---------|
| 风险驾驶舱 | `/` | `Dashboard.tsx` | ✅ 已实现(简化版) |
| 白小侠 AI 对话 | `/ai-assistant` | `AIAssistant.tsx` (chat tab) | ✅ 已实现 |
| 判赔预测器 | `/ai-assistant` | `AIAssistant.tsx` (predict tab) | ✅ 已实现 |
| 文件 AI 审查 | `/ai-assistant` | `AIAssistant.tsx` (review tab) | ✅ 已实现 |
| 全员风险地图 | `/roster` | `Roster.tsx` | ✅ 已实现(花名册) |
| 个人风险档案 | `/roster` | `Roster.tsx` (详情面板) | ✅ 已实现 |
| 合规中心 | — | — | ❌ 未独立实现 |
| 电子签中心 | — | — | ❌ 未实现 |
| 入职管理 | `/roster` | `Roster.tsx` (入职 Tab) | ✅ 集成在花名册 |
| 合同管理 | `/roster` | `Roster.tsx` (合同 Tab) | ✅ 集成在花名册 |
| 考勤确认 | — | — | ❌ 未实现 |
| 工资条发布 | `/money` | `Money.tsx` (payslip tab) | ✅ 已实现 |
| 规章制度民主程序 | — | — | ❌ 未实现 |
| 企业文本库 | — | — | ❌ 未实现 |
| 用工文本模板库 | — | — | ❌ 未实现 |
| 用工体检诊断 | — | — | ❌ 未实现 |
| 背景调查 | — | — | ❌ 未实现 |
| 视频中心 | — | — | ❌ 未实现 |
| 五险一金计算器 | `/social` | — | ⚠️ 路由存在,组件待确认 |
| 医疗期计算器 | — | — | ❌ 未实现 |
| 通知管理 | — | — | ❌ 未实现 |
| 用户管理 | `/settings` | — | ⚠️ 路由存在,组件待确认 |
| 系统日志 | — | — | ❌ 未实现 |
| 申请草稿/已发申请 | — | — | ❌ 未实现 |
---
## 七、关键发现与建议
### 7.1 原型亮点
1. **AI 驱动设计**:每个页面都嵌入 AI 能力(预警/建议/预测/审查/巡检)
2. **风险量化体系**:78 分健康度 + 7 维评分 + 金额区间预测,让抽象法律风险可视化
3. **三档方案对比**:判赔预测器的红/黄/绿三档方案 + 操作步骤,决策支持性强
4. **左右联动审查**:文件审查的原文高亮 ↔ 风险卡片联动 + 一键采纳,交互体验好
5. **价值量化报告**:年度 AI 价值报告将 SaaS 产品价值量化为 ROI(1,330%),利于续约
### 7.2 React 实现差距
- **未实现模块**:合规中心、电子签、民主程序、企业文本库、背景调查、视频中心、计算器工具等约 14 个模块
- **Dashboard 差异**:原型强调 AI 预警 + 风险评分 + AI 建议流,React 版偏重统计卡片 + 成本概览
- **导航差异**:原型用左侧 Sidebar 分组菜单(信息密度高),React 用顶部 Tab(6 个入口)
### 7.3 后端 API 对接状态
原型已完整对接 3 个核心 API
- `/api/chat`SSE 流式对话)
- `/api/case/predict`(判赔预测)
- `/api/upload` + `/api/contract/review` + `/api/contract/export`(文件审查)
React 前端使用 `api` 封装库统一调用,需确认上述 API 路径是否一致。
+787
View File
@@ -0,0 +1,787 @@
# TurboHR / 白话用工侠 系统功能详细分析报告
> 项目路径:`/Users/freedak/Documents/AIDashboard/UTMS/TurboHR/`
> 分析日期:2026-07-26
> 版本:v1.0
---
## 一、系统总体架构
### 1.1 技术栈
| 层级 | 技术选型 | 版本/说明 |
|------|---------|----------|
| 前端框架 | React 18 + TypeScript | Vite 构建,懒加载 + Suspense |
| 前端路由 | React Router v6 | 嵌套路由 + 认证守卫 |
| 状态管理 | Zustand + persist | localStorage 持久化 auth 状态 |
| 数据获取 | TanStack React Query | 缓存 + 自动刷新 + Mutation |
| UI 组件 | 自建组件库 | Card/Button/Input/Modal/Pagination/EmptyState/Skeleton/Signal/ConfirmDialog |
| 图标 | lucide-react | — |
| 图表 | recharts | 饼图等 |
| Markdown | react-markdown + remark-gfm + rehype-raw | AI 回复渲染 |
| 通知 | sonner | Toast 消息 |
| 后端框架 | Express 4 + TypeScript | tsx watch 热重载 |
| ORM | Prisma 5 | PostgreSQL |
| 数据库 | PostgreSQL | pgvector 扩展(RAG 向量检索) |
| 认证 | JWTjsonwebtoken | accessToken + refreshToken |
| 加密 | AES-256-CBC | 薪资/身份证/银行账号加密存储 |
| AI | OpenAI SDK → 阿里云 DashScope | qwen-plus / qwen-max 模型 |
| 文件上传 | multer | 内存/磁盘存储 |
| 数据导出 | ExcelJS + JSON | 支持 gzip 压缩 |
| 数据导入 | xlsx (SheetJS) | Excel 解析 + 身份证校验 |
| 安全 | helmet + cors + express-rate-limit | API 限流 |
| 日志 | morgan | HTTP 请求日志 |
| 压缩 | compression | SSE 流式响应排除压缩 |
### 1.2 项目目录结构
```
TurboHR/
├── backend/ # 后端(Express + Prisma
│ ├── prisma/
│ │ ├── schema.prisma # 数据模型定义(879 行,30+ 表/模型)
│ │ ├── seed.ts # 种子数据
│ │ ├── seed-wufang.ts # 五方科技演示数据
│ │ └── migrations/ # 数据库迁移
│ ├── src/
│ │ ├── app.ts # Express 应用配置 + 路由挂载
│ │ ├── index.ts # 启动入口
│ │ ├── routes/ # 15 个路由模块
│ │ ├── services/ # 8 个业务服务
│ │ ├── middleware/ # 5 个中间件
│ │ ├── schemas/ # 4 个 Zod 校验 schema
│ │ └── lib/ # 5 个工具库
│ └── package.json
├── frontend/ # 前端(React + Vite
│ ├── src/
│ │ ├── App.tsx # 路由定义 + 布局
│ │ ├── pages/ # 17 个页面组件
│ │ │ ├── auth/ # 认证页面(登录/注册/忘记密码)
│ │ │ └── portal/ # 员工端页面(5 个)
│ │ ├── components/ # 组件
│ │ │ ├── layout/ # 布局组件(TopNav/MobileTabBar/PageContainer
│ │ │ └── ui/ # UI 基础组件(9 个)
│ │ ├── hooks/ # 自定义 Hooks3 个)
│ │ ├── store/ # Zustand 状态管理
│ │ ├── lib/ # API 客户端
│ │ └── types/ # TypeScript 类型定义
│ └── package.json
├── docs/ # 文档
├── 0-req.md # 需求文档
├── 1-prd.md # 产品需求文档
├── 2-task.md # 任务拆解
└── 白话用工侠_测试页面.html # HTML 原型(6370 行)
```
### 1.3 系统角色
| 角色 | 端 | 说明 |
|------|-----|------|
| ADMIN | 管理端 | 系统管理员,全部权限 + 数据导出 |
| HR | 管理端 | HR 操作员,业务操作权限 |
| VIEWER | 管理端 | 只读查看者 |
| EMPLOYEE | 员工端 | 普通员工,查看工资条/合同/入职填报 |
### 1.4 套餐体系
| 套餐 | AI 问答 | 合同审查 | 案例匹配 | 最大员工数 |
|------|---------|---------|---------|-----------|
| FREE | 10 次/月 | 3 次/月 | 3 次/月 | 20 |
| PRO | 100 次/月 | 20 次/月 | 20 次/月 | 更多 |
| ENTERPRISE | 无限 | 无限 | 无限 | 无限 |
---
## 二、数据库模型详解(Prisma Schema
### 2.1 枚举定义(15 个)
| 枚举 | 值 | 用途 |
|------|-----|------|
| `Plan` | FREE / PRO / ENTERPRISE | 组织套餐等级 |
| `Role` | ADMIN / HR / VIEWER | 管理端用户角色 |
| `EmployeeStatus` | ACTIVE / RESIGNED | 员工状态 |
| `FemaleWorkerType` | CADRE / WORKER | 女性岗位类型(影响退休年龄) |
| `ContractType` | FIXED / UNFIXED / UNSIGNED / LABOR / INTERNSHIP | 合同类型 |
| `SignMethod` | PAPER / ELECTRONIC | 签署方式 |
| `RiskType` | CONTRACT / SALARY / TERMINATION / MONTHLY / ONBOARDING | 风险类型 |
| `RiskLevel` | HIGH / MEDIUM / LOW | 风险等级 |
| `RiskStatus` | PENDING / RESOLVED / IGNORED | 风险处理状态 |
| `PayrollBatchType` | REGULAR / TERMINATION / BONUS / SEVERANCE | 工资批次类型 |
| `PayrollBatchStatus` | DRAFT / ARCHIVED | 批次状态 |
| `PayslipItemType` | INPUT / CALCULATED | 工资条项类型 |
| `PayslipStatus` | PENDING / PUBLISHED | 工资条发布状态 |
| `TerminationReason` | NEGOTIATED / FAULT / NONFAULT / LAYOFF / EXPIRED / RESIGNATION | 解聘原因 |
| `RiskAssessment` | SAFE / WARNING / DANGER | 风险评估结果 |
| `OnboardingStatus` | PENDING / APPROVED / REJECTED / CANCELLED | 入职链接状态 |
| `ContractConfirmStatus` | UNCONFIRMED / CONFIRMED / EXPIRED | 合同确认状态 |
### 2.2 数据模型总览(30+ 个模型)
```
核心组织
├── Organization # 企业组织(套餐/城市/发薪频率/退休提醒)
├── User # 管理端用户(手机号/角色/禁用状态)
员工管理
├── Employee # 员工(AES 加密薪资/身份证/银行账号)
├── LaborContract # 劳动合同(类型/期限/试用期/续签/电子签)
├── OvertimeRecord # 加班记录(工作日/休息日/节假日)
├── DisciplinaryRecord # 违纪记录(类型/严重度/处理/签字确认)
├── AttendanceRecord # 考勤记录(打卡/迟到/早退/缺勤/加班)
├── TrainingRecord # 培训记录(制度培训/签收确认)
├── PerformanceRecord # 绩效记录(考核周期/得分/等级/改进计划)
├── EmployeeAttachment # 员工附件(身份证/银行卡/合同扫描件等)
├── SalaryChangeRecord # 薪酬变更记录(版本化,effectiveMonth/endMonth
├── EmployeeDepartmentRecord # 部门变更记录(版本化)
薪税管理
├── PayrollBatch # 工资批次(月/序号/类型/状态/汇总金额)
├── BatchEntry # 批次明细(每员工薪酬项 + 自动计算项 + 风险提示)
├── Payslip # 工资条(薪酬构成 + 累计预扣法 + 确认状态)
├── PayslipItem # 工资条模版项(预置 + 自定义,公式计算)
├── OvertimeConfig # 加班费配置(倍率/月计薪天数)
社保公积金
├── SocialInsuranceConfig # 社保配置(版本化,按城市,比例/基数上下限)
├── HousingFundConfig # 公积金配置(版本化,按城市)
├── EmployeeSocialInsRecord # 员工社保记录(版本化,startMonth/endMonth
├── EmployeeHousingFundRecord # 员工公积金记录(版本化)
├── SocialMonthlyProcess # 月度社保/公积金办理记录(快照)
解聘管理
├── TerminationRecord # 解聘/离职记录(状态机/补偿金明细/交接清单/审批)
风险管理
├── RiskItem # 风险待办(自动检测 + 手动创建)
├── AuditLog # 审计日志(操作/实体/详情/IP)
通知管理
├── NotificationSetting # 通知设置(合同到期/未签/加班/工资条/月度提醒日)
├── NotificationLog # 通知发送记录
员工端
├── OnboardingLink # 入职填报链接(token/状态/表单数据/过期时间)
├── ContractConfirmLink # 合同确认链接(token/验证码/签署证据链)
AI 能力
├── AIConversation # AI 会话记录(标题/消息列表)
├── AIReviewRecord # AI 审查/案例匹配记录
├── RagKnowledge # RAG 知识库(pgvector 向量检索)
├── RetirementPolicy # 退休政策版本管理(AI 解析/用户确认)
```
### 2.3 关键数据设计模式
| 模式 | 实现 | 涉及模型 |
|------|------|---------|
| **版本化快照** | `effectiveMonth` + `endMonth` + `isCurrent` | SocialInsuranceConfig, HousingFundConfig, SalaryChangeRecord, EmployeeSocialInsRecord, EmployeeHousingFundRecord, EmployeeDepartmentRecord |
| **敏感字段加密** | AES-256-CBC 对称加密 | Employee.monthlySalary, idCardNumber, bankAccount |
| **身份证哈希索引** | SHA-256 哈希用于去重查询 | Employee.idCardHash |
| **状态机** | status + currentStep 字段 | TerminationRecord (DRAFT→PENDING_APPROVAL→APPROVED→EXECUTING→COMPLETED) |
| **补偿金明细** | JSON 字段存储分项 + 调整记录 | TerminationRecord.compensationBreakdown |
| **合规检查覆盖** | JSON 字段记录人工覆盖原因 | TerminationRecord.checklistOverrides |
| **工作交接清单** | JSON 数组,每项含 done/remark | TerminationRecord.handoverItems |
| **月度办理快照** | JSON 存储办理时全量数据 | SocialMonthlyProcess.snapshot |
| **AI 消息历史** | JSON 数组 [{role, content}] | AIConversation.messages |
| **RAG 向量检索** | pgvector vector(1536) + ivfflat 索引 | RagKnowledge.embedding |
---
## 三、后端 API 详细分析
### 3.1 API 路由总览(15 个路由模块)
| 路由前缀 | 文件 | 行数 | 功能 |
|---------|------|------|------|
| `/api/v1/auth` | auth.routes.ts | 3445B | 注册/登录/刷新令牌 |
| `/api/v1/dashboard` | dashboard.routes.ts | 2865B | 仪表盘数据 + 待办操作 |
| `/api/v1/employees` | employee.routes.ts | 7334B | 员工 CRUD |
| `/api/v1/roster` | roster.routes.ts | 38878B | 花名册聚合 API(最大路由) |
| `/api/v1/termination` | termination.routes.ts | 10689B | 解聘/离职全流程 |
| `/api/v1/ai` | ai.routes.ts | 24384B | AI 问答/审查/案例/预测 |
| `/api/v1/portal` | portal.routes.ts | 16433B | 员工端全部接口 |
| `/api/v1/settings` | settings.routes.ts | 8574B | 企业/用户/退休政策 |
| `/api/v1/payroll` | payroll.routes.ts | 19655B | 加班费 + 工资条 |
| `/api/v1/payroll2` | payroll2.routes.ts | 26958B | 工资批次 + 薪酬模版 |
| `/api/v1/social` | social.routes.ts | 44930B | 社保公积金配置/记录/月度办理 |
| `/api/v1/notifications` | notification.routes.ts | 6436B | 通知设置/日志 |
| `/api/v1/attachments` | attachment.routes.ts | 1926B | 文件附件 |
| `/api/v1/export` | export.routes.ts | 10238B | 数据导出(JSON/Excel + 脱敏) |
| `/api/v1/import` | import.routes.ts | 34981B | Excel 批量导入 |
### 3.2 认证与安全
#### JWT 双 Token 机制
- **accessToken**:短期令牌,API 请求携带
- **refreshToken**:长期令牌,accessToken 过期后自动刷新
- **前端自动刷新**axios 拦截器 401 → 用 refreshToken 换新 accessToken → 重放原请求
#### 加密体系
```
AES-256-CBC 对称加密
├── Employee.monthlySalary → 月薪加密存储
├── Employee.idCardNumber → 身份证号加密存储
├── Employee.bankAccount → 银行账号加密存储
└── SHA-256 哈希
└── Employee.idCardHash → 身份证哈希(用于查重/匹配)
```
#### 中间件链
| 中间件 | 用途 |
|--------|------|
| `helmet` | 安全 HTTP 头 |
| `compression` | Gzip 压缩(排除 SSE |
| `cors` | 跨域(credentials: true |
| `express.json` | JSON 解析(10MB 限制) |
| `morgan` | HTTP 日志 |
| `apiLimiter` | API 限流 |
| `authMiddleware` | JWT 验证 |
| `requireAdmin` | ADMIN 角色校验 |
| `auditLog` | 操作审计日志 |
#### 员工端认证
- **密码登录**:手机号 + 密码(bcrypt 比对)
- **验证码登录**:手机号 + 验证码(60 秒频率限制,5 次错误锁定)
- **portalAuth 中间件**:验证 token role === 'EMPLOYEE'
### 3.3 核心业务服务(8 个 Service
#### 3.3.1 risk.service.ts568 行)— 风险自动检测引擎
| 函数 | 检测内容 |
|------|---------|
| `detectContractRisks()` | 合同风险:未签合同(>30天 HIGH / >365天 视为无固定期限)/ 即将到期(≤30天 MEDIUM)/ 已到期(HIGH/ 试用期超限(MEDIUM |
| `detectOnboardingRisks()` | 入职风险:入职未签合同(>30天 HIGH |
| `detectTerminationRisks()` | 解聘风险:孕期/哺乳期(HIGH)/ 医疗期(MEDIUM/ 工伤(HIGH |
| `detectMonthlyTasks()` | 月度任务:发薪日/社保日/公积金日/个税日 + 工资条生成提醒 |
| `runRiskDetection()` | 执行全量检测 + 去重写入 RiskItem |
| `getDashboardData()` | 仪表盘聚合:员工数/风险数/待办/人力成本/合同/解聘/违纪/考勤等 |
**风险检测触发时机**:每次请求 `/dashboard` 时自动执行 `runRiskDetection()`
#### 3.3.2 termination.service.ts811 行)— 解聘全流程
**6 步向导**
1. 选择员工
2. 解聘方式(7 种原因)
3. 合规检查(按原因动态生成 checklist + 系统自动判断)
4. 费用结算(补偿金计算 + 分项明细 + 调整记录)
5. 工作交接(6 项默认清单)
6. 确认提交
**状态机**
```
DRAFT → PENDING_APPROVAL → APPROVED → EXECUTING → COMPLETED
REJECTED
DRAFT/CANCELLED ← 撤回
```
**合规检查清单**(按解聘原因动态生成):
| 原因 | 检查项 |
|------|--------|
| 协商解除 | 支付补偿金 / 签署协议 / 结清工资 |
| 过错辞退 | 规章制度依据 / 违纪证据 / 通知工会 / 书面通知 |
| 非过错解除 | 医疗期满(系统自动判断)/ 培训或调岗(系统查记录)/ 支付补偿金 / 提前30天通知 |
| 经济性裁员 | 提前30天说明 / 听取意见 / 报告劳动部门 / 支付补偿金 |
| 合同到期 | 支付补偿金 / 提前通知 |
**系统自动判断能力**
- 医疗期状态 → 自动标记 `autoChecked: false`
- 培训记录 → 自动查 `trainingRecords` 判断是否经过培训
- 风险评估 → 孕期/工伤/医疗期 → DANGER 级别
**事务处理**:解聘创建在事务中执行 → 创建记录 + 关闭社保/公积金 + 更新员工状态 + 解决风险待办
#### 3.3.3 ai.service.ts237 行)— AI 核心服务
| 函数 | 模型 | 用途 |
|------|------|------|
| `chat()` | qwen-plus | 普通对话(含 RAG 检索 + 企业上下文) |
| `chatStream()` | qwen-plus | SSE 流式对话 |
| `reviewContract()` | qwen-max | 合同审查(结构化解析风险项 + 合规评分) |
| `matchCase()` | qwen-max | 案例匹配(相似案例 + 败诉风险 + 建议) |
| `predictRisks()` | qwen-plus | 风险预测(未来30天 + 优先级建议) |
| `predictRisksStream()` | qwen-plus | SSE 流式风险预测(Markdown 表格输出) |
**AI 上下文注入**
- `buildOrgContext()` 函数构建企业数据概览
- 包含:员工列表(姓名/部门/入职日期/工龄/合同状态/特殊状态)+ 当前风险项
- 支持按员工/部门/riskType 范围筛选
**RAG 知识库**
- 15 条预置法律条文(劳动合同法/劳动法/社会保险法)
- pgvector 向量检索(cosine 距离,ivfflat 索引)
- `searchKnowledge()` 检索 top-K 相关条文注入 system prompt
**用量控制**
- `checkUsageLimit()` 按套餐限制月度 AI 调用次数
- `recordUsage()` 写入 AuditLog 记录
#### 3.3.4 rag.service.ts98 行)— RAG 知识库
- 启动时自动初始化(`seedKnowledgeBase()`
- 自动建表 + 创建向量索引
- 首次启动注入 15 条法律条文种子数据
- 使用 `text-embedding-v2` 模型生成 1536 维向量
#### 3.3.5 retirement.service.ts260 行)— 退休计算
- 从身份证号提取出生日期 + 性别
- 渐进式延迟退休算法(2025-01-01 起改革)
- 男性:60→63 岁,每 4 个月延迟 1 个月
- 女性干部:55→58 岁,每 4 个月延迟 1 个月
- 女性工人:50→55 岁,每 2 个月延迟 1 个月
- AI 解析最新退休政策 + 用户确认 + 版本管理
#### 3.3.6 contract.service.ts23861B)— 合同服务
- 合同状态计算(`getContractStatus`
- 合同 CRUD + 续签
#### 3.3.7 payroll.service.ts15131B)— 薪酬计算
- 薪酬模版管理
- 批次明细计算(`calcBatchEntry`
- 社保/公积金/个税自动计算
- 工资条生成(`generatePayslipFromBatches`
- 风险提示(`getPayrollRiskWarnings`
#### 3.3.8 auth.service.ts3012B)— 认证服务
- 注册(创建 Organization + User
- 登录(bcrypt 验证 + JWT 签发)
- Token 刷新
### 3.4 员工端 APIportal.routes.ts
| 端点 | 方法 | 功能 |
|------|------|------|
| `/portal/login` | POST | 密码登录 |
| `/portal/send-code` | POST | 发送验证码(60s 频率限制) |
| `/portal/verify-code` | POST | 验证码登录(5 次错误锁定) |
| `/portal/payslip` | GET | 查看当月工资条 |
| `/portal/payslip/history` | GET | 最近 6 个月工资条 |
| `/portal/payslip/:id/confirm` | POST | 工资条确认已阅(记录 IP) |
| `/portal/contract` | GET | 查看我的合同 |
| `/portal/onboarding/:token` | GET | 获取入职填报信息 |
| `/portal/onboarding` | POST | 入职填报提交 |
| `/portal/onboarding/:token/upload` | POST | 入职文件上传(JPG/PNG/PDF/BMP10MB 限制) |
| `/portal/onboarding/:id/revoke` | POST | 撤回入职链接(HR 端) |
| `/portal/contract-confirm/:token` | GET | 获取合同确认信息 |
| `/portal/contract-confirm/send-code` | POST | 合同签署验证码 |
| `/portal/contract-confirm` | POST | 合同签署确认(验证码 + IP + UserAgent 证据链) |
| `/portal/contract-confirm/:id/resend` | POST | 重发合同确认链接(7 天有效期) |
### 3.5 数据导入导出
#### 导入(import.routes.ts614 行)
- Excel 上传(multer memoryStorage10MB 限制)
- 身份证号校验(18 位校验位算法 + 15 位自动升级 18 位)
- 社保基数范围校验(北京/上海/广州/深圳/杭州 上下限)
- 批量创建员工 + 合同 + 社保记录
#### 导出(export.routes.ts246 行)
- 格式:JSON / Excel
- 模块选择:employees/contracts/terminations/payrollBatches/payslips/socialRecords/housingRecords/riskItems
- 敏感字段脱敏:身份证 `138****1234` / 银行账号 `****1234`
- 非 ADMIN 角色强制脱敏
- 支持 gzip 压缩
---
## 四、前端页面详细分析
### 4.1 路由结构
```
管理端(ProtectedRoute + AdminLayout
├── / → Dashboard (总览)
├── /roster → Roster (员工花名册)
├── /money → Money (薪税管理)
├── /social → SocialInsurance(社保公积金)
├── /termination → Termination (解聘补偿)
├── /ai-assistant → AIAssistant AI 顾问)
└── /settings → Settings (系统设置)
员工端(PortalLayout,无认证守卫)
├── /portal/login → PortalLogin (员工登录)
├── /portal/payslip → Payslip (工资条)
├── /portal/contract → MyContract (我的合同)
├── /portal/onboarding → Onboarding (入职填报)
└── /portal/contract-confirm → ContractConfirm(合同签署确认)
认证页(PublicRoute
├── /login → Login
├── /register → Register
└── /forgot-password → ForgotPassword
```
### 4.2 布局组件
#### TopNav.tsx(桌面端顶部导航)
- 6 个导航 Tab:总览 / 员工管理 / 薪税 / 社保公积金 / 解聘补偿 / AI 顾问
- React Query 拉取 `/dashboard` 数据,60 秒自动刷新
- 风险计数 Badge(总览 Tab + 通知铃铛)
- 用户下拉菜单(设置 / 退出)
#### MobileTabBar.tsx(移动端底部 Tab
- 6 个 Tab:总览 / 员工 / 薪税 / 社保 / 解聘 / AI
- `md:hidden` 仅移动端显示
- 固定底部 `fixed bottom-0`
#### PageContainer.tsx
- 内容容器包装
#### OnboardingGuide.tsx
- 新手引导组件
### 4.3 各页面功能详解
---
#### 4.3.1 Dashboard.tsx654 行)— 总览仪表盘
**4 个 Tab**:概览 / 风险提醒 / 月度任务 / 薪酬
**概览 Tab**
- 4 统计卡片:在管员工数 / 高风险数 / 待办数 / 本月人力成本
- 当月人力成本卡片(应发/社保企业/公积金企业/实发)
- 年度人力成本卡片
- 合同到期预警列表
- 月度活动统计(合同签订/解聘/违纪/考勤/补偿金)
- 工资条明细
**风险提醒 Tab**
- 风险待办列表(按类型图标 + 级别排序)
- 单条 resolve / ignore
- 批量 resolve / ignore
- 分页处理
**月度任务 Tab**
- 月度任务列表(发薪/社保/公积金/个税/工资条)
**数据获取**
- `useQuery(['dashboard'])``/dashboard`(含风险检测 + 全量聚合数据)
- `useQuery(['expiring-contracts'])``/roster/contracts/expiring`
---
#### 4.3.2 Roster.tsx3840 行)— 员工花名册(最大页面)
**列表视图**
- 搜索(防抖 `useDebouncedValue`)+ 状态筛选(在职/预入职/离职)+ 合同状态筛选
- 表格列:姓名/部门/状态/合同/工龄/月薪/操作
- 分页
- 未保存变更检测(`useUnsavedChanges`
**详情面板(Tab 分组)**
| Tab | 内容 |
|-----|------|
| 人事信息 | 基本信息 / 合同信息(类型/期限/试用期/签署方式/续签) |
| 薪酬 | 薪酬记录 / 社保公积金记录(版本化时间轴) |
| 考勤绩效 | 考勤记录 / 培训记录 / 绩效记录 |
| 风险合规 | 违纪记录(类型/严重度/处理/签字) / 离职解聘 |
| 变更历史 | 薪酬变更 / 部门变更 |
**关键功能**
- 员工 CRUD + 身份证自动提取出生日期/性别
- 合同管理(签订/续签/电子签链接)
- 违纪记录管理
- 考勤记录管理
- 培训记录管理
- 绩效记录管理
- 附件上传
- 入职链接生成(token + 过期时间)
- 合同确认链接生成(token + 验证码签署)
- QR 码分享
---
#### 4.3.3 Money.tsx1671 行)— 薪税管理
**4 个 Tab**
| Tab | 组件 | 功能 |
|-----|------|------|
| 发薪批次 | BatchManager | 批次创建(常规/离职结算/奖金/补偿金)/ 复制上月 / 审批流 / 工资条明细 / 归档锁定 |
| 薪酬模版 | TemplateManager | 模版项管理(预置项 + 自定义项)/ 公式计算 / 排序 / 编辑 |
| 加班费计算 | OvertimeCalculator | 按月计算(工作日 1.5x / 休息日 2x / 节假日 3x)/ 关联批次 |
| 工资条管理 | PayslipManager | 从批次生成工资条 / 发布到员工端 / 确认状态追踪 |
**批次流程**
```
创建批次(DRAFT)→ 添加员工 → 编辑薪酬项 → 自动计算社保/公积金/个税
→ 风险提示 → 审批 → 归档(ARCHIVED,锁定)→ 生成工资条 → 发布到员工端
```
---
#### 4.3.4 SocialInsurance.tsx983 行)— 社保公积金
**3 个 Tab**
| Tab | 功能 |
|-----|------|
| 月度办理 | 按月查看社保/公积金办理状态 + 增减员 + 在保人员 + 缴费明细 + 办理记录快照 |
| 社保配置 | 按城市配置社保比例 + 基数上下限 + 版本管理 + 基数调整 |
| 公积金配置 | 按城市配置公积金比例 + 基数上下限 + 版本管理 + 基数调整 |
**版本管理**
- 新版本创建时旧版本自动 `isCurrent: false`
- 支持查看历史版本
- 基数调整(批量更新员工社保/公积金基数)
---
#### 4.3.5 Termination.tsx1778 行)— 解聘补偿
**3 个视图**
| 视图 | 功能 |
|------|------|
| 列表 | 解聘/离职记录列表 + 状态筛选 + 操作 |
| 向导 | 6 步解聘向导(选择员工 → 解聘方式 → 合规检查 → 费用结算 → 工作交接 → 确认提交) |
| 详情 | 解聘记录详情 + PDF 导出 |
**7 种解聘原因**
- 协商解除(第 36 条)
- 过错辞退(第 39 条)
- 非过错解除(第 40 条)
- 经济性裁员(第 41 条)
- 合同到期不续签(第 44 条/第 46 条)
- 违法解除(赔偿金 ×2,第 87 条)
- 员工主动离职(第 37 条)
**合规检查**
- 系统自动判断(医疗期/培训记录)
- 人工勾选 + 覆盖原因
- 风险评估(SAFE/WARNING/DANGER
**费用结算**
- 经济补偿金计算(N / N+1 / 2N)
- 补偿金分项明细(severance / noticePay / doublePay / other
- 调整记录(field / from / to / reason
**PDF 导出**:使用 jsPDF 生成解聘通知书
---
#### 4.3.6 AIAssistant.tsx1149 行)— AI 顾问
**5 个 Tab**
| Tab | 功能 | AI 模型 |
|-----|------|---------|
| 智能问答 | SSE 流式对话 + Markdown 渲染 + 快捷问题 + 历史会话 | qwen-plus |
| 风险预测 | 按员工/部门/风险类型筛选 + SSE 流式 + Markdown 表格输出 | qwen-plus |
| 合同审查 | 粘贴合同文本 → AI 逐条审查 + 风险分级 + 合规评分 | qwen-max |
| 案例匹配 | 输入争议情形 → AI 匹配相似案例 + 败诉风险 + 建议 | qwen-max |
| 知识库 | RAG 法律条文浏览 | — |
**通用功能**
- `useAIHistory` Hook:历史记录 CRUD`/ai/conversations`
- `HistoryBar` 组件:历史记录加载/删除
- 流式渲染:SSE 逐 token 输出
- 快捷问题按钮
---
#### 4.3.7 Settings.tsx1190 行)— 系统设置
**6 个 Section**
| Section | 功能 |
|---------|------|
| 企业信息 | 企业名称/城市/联系人/发薪频率/退休提醒开关 |
| 用户管理 | 管理端用户 CRUD + 角色分配(ADMIN/HR/VIEWER |
| 套餐 | 当前套餐 + 升级 |
| 通知设置 | 合同到期/未签/加班/工资条提醒 + 月度提醒日 + 微信 Webhook + 邮件 |
| 数据导入 | Excel 上传 + 身份证校验 + 社保基数校验 |
| 数据导出 | 模块选择 + 格式选择 + 脱敏选项 |
---
### 4.4 员工端页面(5 个)
| 页面 | 行数 | 功能 |
|------|------|------|
| PortalLogin.tsx | 5276B | 密码登录 + 验证码登录 |
| Payslip.tsx | 7714B | 当月工资条 + 6 个月历史 + 确认已阅 |
| MyContract.tsx | 5769B | 查看合同详情 + 电子签确认 |
| Onboarding.tsx | 8461B | 入职信息填报 + 文件上传(身份证/银行卡/学历等) |
| ContractConfirm.tsx | 6570B | 合同电子签署确认(验证码 + 证据链) |
### 4.5 自定义 Hooks
| Hook | 用途 |
|------|------|
| `useDebouncedValue` | 搜索防抖(300ms |
| `useUnsavedChanges` | 未保存变更检测 + 离开提示 |
| `useConfirm` | 二次确认对话框 |
### 4.6 UI 组件库(9 个)
| 组件 | 说明 |
|------|------|
| Button | 按钮组件(variant: primary/secondary/danger/ghost |
| Card | 卡片容器 |
| Input | 输入框 + Label + Select |
| Modal | 模态对话框 |
| Pagination | 分页组件 |
| EmptyState | 空状态占位 |
| Skeleton | 骨架屏加载 |
| Signal | 信号指示器 |
| ConfirmDialog | 确认对话框 |
---
## 五、已实现功能清单
### 5.1 核心业务功能
| 模块 | 功能点 | 实现状态 |
|------|--------|---------|
| **认证** | 手机号注册/登录/JWT 双 Token/自动刷新 | ✅ |
| **认证** | 员工端密码登录 + 验证码登录 | ✅ |
| **仪表盘** | 风险自动检测 + 待办列表 + 批量操作 | ✅ |
| **仪表盘** | 人力成本统计(月度/年度) | ✅ |
| **仪表盘** | 合同到期预警 | ✅ |
| **仪表盘** | 月度活动统计 | ✅ |
| **花名册** | 员工 CRUD + AES 加密敏感字段 | ✅ |
| **花名册** | 身份证号校验 + 自动提取出生日期/性别 | ✅ |
| **花名册** | 合同管理(签订/续签/电子签链接) | ✅ |
| **花名册** | 违纪记录 + 考勤记录 + 培训记录 + 绩效记录 | ✅ |
| **花名册** | 薪酬变更记录(版本化) | ✅ |
| **花名册** | 部门变更记录(版本化) | ✅ |
| **花名册** | 附件管理 | ✅ |
| **花名册** | 入职链接生成 + 员工自助填报 | ✅ |
| **花名册** | 合同确认链接 + 验证码签署 + 证据链 | ✅ |
| **薪税** | 工资批次管理(4 种类型) | ✅ |
| **薪税** | 批次复制上月 / 空白 / 指定批次 | ✅ |
| **薪税** | 薪酬模版(预置 + 自定义 + 公式计算) | ✅ |
| **薪税** | 加班费计算(3 种倍率) | ✅ |
| **薪税** | 社保/公积金/个税自动计算 | ✅ |
| **薪税** | 工资条生成 + 发布到员工端 | ✅ |
| **薪税** | 工资条确认追踪(IP + 时间戳) | ✅ |
| **薪税** | 累计预扣法个税计算 | ✅ |
| **薪税** | 批次归档锁定 | ✅ |
| **薪税** | 薪酬风险提示 | ✅ |
| **社保** | 社保配置(按城市 + 版本化) | ✅ |
| **社保** | 公积金配置(按城市 + 版本化) | ✅ |
| **社保** | 员工社保/公积金记录(版本化时间轴) | ✅ |
| **社保** | 月度办理(增减员 + 在保人员 + 缴费明细 + 快照) | ✅ |
| **社保** | 基数调整(批量更新) | ✅ |
| **解聘** | 6 步解聘向导 | ✅ |
| **解聘** | 7 种解聘原因 + 动态合规检查清单 | ✅ |
| **解聘** | 系统自动判断(医疗期/培训记录) | ✅ |
| **解聘** | 风险评估(孕期/工伤/医疗期) | ✅ |
| **解聘** | 补偿金计算(N/N+1/2N+ 分项明细 | ✅ |
| **解聘** | 工作交接清单 | ✅ |
| **解聘** | 审批流(草稿→待审批→审批→执行→完成) | ✅ |
| **解聘** | 撤回(未到日期可撤回) | ✅ |
| **解聘** | PDF 解聘通知书导出 | ✅ |
| **解聘** | 员工主动离职流程 | ✅ |
| **AI** | 智能问答(SSE 流式 + RAG 检索 + 企业上下文) | ✅ |
| **AI** | 合同审查(结构化风险项 + 合规评分) | ✅ |
| **AI** | 案例匹配(相似案例 + 败诉风险) | ✅ |
| **AI** | 风险预测(SSE 流式 + Markdown 表格) | ✅ |
| **AI** | 历史会话管理 | ✅ |
| **AI** | 用量控制(按套餐限制) | ✅ |
| **AI** | 案例转待办 | ✅ |
| **设置** | 企业信息管理 | ✅ |
| **设置** | 用户管理(CRUD + 角色) | ✅ |
| **设置** | 通知设置(多渠道 + 月度提醒日) | ✅ |
| **设置** | 退休政策管理(AI 解析 + 用户确认 + 版本管理) | ✅ |
| **设置** | 退休提醒(渐进式延迟退休计算) | ✅ |
| **数据** | Excel 批量导入(身份证校验 + 社保基数校验) | ✅ |
| **数据** | 数据导出(JSON/Excel + 脱敏 + gzip | ✅ |
| **员工端** | 工资条查看 + 确认 | ✅ |
| **员工端** | 合同查看 + 电子签确认 | ✅ |
| **员工端** | 入职自助填报 + 文件上传 | ✅ |
### 5.2 技术能力清单
| 能力 | 实现 |
|------|------|
| 多租户隔离 | orgId 贯穿所有查询 |
| JWT 双 Token | accessToken + refreshToken + 自动刷新 |
| AES-256 加密 | 薪资/身份证/银行账号 |
| SHA-256 哈希 | 身份证号查重 |
| API 限流 | express-rate-limit |
| 安全 HTTP 头 | helmet |
| SSE 流式 | AI 对话 + 风险预测 |
| RAG 向量检索 | pgvector + ivfflat + cosine 距离 |
| 审计日志 | AuditLog(操作/实体/详情/IP |
| 操作确认 | useConfirm Hook + ConfirmDialog |
| 搜索防抖 | useDebouncedValue |
| 未保存检测 | useUnsavedChanges |
| 懒加载 | React.lazy + Suspense |
| 骨架屏 | Skeleton 组件 |
| 响应式 | md:hidden 移动端 Tab |
| 批量操作 | 待办批量 resolve/ignore |
| 版本化数据 | 社保/公积金/薪酬/部门变更 |
| 状态机 | 解聘流程 7 状态 |
| 事务处理 | 解聘创建事务 |
| 数据脱敏 | 导出时身份证/银行账号脱敏 |
| 身份证校验 | 18 位校验位算法 + 15 位升级 |
| 渐进式延迟退休 | 2025 改革算法 |
---
## 六、与 HTML 原型的功能差距分析
| 原型页面 | React 实现 | 差距说明 |
|---------|-----------|---------|
| 风险驾驶舱 | Dashboard.tsx | 原型有 SVG 环形评分 + AI 建议卡片流 + 业务流程入口,React 版偏重统计卡片 |
| 白小侠 AI 对话 | AIAssistant (chat) | ✅ 基本对应,原型有转专家/约律师功能未实现 |
| 判赔预测器 | AIAssistant (predict) | 原型有 12 场景选择 + 三档方案卡片,React 版为 AI 自由文本输出 |
| 文件 AI 审查 | AIAssistant (review) | 原型有左右分栏 + 原文高亮 + 一键采纳,React 版为文本输入 + AI 文本输出 |
| 全员风险地图 | Roster.tsx | ✅ 花名册已实现,但无 7 维仲裁风险评分 + 风险标签 |
| 个人风险档案 | Roster.tsx (详情) | ✅ 员工详情已实现,但无 7 维评分趋势图 + 修复路径 |
| 年度 AI 价值报告 | — | ❌ 未实现 |
| 合规中心 | — | ❌ 未独立实现(功能分散在各页面) |
| 电子签中心 | — | ❌ 未独立实现(合同确认链接已实现,但无独立管理页) |
| 申请草稿/已发申请 | — | ❌ 未实现 |
| 入职管理 | Roster.tsx | ✅ 通过入职链接实现 |
| 合同管理 | Roster.tsx (合同 Tab) | ✅ 集成在花名册 |
| 考勤确认 | Roster.tsx (考勤 Tab) | ✅ 考勤记录已实现,但无员工端确认流程 |
| 工资条发布 | Money.tsx (payslip) | ✅ 已实现 |
| 规章制度民主程序 | — | ❌ 未实现 |
| 企业文本库 | — | ❌ 未实现 |
| 用工文本模板库 | — | ❌ 未实现 |
| 用工体检诊断 | — | ❌ 未实现 |
| 背景调查 | — | ❌ 未实现 |
| 视频中心 | — | ❌ 未实现 |
| 五险一金计算器 | SocialInsurance.tsx | ✅ 社保配置已实现,但无独立计算器工具 |
| 医疗期计算器 | — | ❌ 未实现 |
| 通知管理 | Settings.tsx (通知设置) | ✅ 通知设置已实现,但无独立通知管理页 |
| 用户管理 | Settings.tsx (用户管理) | ✅ 已实现 |
| 系统日志 | — | ❌ 未实现(AuditLog 有数据但无前端页面) |
---
## 七、系统架构亮点与不足
### 7.1 亮点
1. **AI 深度集成**:RAG 知识库 + 企业上下文注入 + SSE 流式输出,AI 不是简单聊天而是结合企业实际数据
2. **风险自动检测引擎**:每次访问 Dashboard 自动检测合同/入职/解聘/月度任务风险,去重写入待办
3. **版本化数据设计**:社保/公积金/薪酬/部门变更全部版本化,支持时间轴追溯
4. **解联合规引擎**:7 种解聘原因 × 动态合规清单 × 系统自动判断 × 风险评估 × 状态机审批流
5. **敏感数据加密**AES-256-CBC 加密薪资/身份证/银行账号 + SHA-256 哈希索引
6. **员工端自助**:入职填报 + 合同电子签 + 工资条查看,token 链接 + 验证码 + 证据链
7. **渐进式延迟退休**:精确实现 2025 年改革算法(男 60→63 / 女干部 55→58 / 女工人 50→55
### 7.2 不足
1. **原型差距大**:26 个页面模块仅实现约 60%,合规中心/电子签中心/民主程序/文本库/背景调查/视频中心等未实现
2. **AI 审查交互弱**:原型有左右分栏 + 原文高亮 + 一键采纳,React 版仅为文本输入输出
3. **判赔预测简化**:原型有 12 场景选择 + 三档方案卡片 + 金额区间,React 版为 AI 自由文本
4. **无风险评分体系**:原型有 78 分健康度 + 5 维评分 + 7 维员工风险评分,React 版仅有高/中/低三级
5. **AuditLog 无前端**:有审计日志数据模型但无系统日志查看页面
6. **通知无独立管理**:通知设置已实现但通知列表/已读管理未独立建页
7. **Roster.tsx 过大**3840 行单文件,应拆分为子组件
8. **验证码内存存储**`codeStore = new Map()` 生产环境应使用 Redis