feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件) - 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录) - AI顾问新增人力报告Tab,支持流式生成+Word导出 - 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分 - 花名册/合同/解聘补偿新增部门和状态筛选 - 薪税管理新增工资表导入模板下载、银行代发CSV导出 - 社保公积金支持多公积金账户类型显示 - 数据导出新增花名册/解聘记录导出,中文文件名编码修复 - 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出 - 移除工作台日历卡片(已迁移至独立工作日历页面) - 新增20260728/20260729更新测试指导文档
This commit is contained in:
@@ -9,6 +9,12 @@ import { Writable } from 'stream'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// RFC 5987 编码中文文件名,兼容所有浏览器
|
||||
function contentDisposition(filename: string): string {
|
||||
const encoded = encodeURIComponent(filename)
|
||||
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
|
||||
}
|
||||
|
||||
// 敏感字段脱敏
|
||||
function maskIdCard(idCard: string | null): string | null {
|
||||
if (!idCard) return null
|
||||
@@ -87,7 +93,7 @@ router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: R
|
||||
}
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.xlsx"`)
|
||||
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.xlsx`))
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} else {
|
||||
@@ -95,10 +101,10 @@ router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: R
|
||||
if (useGzip) {
|
||||
res.setHeader('Content-Encoding', 'gzip')
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`)
|
||||
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.json.gz`))
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
|
||||
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.json`))
|
||||
}
|
||||
|
||||
const gzip = useGzip ? createGzip() : null
|
||||
@@ -234,7 +240,145 @@ router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, n
|
||||
totalRow.font = { bold: true }
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="payroll-${month}.xlsx"`)
|
||||
res.setHeader('Content-Disposition', contentDisposition(`薪税汇总-${month}.xlsx`))
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 导出花名册 Excel(支持筛选)
|
||||
router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const search = req.query.search as string | undefined
|
||||
const status = req.query.status as string | undefined
|
||||
const department = req.query.department as string | undefined
|
||||
const contractStatus = req.query.contractStatus as string | undefined
|
||||
|
||||
const where: any = { orgId }
|
||||
if (department) where.department = department
|
||||
if (status === 'RESIGNED') {
|
||||
where.status = 'RESIGNED'
|
||||
} else if (status === 'ACTIVE') {
|
||||
where.status = 'ACTIVE'
|
||||
}
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ department: { contains: search } },
|
||||
]
|
||||
}
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where,
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const ws = workbook.addWorksheet('花名册')
|
||||
ws.columns = [
|
||||
{ header: '姓名', key: 'name', width: 12 },
|
||||
{ header: '部门', key: 'department', width: 15 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '入职日期', key: 'hireDate', width: 12 },
|
||||
{ header: '合同起始', key: 'contractStart', width: 12 },
|
||||
{ header: '合同结束', key: 'contractEnd', width: 12 },
|
||||
{ header: '联系方式', key: 'phone', width: 15 },
|
||||
]
|
||||
ws.getRow(1).font = { bold: true }
|
||||
|
||||
for (const e of employees) {
|
||||
const contract = e.contracts[0]
|
||||
ws.addRow({
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
status: e.status === 'ACTIVE' ? '在职' : e.status === 'RESIGNED' ? '离职' : '预入职',
|
||||
hireDate: e.hireDate?.toISOString().slice(0, 10) || '',
|
||||
contractStart: contract?.startDate?.toISOString().slice(0, 10) || '',
|
||||
contractEnd: contract?.endDate?.toISOString().slice(0, 10) || '',
|
||||
phone: e.phone || '',
|
||||
})
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', contentDisposition(`花名册-${new Date().toISOString().slice(0, 10)}.xlsx`))
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 导出解聘记录 Excel(支持筛选)
|
||||
router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const status = req.query.status as string | undefined
|
||||
const department = req.query.department as string | undefined
|
||||
const search = req.query.search as string | undefined
|
||||
|
||||
const where: any = { orgId }
|
||||
if (status) where.status = status
|
||||
if (department || search) {
|
||||
where.employee = {}
|
||||
if (department) where.employee.department = department
|
||||
if (search) {
|
||||
where.employee.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ department: { contains: search } },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const records = await prisma.terminationRecord.findMany({
|
||||
where,
|
||||
include: { employee: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const ws = workbook.addWorksheet('解聘记录')
|
||||
ws.columns = [
|
||||
{ header: '员工姓名', key: 'name', width: 12 },
|
||||
{ header: '部门', key: 'department', width: 15 },
|
||||
{ header: '解聘类型', key: 'type', width: 12 },
|
||||
{ header: '解聘原因', key: 'reason', width: 20 },
|
||||
{ header: '解聘日期', key: 'terminationDate', width: 12 },
|
||||
{ header: '补偿金', key: 'compensation', width: 12 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '创建日期', key: 'createdAt', width: 12 },
|
||||
]
|
||||
ws.getRow(1).font = { bold: true }
|
||||
|
||||
const reasonLabels: Record<string, string> = {
|
||||
NEGOTIATED: '协商解除', FAULT: '过错解除', NONFAULT: '非过错解除',
|
||||
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除', RESIGNATION: '员工离职',
|
||||
}
|
||||
const statusLabels: Record<string, string> = {
|
||||
DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批',
|
||||
REJECTED: '已驳回', EXECUTING: '执行中', COMPLETED: '已完成', CANCELLED: '已撤销',
|
||||
}
|
||||
|
||||
for (const r of records) {
|
||||
ws.addRow({
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
type: r.type === 'TERMINATION' ? '解聘' : '离职',
|
||||
reason: reasonLabels[r.reason] || r.reason,
|
||||
terminationDate: r.terminationDate?.toISOString().slice(0, 10) || '',
|
||||
compensation: r.compensation || 0,
|
||||
status: statusLabels[r.status] || r.status,
|
||||
createdAt: r.createdAt?.toISOString().slice(0, 10) || '',
|
||||
})
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', contentDisposition(`解聘记录-${new Date().toISOString().slice(0, 10)}.xlsx`))
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user