feat: Sprint 1 — 设计Token系统 + 新导航5分组 + AppShell/PageHeader/FilterBar/DataTable组件 + 花名册导出扩充19字段 + 社保/公积金不缴纳选项 + 个税申报表导出 + 入离职/绩效统计看板

This commit is contained in:
selfrelease
2026-07-31 17:40:49 +08:00
parent c15e11ec22
commit 821a62e3f8
16 changed files with 3177 additions and 55 deletions
+138
View File
@@ -239,4 +239,142 @@ router.get('/workforce-stats', authMiddleware, async (req: AuthRequest, res: Res
}
})
// 入离职统计看板 — 按月聚合入职和离职人数
router.get('/turnover-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const months = parseInt(req.query.months as string) || 12
// 计算最近 N 个月的月份列表
const now = new Date()
const monthList: string[] = []
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
monthList.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
// 查询入职数据(按 hireDate 月份分组)
const startDate = new Date(monthList[0] + '-01')
const employees = await prisma.employee.findMany({
where: {
orgId,
OR: [
{ hireDate: { gte: startDate } },
{ status: 'RESIGNED' },
],
},
select: { id: true, name: true, hireDate: true, status: true, department: true },
})
// 查询离职记录
const terminations = await prisma.terminationRecord.findMany({
where: {
orgId,
terminationDate: { gte: startDate },
},
select: { employeeId: true, terminationDate: true, type: true, reason: true },
})
// 按月聚合
const monthlyData = monthList.map(month => {
const monthStart = new Date(month + '-01')
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1)
const hired = employees.filter(e => e.hireDate >= monthStart && e.hireDate < monthEnd).length
const left = terminations.filter(t => {
const td = t.terminationDate
return td && td >= monthStart && td < monthEnd
}).length
return { month, hired, left, net: hired - left }
})
// 汇总
const totalHired = monthlyData.reduce((s, m) => s + m.hired, 0)
const totalLeft = monthlyData.reduce((s, m) => s + m.left, 0)
const currentHeadcount = employees.filter(e => e.status !== 'RESIGNED').length
const avgHeadcount = currentHeadcount // 简化:使用当前人数
const turnoverRate = avgHeadcount > 0 ? (totalLeft / avgHeadcount * 100).toFixed(1) : '0'
res.json({
success: true,
data: {
monthly: monthlyData,
summary: {
totalHired,
totalLeft,
currentHeadcount,
turnoverRate: parseFloat(turnoverRate),
},
},
})
} catch (err) {
next(err)
}
})
// 绩效统计看板 — 按周期聚合绩效分布
router.get('/performance-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const period = (req.query.period as string) || new Date().toISOString().slice(0, 4) // 默认当前年
const records = await prisma.performanceRecord.findMany({
where: {
orgId,
period: { startsWith: period },
},
include: { employee: { select: { name: true, department: true } } },
orderBy: { createdAt: 'desc' },
})
// 按等级分布
const gradeDist: Record<string, number> = {}
// 按部门分布
const deptDist: Record<string, { count: number; avgScore: number; scores: number[] }> = {}
// 按周期分布
const periodDist: Record<string, { count: number; avgScore: number; scores: number[] }> = {}
for (const r of records) {
// 等级分布
const grade = r.grade || '未评级'
gradeDist[grade] = (gradeDist[grade] || 0) + 1
// 部门分布
const dept = r.employee?.department || '未分配'
if (!deptDist[dept]) deptDist[dept] = { count: 0, avgScore: 0, scores: [] }
deptDist[dept].count++
if (r.score) deptDist[dept].scores.push(r.score)
// 周期分布
if (!periodDist[r.period]) periodDist[r.period] = { count: 0, avgScore: 0, scores: [] }
periodDist[r.period].count++
if (r.score) periodDist[r.period].scores.push(r.score)
}
// 计算平均分
const calcAvg = (d: typeof deptDist) => Object.entries(d).map(([name, v]) => ({
name,
count: v.count,
avgScore: v.scores.length > 0 ? Math.round(v.scores.reduce((s, x) => s + x, 0) / v.scores.length * 10) / 10 : 0,
}))
const allScores = records.map(r => r.score).filter(Boolean) as number[]
const overallAvg = allScores.length > 0 ? Math.round(allScores.reduce((s, x) => s + x, 0) / allScores.length * 10) / 10 : 0
res.json({
success: true,
data: {
total: records.length,
overallAvgScore: overallAvg,
gradeDistribution: Object.entries(gradeDist).map(([name, value]) => ({ name, value })),
departmentDistribution: calcAvg(deptDist),
periodDistribution: calcAvg(periodDist),
},
})
} catch (err) {
next(err)
}
})
export default router