feat: 预入职状态+入职手续待办
1. 花名册动态状态:hireDate > today 显示「预入职」(蓝色标签) 2. RiskType 增加 ONBOARDING 枚举 3. 新增 detectOnboardingRisks:入职日期到但未签合同→生成待办 4. detectContractRisks 排除预入职员工(未到入职日期不检查合同) 5. Dashboard 风险提醒 tab 支持 ONBOARDING 待办类型 6. 前端类型定义同步更新
This commit is contained in:
@@ -42,6 +42,7 @@ enum RiskType {
|
|||||||
SALARY
|
SALARY
|
||||||
TERMINATION
|
TERMINATION
|
||||||
MONTHLY
|
MONTHLY
|
||||||
|
ONBOARDING
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RiskLevel {
|
enum RiskLevel {
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
contractType: 'UNSIGNED',
|
contractType: 'UNSIGNED',
|
||||||
hireDate: e.hireDate,
|
hireDate: e.hireDate,
|
||||||
})
|
})
|
||||||
|
const isResigned = e.terminations.some((t) => t.terminationDate <= today)
|
||||||
|
const isPreHire = !isResigned && e.hireDate > today
|
||||||
return {
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
name: e.name,
|
name: e.name,
|
||||||
department: e.department,
|
department: e.department,
|
||||||
status: e.terminations.some((t) => t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE',
|
status: isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE'),
|
||||||
hasTermination: e.terminations.length > 0,
|
hasTermination: e.terminations.length > 0,
|
||||||
latestTerminationDate: e.terminations[0]?.terminationDate || null,
|
latestTerminationDate: e.terminations[0]?.terminationDate || null,
|
||||||
latestTerminationType: e.terminations[0]?.type || null,
|
latestTerminationType: e.terminations[0]?.type || null,
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ function daysBetween(a: Date, b: Date): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function detectContractRisks(orgId: string) {
|
export async function detectContractRisks(orgId: string) {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
const employees = await prisma.employee.findMany({
|
const employees = await prisma.employee.findMany({
|
||||||
where: { orgId, status: 'ACTIVE' },
|
where: { orgId, status: 'ACTIVE', hireDate: { lte: today } },
|
||||||
include: { contracts: { orderBy: { createdAt: 'desc' } } },
|
include: { contracts: { orderBy: { createdAt: 'desc' } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -98,6 +100,43 @@ export async function detectContractRisks(orgId: string) {
|
|||||||
return risks
|
return risks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预入职检查:入职日期已到但未签合同 → 待办
|
||||||
|
export async function detectOnboardingRisks(orgId: string) {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const employees = await prisma.employee.findMany({
|
||||||
|
where: { orgId, status: 'ACTIVE', hireDate: { lte: today } },
|
||||||
|
include: {
|
||||||
|
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||||
|
terminations: { where: { terminationDate: { lte: today } }, take: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||||
|
|
||||||
|
for (const emp of employees) {
|
||||||
|
// 已离职的跳过
|
||||||
|
if (emp.terminations.length > 0) continue
|
||||||
|
|
||||||
|
const latestContract = emp.contracts[0]
|
||||||
|
const hasSignedContract = latestContract && latestContract.contractType !== 'UNSIGNED'
|
||||||
|
|
||||||
|
if (!hasSignedContract) {
|
||||||
|
const daysSinceHire = daysBetween(today, emp.hireDate)
|
||||||
|
risks.push({
|
||||||
|
employeeId: emp.id,
|
||||||
|
type: 'ONBOARDING',
|
||||||
|
level: daysSinceHire > 30 ? 'HIGH' : 'MEDIUM',
|
||||||
|
title: `${emp.name}入职手续未完成${daysSinceHire > 30 ? `(已超${daysSinceHire}天)` : ''}`,
|
||||||
|
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},尚未签订劳动合同,请尽快完成入职手续。`,
|
||||||
|
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return risks
|
||||||
|
}
|
||||||
|
|
||||||
export async function detectTerminationRisks(orgId: string) {
|
export async function detectTerminationRisks(orgId: string) {
|
||||||
const employees = await prisma.employee.findMany({
|
const employees = await prisma.employee.findMany({
|
||||||
where: { orgId, status: 'ACTIVE' },
|
where: { orgId, status: 'ACTIVE' },
|
||||||
@@ -206,10 +245,11 @@ export async function runRiskDetection(orgId: string) {
|
|||||||
|
|
||||||
const contractRisks = await detectContractRisks(orgId)
|
const contractRisks = await detectContractRisks(orgId)
|
||||||
const terminationRisks = await detectTerminationRisks(orgId)
|
const terminationRisks = await detectTerminationRisks(orgId)
|
||||||
|
const onboardingRisks = await detectOnboardingRisks(orgId)
|
||||||
const monthlyTasks = await detectMonthlyTasks(orgId)
|
const monthlyTasks = await detectMonthlyTasks(orgId)
|
||||||
|
|
||||||
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
|
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
|
||||||
const nonMonthlyRisks = [...contractRisks, ...terminationRisks]
|
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks]
|
||||||
const toCreate = [
|
const toCreate = [
|
||||||
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.title}`)),
|
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.title}`)),
|
||||||
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert } from 'lucide-react'
|
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus } from 'lucide-react'
|
||||||
import api from '../lib/api'
|
import api from '../lib/api'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
@@ -18,6 +18,7 @@ const TODO_ICON_CONFIG: Record<string, { icon: typeof FileText; color: string; b
|
|||||||
SALARY: { icon: DollarSign, color: 'text-amber-600', bg: 'bg-amber-50' },
|
SALARY: { icon: DollarSign, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||||
TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
|
TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
|
||||||
MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' },
|
MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||||
|
ONBOARDING: { icon: UserPlus, color: 'text-cyan-600', bg: 'bg-cyan-50' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function TodoIcon({ type, level }: { type: string; level: string }) {
|
function TodoIcon({ type, level }: { type: string; level: string }) {
|
||||||
@@ -53,7 +54,7 @@ export default function Dashboard() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION') || []
|
const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'ONBOARDING') || []
|
||||||
const taskTodos = data?.todos.filter((t) => t.type === 'MONTHLY' || t.type === 'SALARY') || []
|
const taskTodos = data?.todos.filter((t) => t.type === 'MONTHLY' || t.type === 'SALARY') || []
|
||||||
const filteredTodos = activeTab === 'risk' ? riskTodos : taskTodos
|
const filteredTodos = activeTab === 'risk' ? riskTodos : taskTodos
|
||||||
|
|
||||||
|
|||||||
@@ -133,8 +133,12 @@ export default function Roster() {
|
|||||||
<td className="py-2 px-3 font-medium">{e.name}</td>
|
<td className="py-2 px-3 font-medium">{e.name}</td>
|
||||||
<td className="py-2 px-3 text-gray-500">{e.department}</td>
|
<td className="py-2 px-3 text-gray-500">{e.department}</td>
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${e.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||||
{e.status === 'ACTIVE' ? '在职' : '离职'}
|
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
|
||||||
|
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{e.status === 'ACTIVE' ? '在职' : e.status === 'PRE_HIRE' ? '预入职' : '离职'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
<td className="py-2 px-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export interface DashboardData {
|
|||||||
}
|
}
|
||||||
todos: {
|
todos: {
|
||||||
id: string
|
id: string
|
||||||
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY'
|
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING'
|
||||||
level: 'high' | 'medium' | 'low'
|
level: 'high' | 'medium' | 'low'
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -93,7 +93,7 @@ export interface DashboardData {
|
|||||||
}[]
|
}[]
|
||||||
resolvedTodos: {
|
resolvedTodos: {
|
||||||
id: string
|
id: string
|
||||||
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY'
|
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING'
|
||||||
level: 'high' | 'medium' | 'low'
|
level: 'high' | 'medium' | 'low'
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
|||||||
Reference in New Issue
Block a user