feat: 节假日配置、排班管理独立页面、考勤加班显示优化
- 新增 HolidayConfig 模型,支持法定节假日和调休工作日配置 - 加班费同步逻辑改用 HolidayConfig 判断日期类型 - 员工端考勤显示加班工时、费率和日期类型 - 周末/节假日出勤状态显示为"周末出勤"/"节假日出勤" - 新增 Employee.defaultShiftId 字段,支持长期排班(工作日班次) - 排班管理拆分为独立页面(班次管理+排班),考勤管理保留出勤相关功能 - 排班和每日出勤页面增加身份证号列 - 修复岗位和部门编辑失败问题(POST 改 PUT) - 新增 backfill 脚本:合同薪资回填、默认班次回填 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -166,6 +166,7 @@ model Organization {
|
|||||||
departmentRecords EmployeeDepartmentRecord[]
|
departmentRecords EmployeeDepartmentRecord[]
|
||||||
notificationSetting NotificationSetting?
|
notificationSetting NotificationSetting?
|
||||||
overtimeConfig OvertimeConfig?
|
overtimeConfig OvertimeConfig?
|
||||||
|
holidayConfigs HolidayConfig[]
|
||||||
notificationLogs NotificationLog[]
|
notificationLogs NotificationLog[]
|
||||||
employeeAttachments EmployeeAttachment[]
|
employeeAttachments EmployeeAttachment[]
|
||||||
disciplinaryRecords DisciplinaryRecord[]
|
disciplinaryRecords DisciplinaryRecord[]
|
||||||
@@ -273,6 +274,8 @@ model Employee {
|
|||||||
education String? // 学历(博士/硕士/本科/大专/高中/其他)
|
education String? // 学历(博士/硕士/本科/大专/高中/其他)
|
||||||
femaleWorkerType FemaleWorkerType? // 女性岗位类型(CADRE=干部/WORKER=工人,仅女性需要区分)
|
femaleWorkerType FemaleWorkerType? // 女性岗位类型(CADRE=干部/WORKER=工人,仅女性需要区分)
|
||||||
retirementDaysLeft Int? // 距退休天数(便捷字段,定期计算)
|
retirementDaysLeft Int? // 距退休天数(便捷字段,定期计算)
|
||||||
|
defaultShiftId String? // 默认班次ID(长期排班=工作日班次,null=未设置)
|
||||||
|
defaultShift Shift? @relation("EmployeeDefaultShift", fields: [defaultShiftId], references: [id], onDelete: SetNull)
|
||||||
createdBy String
|
createdBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -637,6 +640,20 @@ model OvertimeConfig {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 节假日配置(区分法定节假日和调休工作日)
|
||||||
|
model HolidayConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orgId String
|
||||||
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
date DateTime // 日期(仅取日期部分)
|
||||||
|
type String // HOLIDAY(法定节假日) | WORKDAY(调休工作日,即周末调休上班)
|
||||||
|
name String? // 节假日名称(如"春节"、"国庆节")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([orgId, date])
|
||||||
|
@@index([orgId, date])
|
||||||
|
}
|
||||||
|
|
||||||
model MedicalPeriodPolicy {
|
model MedicalPeriodPolicy {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
@@ -1332,6 +1349,7 @@ model Shift {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
assignments ShiftAssignment[]
|
assignments ShiftAssignment[]
|
||||||
|
defaultEmployees Employee[] @relation("EmployeeDefaultShift")
|
||||||
|
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 迁移脚本:将员工月工资复制到合同的 baseSalary
|
||||||
|
* 当合同 baseSalary=0 且 performanceSalary=0 时,用员工 monthlySalary 填充 baseSalary
|
||||||
|
* 月工资 = baseSalary + performanceSalary
|
||||||
|
*/
|
||||||
|
import prisma from '../src/lib/prisma'
|
||||||
|
import { decrypt } from '../src/lib/crypto'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const contracts = await prisma.laborContract.findMany({
|
||||||
|
where: {
|
||||||
|
baseSalary: 0,
|
||||||
|
performanceSalary: 0,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
employee: {
|
||||||
|
select: { monthlySalary: true, baseSalary: true, performanceSalary: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`找到 ${contracts.length} 个合同 baseSalary=0 且 performanceSalary=0`)
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
for (const contract of contracts) {
|
||||||
|
const emp = contract.employee
|
||||||
|
if (!emp?.monthlySalary) continue
|
||||||
|
|
||||||
|
let empBase = 0
|
||||||
|
let empPerf = 0
|
||||||
|
try {
|
||||||
|
empBase = emp.baseSalary ? Number(decrypt(emp.baseSalary)) || 0 : 0
|
||||||
|
empPerf = emp.performanceSalary ? Number(decrypt(emp.performanceSalary)) || 0 : 0
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 优先用员工的 baseSalary/performanceSalary
|
||||||
|
let newBase = empBase
|
||||||
|
let newPerf = empPerf
|
||||||
|
|
||||||
|
// 如果员工也没有拆分工资,用 monthlySalary 作为 baseSalary
|
||||||
|
if (newBase === 0 && newPerf === 0) {
|
||||||
|
let monthly = 0
|
||||||
|
try {
|
||||||
|
monthly = Number(decrypt(emp.monthlySalary)) || 0
|
||||||
|
} catch {}
|
||||||
|
if (monthly > 0) {
|
||||||
|
newBase = monthly
|
||||||
|
newPerf = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newBase > 0 || newPerf > 0) {
|
||||||
|
await prisma.laborContract.update({
|
||||||
|
where: { id: contract.id },
|
||||||
|
data: { baseSalary: newBase, performanceSalary: newPerf },
|
||||||
|
})
|
||||||
|
console.log(` ✓ 合同 ${contract.id} (员工 ${contract.employeeId}): base=${newBase}, perf=${newPerf}`)
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n完成:共更新 ${updated} 个合同`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 迁移脚本:为现有员工设置默认班次(长期排班=工作日班次)
|
||||||
|
* 每个员工的默认班次设为该组织的第一个班次
|
||||||
|
*/
|
||||||
|
import prisma from '../src/lib/prisma'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 查找所有没有默认班次的在职员工
|
||||||
|
const employees = await prisma.employee.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
defaultShiftId: null,
|
||||||
|
},
|
||||||
|
select: { id: true, orgId: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`找到 ${employees.length} 个员工未设置默认班次`)
|
||||||
|
|
||||||
|
// 按组织分组
|
||||||
|
const orgShifts = new Map<string, string>()
|
||||||
|
let updated = 0
|
||||||
|
|
||||||
|
for (const emp of employees) {
|
||||||
|
// 获取该组织的第一个班次(缓存)
|
||||||
|
let shiftId = orgShifts.get(emp.orgId)
|
||||||
|
if (!shiftId) {
|
||||||
|
const shift = await prisma.shift.findFirst({
|
||||||
|
where: { orgId: emp.orgId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
if (shift) {
|
||||||
|
shiftId = shift.id
|
||||||
|
orgShifts.set(emp.orgId, shiftId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiftId) {
|
||||||
|
await prisma.employee.update({
|
||||||
|
where: { id: emp.id },
|
||||||
|
data: { defaultShiftId: shiftId },
|
||||||
|
})
|
||||||
|
console.log(` ✓ 员工 ${emp.name} → 默认班次已设置`)
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
console.log(` ✗ 员工 ${emp.name} → 该组织无班次配置,跳过`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n完成:共更新 ${updated} 个员工`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
@@ -222,6 +222,48 @@ router.delete('/shift-assignments/:id', authMiddleware, async (req: AuthRequest,
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 设置员工默认班次(长期排班=工作日班次)
|
||||||
|
router.post('/default-shift', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { employeeId, shiftId } = req.body as { employeeId: string; shiftId: string | null }
|
||||||
|
if (!employeeId) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
|
||||||
|
|
||||||
|
// 验证 shiftId 存在且属于该 org(shiftId 为 null 时表示取消默认班次)
|
||||||
|
if (shiftId) {
|
||||||
|
const shift = await prisma.shift.findFirst({ where: { id: shiftId, orgId: req.user!.orgId } })
|
||||||
|
if (!shift) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '班次不存在' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.employee.update({
|
||||||
|
where: { id: employeeId },
|
||||||
|
data: { defaultShiftId: shiftId || null },
|
||||||
|
})
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量设置员工默认班次
|
||||||
|
router.post('/default-shift/batch', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { items } = req.body as { items: { employeeId: string; shiftId: string | null }[] }
|
||||||
|
if (!items || !Array.isArray(items)) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.shiftId) {
|
||||||
|
const shift = await prisma.shift.findFirst({ where: { id: item.shiftId, orgId: req.user!.orgId } })
|
||||||
|
if (!shift) continue
|
||||||
|
}
|
||||||
|
await prisma.employee.update({
|
||||||
|
where: { id: item.employeeId },
|
||||||
|
data: { defaultShiftId: item.shiftId || null },
|
||||||
|
})
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: { updated } })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
// ========== 每日出勤 ==========
|
// ========== 每日出勤 ==========
|
||||||
|
|
||||||
router.post('/manual-correct', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.post('/manual-correct', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
@@ -165,16 +165,34 @@ router.post('/overtime/sync-from-attendance', async (req: AuthRequest, res: Resp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 按员工汇总加班工时,按日期类型分类
|
// 按员工汇总加班工时,按日期类型分类
|
||||||
|
// 日期类型判断优先级:HolidayConfig > 周末判断
|
||||||
|
// HolidayConfig 中 HOLIDAY=法定节假日(3倍), WORKDAY=调休工作日(1.5倍)
|
||||||
|
const holidays = await prisma.holidayConfig.findMany({
|
||||||
|
where: { orgId, date: { gte: monthStart, lt: monthEnd } },
|
||||||
|
})
|
||||||
|
const holidayMap = new Map<string, string>() // dateStr -> type
|
||||||
|
for (const h of holidays) {
|
||||||
|
holidayMap.set(h.date.toISOString().slice(0, 10), h.type)
|
||||||
|
}
|
||||||
|
|
||||||
const empMap = new Map<string, { weekday: number; weekend: number; holiday: number }>()
|
const empMap = new Map<string, { weekday: number; weekend: number; holiday: number }>()
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
const day = new Date(r.date)
|
const dateStr = r.date.toISOString().slice(0, 10)
|
||||||
|
const day = new Date(dateStr + 'T00:00:00')
|
||||||
const dayOfWeek = day.getDay() // 0=周日, 6=周六
|
const dayOfWeek = day.getDay() // 0=周日, 6=周六
|
||||||
let type: 'weekday' | 'weekend' | 'holiday' = 'weekday'
|
let type: 'weekday' | 'weekend' | 'holiday' = 'weekday'
|
||||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
||||||
|
const holidayType = holidayMap.get(dateStr)
|
||||||
|
if (holidayType === 'HOLIDAY') {
|
||||||
|
// 法定节假日
|
||||||
|
type = 'holiday'
|
||||||
|
} else if (holidayType === 'WORKDAY') {
|
||||||
|
// 调休工作日(周末调休上班),按工作日算
|
||||||
|
type = 'weekday'
|
||||||
|
} else if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||||
|
// 普通周末
|
||||||
type = 'weekend'
|
type = 'weekend'
|
||||||
}
|
}
|
||||||
// 简单判断法定节假日:这里使用周末判断,实际法定节假日需要额外配置
|
|
||||||
// 如果有 holidayHours 字段在 attendanceRecord 中,优先使用
|
|
||||||
|
|
||||||
if (!empMap.has(r.employeeId)) {
|
if (!empMap.has(r.employeeId)) {
|
||||||
empMap.set(r.employeeId, { weekday: 0, weekend: 0, holiday: 0 })
|
empMap.set(r.employeeId, { weekday: 0, weekend: 0, holiday: 0 })
|
||||||
@@ -473,6 +491,167 @@ router.post('/overtime/config', async (req: AuthRequest, res: Response, next: Ne
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ========== 节假日配置 ==========
|
||||||
|
|
||||||
|
// 获取节假日列表(支持按年份筛选)
|
||||||
|
router.get('/holidays', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
const year = req.query.year as string | undefined
|
||||||
|
const where: any = { orgId }
|
||||||
|
if (year) {
|
||||||
|
const start = new Date(`${year}-01-01`)
|
||||||
|
const end = new Date(`${year}-12-31`)
|
||||||
|
end.setDate(end.getDate() + 1)
|
||||||
|
where.date = { gte: start, lt: end }
|
||||||
|
}
|
||||||
|
const holidays = await prisma.holidayConfig.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
})
|
||||||
|
res.json({ success: true, data: holidays })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量保存节假日(覆盖该年配置)
|
||||||
|
router.post('/holidays/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
const { year, items } = req.body as { year: string; items: { date: string; type: string; name?: string }[] }
|
||||||
|
if (!year || !/^\d{4}$/.test(year)) {
|
||||||
|
return res.status(400).json({ success: false, message: '请提供有效的年份' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除该年旧数据
|
||||||
|
const yearStart = new Date(`${year}-01-01`)
|
||||||
|
const yearEnd = new Date(`${year}-12-31`)
|
||||||
|
yearEnd.setDate(yearEnd.getDate() + 1)
|
||||||
|
await prisma.holidayConfig.deleteMany({
|
||||||
|
where: { orgId, date: { gte: yearStart, lt: yearEnd } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量插入新数据
|
||||||
|
if (items && items.length > 0) {
|
||||||
|
await prisma.holidayConfig.createMany({
|
||||||
|
data: items.map(item => ({
|
||||||
|
orgId,
|
||||||
|
date: new Date(item.date),
|
||||||
|
type: item.type,
|
||||||
|
name: item.name || null,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: { count: items?.length || 0 } })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 预置法定节假日(按年份自动填充国务院发布的节假日)
|
||||||
|
router.post('/holidays/preset', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const orgId = req.user!.orgId
|
||||||
|
const { year } = req.body as { year: string }
|
||||||
|
if (!year || !/^\d{4}$/.test(year)) {
|
||||||
|
return res.status(400).json({ success: false, message: '请提供有效的年份' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026年法定节假日配置(国务院发布)
|
||||||
|
const presetData: Record<string, { holidays: { date: string; name: string }[]; workdays: { date: string; name: string }[] }> = {
|
||||||
|
'2026': {
|
||||||
|
holidays: [
|
||||||
|
// 元旦
|
||||||
|
{ date: '2026-01-01', name: '元旦' },
|
||||||
|
// 春节
|
||||||
|
{ date: '2026-02-15', name: '春节' },
|
||||||
|
{ date: '2026-02-16', name: '春节' },
|
||||||
|
{ date: '2026-02-17', name: '春节' },
|
||||||
|
{ date: '2026-02-18', name: '春节' },
|
||||||
|
{ date: '2026-02-19', name: '春节' },
|
||||||
|
{ date: '2026-02-20', name: '春节' },
|
||||||
|
{ date: '2026-02-21', name: '春节' },
|
||||||
|
// 清明节
|
||||||
|
{ date: '2026-04-04', name: '清明节' },
|
||||||
|
{ date: '2026-04-05', name: '清明节' },
|
||||||
|
{ date: '2026-04-06', name: '清明节' },
|
||||||
|
// 劳动节
|
||||||
|
{ date: '2026-05-01', name: '劳动节' },
|
||||||
|
{ date: '2026-05-02', name: '劳动节' },
|
||||||
|
{ date: '2026-05-03', name: '劳动节' },
|
||||||
|
{ date: '2026-05-04', name: '劳动节' },
|
||||||
|
{ date: '2026-05-05', name: '劳动节' },
|
||||||
|
// 端午节
|
||||||
|
{ date: '2026-06-19', name: '端午节' },
|
||||||
|
{ date: '2026-06-20', name: '端午节' },
|
||||||
|
{ date: '2026-06-21', name: '端午节' },
|
||||||
|
// 中秋节
|
||||||
|
{ date: '2026-09-25', name: '中秋节' },
|
||||||
|
{ date: '2026-09-26', name: '中秋节' },
|
||||||
|
{ date: '2026-09-27', name: '中秋节' },
|
||||||
|
// 国庆节
|
||||||
|
{ date: '2026-10-01', name: '国庆节' },
|
||||||
|
{ date: '2026-10-02', name: '国庆节' },
|
||||||
|
{ date: '2026-10-03', name: '国庆节' },
|
||||||
|
{ date: '2026-10-04', name: '国庆节' },
|
||||||
|
{ date: '2026-10-05', name: '国庆节' },
|
||||||
|
{ date: '2026-10-06', name: '国庆节' },
|
||||||
|
{ date: '2026-10-07', name: '国庆节' },
|
||||||
|
],
|
||||||
|
workdays: [
|
||||||
|
// 春节调休
|
||||||
|
{ date: '2026-02-14', name: '春节调休' },
|
||||||
|
{ date: '2026-02-22', name: '春节调休' },
|
||||||
|
// 劳动节调休
|
||||||
|
{ date: '2026-04-26', name: '劳动节调休' },
|
||||||
|
// 国庆节调休
|
||||||
|
{ date: '2026-09-27', name: '国庆节调休' }, // 注意:9-27也是中秋,需确认
|
||||||
|
{ date: '2026-10-10', name: '国庆节调休' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = presetData[year]
|
||||||
|
if (!preset) {
|
||||||
|
return res.status(400).json({ success: false, message: `暂无 ${year} 年预置节假日数据,请手动配置` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除该年旧数据
|
||||||
|
const yearStart = new Date(`${year}-01-01`)
|
||||||
|
const yearEnd = new Date(`${year}-12-31`)
|
||||||
|
yearEnd.setDate(yearEnd.getDate() + 1)
|
||||||
|
await prisma.holidayConfig.deleteMany({
|
||||||
|
where: { orgId, date: { gte: yearStart, lt: yearEnd } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 插入法定节假日
|
||||||
|
const holidayData = preset.holidays.map(h => ({
|
||||||
|
orgId, date: new Date(h.date), type: 'HOLIDAY', name: h.name,
|
||||||
|
}))
|
||||||
|
// 插入调休工作日
|
||||||
|
const workdayData = preset.workdays.map(w => ({
|
||||||
|
orgId, date: new Date(w.date), type: 'WORKDAY', name: w.name,
|
||||||
|
}))
|
||||||
|
// 去重(同一日期可能既是中秋又是调休)
|
||||||
|
const allData = [...holidayData, ...workdayData]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const deduped = allData.filter(d => {
|
||||||
|
const key = d.date.toISOString().slice(0, 10)
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.holidayConfig.createMany({ data: deduped })
|
||||||
|
|
||||||
|
res.json({ success: true, data: { holidays: preset.holidays.length, workdays: deduped.length - preset.holidays.length } })
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ========== 批量导入加班工时 ==========
|
// ========== 批量导入加班工时 ==========
|
||||||
|
|
||||||
const batchOvertimeSchema = z.array(
|
const batchOvertimeSchema = z.array(
|
||||||
|
|||||||
@@ -257,7 +257,24 @@ router.get('/contract', portalAuth, async (req: any, res, next) => {
|
|||||||
if (!contract) {
|
if (!contract) {
|
||||||
return res.json({ success: true, data: null })
|
return res.json({ success: true, data: null })
|
||||||
}
|
}
|
||||||
res.json({ success: true, data: contract })
|
// 补充员工工资结构(合同中 baseSalary/performanceSalary 为 0 时作为 fallback)
|
||||||
|
const employee = await prisma.employee.findFirst({
|
||||||
|
where: { id: req.employee.id },
|
||||||
|
select: { monthlySalary: true, baseSalary: true, performanceSalary: true },
|
||||||
|
})
|
||||||
|
const empBase = employee?.baseSalary ? Number(decrypt(employee.baseSalary)) || 0 : 0
|
||||||
|
const empPerf = employee?.performanceSalary ? Number(decrypt(employee.performanceSalary)) || 0 : 0
|
||||||
|
const empMonthly = employee?.monthlySalary ? Number(decrypt(employee.monthlySalary)) || 0 : 0
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...contract,
|
||||||
|
// 合同中工资为 0 时用员工档案的工资补齐
|
||||||
|
baseSalary: contract.baseSalary || empBase,
|
||||||
|
performanceSalary: contract.performanceSalary || empPerf,
|
||||||
|
monthlySalary: empMonthly,
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
@@ -730,7 +747,54 @@ router.get('/attendance', portalAuth, async (req: any, res, next) => {
|
|||||||
},
|
},
|
||||||
orderBy: { date: 'asc' },
|
orderBy: { date: 'asc' },
|
||||||
})
|
})
|
||||||
res.json({ success: true, data: { published: true, records, title: publish.title } })
|
|
||||||
|
// 查询节假日配置,判断每日日期类型和加班费率
|
||||||
|
const holidays = await prisma.holidayConfig.findMany({
|
||||||
|
where: { orgId: req.employee.orgId, date: { gte: startDate, lt: endDate } },
|
||||||
|
})
|
||||||
|
const holidayMap = new Map<string, string>() // dateStr -> type
|
||||||
|
for (const h of holidays) {
|
||||||
|
holidayMap.set(h.date.toISOString().slice(0, 10), h.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取加班费配置
|
||||||
|
const otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId: req.employee.orgId } })
|
||||||
|
const rates = {
|
||||||
|
weekday: otConfig?.weekdayRate ?? 1.5,
|
||||||
|
weekend: otConfig?.weekendRate ?? 2.0,
|
||||||
|
holiday: otConfig?.holidayRate ?? 3.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每条记录附加日期类型和加班费率
|
||||||
|
const enrichedRecords = records.map(r => {
|
||||||
|
const dateStr = r.date.toISOString().slice(0, 10)
|
||||||
|
const day = new Date(dateStr + 'T00:00:00')
|
||||||
|
const dayOfWeek = day.getDay()
|
||||||
|
const holidayType = holidayMap.get(dateStr)
|
||||||
|
|
||||||
|
let dateType: 'weekday' | 'weekend' | 'holiday' = 'weekday'
|
||||||
|
let overtimeRate = rates.weekday
|
||||||
|
|
||||||
|
if (holidayType === 'HOLIDAY') {
|
||||||
|
dateType = 'holiday'
|
||||||
|
overtimeRate = rates.holiday
|
||||||
|
} else if (holidayType === 'WORKDAY') {
|
||||||
|
dateType = 'weekday'
|
||||||
|
overtimeRate = rates.weekday
|
||||||
|
} else if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||||
|
dateType = 'weekend'
|
||||||
|
overtimeRate = rates.weekend
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
dateType,
|
||||||
|
overtimeRate,
|
||||||
|
hasOvertime: (r.overtimeHours || 0) > 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ success: true, data: { published: true, records: enrichedRecords, title: publish.title, overtimeRates: rates } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,8 +335,8 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
|||||||
performanceRecords: { orderBy: { period: 'desc' } },
|
performanceRecords: { orderBy: { period: 'desc' } },
|
||||||
terminations: { orderBy: { createdAt: 'desc' } },
|
terminations: { orderBy: { createdAt: 'desc' } },
|
||||||
attachments: true,
|
attachments: true,
|
||||||
socialInsRecords: { orderBy: { startMonth: 'desc' } },
|
socialInsRecords: { orderBy: { startMonth: 'desc' }, include: { account: { select: { id: true, name: true, city: true } } } },
|
||||||
housingFundRecords: { orderBy: { startMonth: 'desc' } },
|
housingFundRecords: { orderBy: { startMonth: 'desc' }, include: { account: { select: { id: true, name: true, city: true } } } },
|
||||||
salaryChanges: { orderBy: { effectiveDate: 'desc' } },
|
salaryChanges: { orderBy: { effectiveDate: 'desc' } },
|
||||||
departmentRecords: { orderBy: { effectiveMonth: 'desc' } },
|
departmentRecords: { orderBy: { effectiveMonth: 'desc' } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1022,6 +1022,8 @@ router.post('/housing-calculate', async (req: AuthRequest, res: Response, next:
|
|||||||
configVersion: config.effectiveFrom,
|
configVersion: config.effectiveFrom,
|
||||||
housingOrg,
|
housingOrg,
|
||||||
housingEmp,
|
housingEmp,
|
||||||
|
orgRate: config.housingOrg,
|
||||||
|
empRate: config.housingEmp,
|
||||||
total: housingOrg + housingEmp,
|
total: housingOrg + housingEmp,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
|
import { decrypt } from '../lib/crypto'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 考勤确认服务
|
* 考勤确认服务
|
||||||
@@ -207,7 +208,8 @@ export async function getShiftAssignments(orgId: string, date: string) {
|
|||||||
const nextDay = new Date(day)
|
const nextDay = new Date(day)
|
||||||
nextDay.setDate(nextDay.getDate() + 1)
|
nextDay.setDate(nextDay.getDate() + 1)
|
||||||
|
|
||||||
return prisma.shiftAssignment.findMany({
|
// 查询当天排班记录
|
||||||
|
const assignments = await prisma.shiftAssignment.findMany({
|
||||||
where: { orgId, date: { gte: day, lt: nextDay } },
|
where: { orgId, date: { gte: day, lt: nextDay } },
|
||||||
include: {
|
include: {
|
||||||
employee: { select: { id: true, name: true, department: true } },
|
employee: { select: { id: true, name: true, department: true } },
|
||||||
@@ -215,6 +217,57 @@ export async function getShiftAssignments(orgId: string, date: string) {
|
|||||||
},
|
},
|
||||||
orderBy: { employee: { name: 'asc' } },
|
orderBy: { employee: { name: 'asc' } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 查询所有在职员工的默认班次(长期排班)
|
||||||
|
const employees = await prisma.employee.findMany({
|
||||||
|
where: { orgId, status: 'ACTIVE' },
|
||||||
|
select: { id: true, name: true, department: true, idCardNumber: true, defaultShiftId: true, defaultShift: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 合并:有当天排班记录的用排班记录,没有的 fallback 到默认班次
|
||||||
|
// 如果当天排班和默认班次相同,也标记为 isDefault(视为长期排班)
|
||||||
|
const assignmentMap = new Map(assignments.map(a => [a.employeeId, a]))
|
||||||
|
const merged = employees.map(emp => {
|
||||||
|
// 解密身份证号
|
||||||
|
let idCardNumber: string | null = null
|
||||||
|
try { idCardNumber = emp.idCardNumber ? (emp.idCardNumber.includes(':') ? decrypt(emp.idCardNumber) : emp.idCardNumber) : null } catch { idCardNumber = null }
|
||||||
|
|
||||||
|
const explicit = assignmentMap.get(emp.id)
|
||||||
|
if (explicit) {
|
||||||
|
// 当天排班和默认班次相同,视为长期排班
|
||||||
|
const sameAsDefault = emp.defaultShiftId && explicit.shiftId === emp.defaultShiftId
|
||||||
|
return {
|
||||||
|
...explicit,
|
||||||
|
isDefault: !!sameAsDefault,
|
||||||
|
employee: { ...explicit.employee, idCardNumber },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有当天排班,使用默认班次(长期排班)
|
||||||
|
if (emp.defaultShift) {
|
||||||
|
return {
|
||||||
|
id: `default-${emp.id}`,
|
||||||
|
employeeId: emp.id,
|
||||||
|
shiftId: emp.defaultShiftId,
|
||||||
|
shift: emp.defaultShift,
|
||||||
|
date: day,
|
||||||
|
isDefault: true,
|
||||||
|
employee: { id: emp.id, name: emp.name, department: emp.department, idCardNumber },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无排班
|
||||||
|
return {
|
||||||
|
id: `none-${emp.id}`,
|
||||||
|
employeeId: emp.id,
|
||||||
|
shiftId: null,
|
||||||
|
shift: null,
|
||||||
|
date: day,
|
||||||
|
isDefault: false,
|
||||||
|
employee: { id: emp.id, name: emp.name, department: emp.department, idCardNumber },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function batchAssignShifts(orgId: string, userId: string, items: Array<{
|
export async function batchAssignShifts(orgId: string, userId: string, items: Array<{
|
||||||
@@ -334,7 +387,7 @@ export async function getDailyAttendance(orgId: string, date: string) {
|
|||||||
}),
|
}),
|
||||||
prisma.employee.findMany({
|
prisma.employee.findMany({
|
||||||
where: { orgId, status: 'ACTIVE' },
|
where: { orgId, status: 'ACTIVE' },
|
||||||
select: { id: true, name: true, department: true },
|
select: { id: true, name: true, department: true, idCardNumber: true, defaultShiftId: true, defaultShift: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -344,11 +397,17 @@ export async function getDailyAttendance(orgId: string, date: string) {
|
|||||||
|
|
||||||
return employees.map(emp => {
|
return employees.map(emp => {
|
||||||
const record = recordMap.get(emp.id)
|
const record = recordMap.get(emp.id)
|
||||||
const shift = shiftMap.get(emp.id)
|
// 优先使用当天排班,没有则 fallback 到默认班次
|
||||||
|
const shift = shiftMap.get(emp.id) || emp.defaultShift
|
||||||
|
// 解密身份证号
|
||||||
|
let idCardNumber: string | null = null
|
||||||
|
try { idCardNumber = emp.idCardNumber ? (emp.idCardNumber.includes(':') ? decrypt(emp.idCardNumber) : emp.idCardNumber) : null } catch { idCardNumber = null }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
employeeId: emp.id,
|
employeeId: emp.id,
|
||||||
name: emp.name,
|
name: emp.name,
|
||||||
department: emp.department,
|
department: emp.department,
|
||||||
|
idCardNumber,
|
||||||
shift: shift ? { name: shift.name, startTime: shift.startTime, endTime: shift.endTime, color: shift.color } : null,
|
shift: shift ? { name: shift.name, startTime: shift.startTime, endTime: shift.endTime, color: shift.color } : null,
|
||||||
checkInTime: record?.checkInTime || null,
|
checkInTime: record?.checkInTime || null,
|
||||||
checkOutTime: record?.checkOutTime || null,
|
checkOutTime: record?.checkOutTime || null,
|
||||||
|
|||||||
@@ -401,6 +401,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
|||||||
// 默认密码:手机号后6位(员工可在员工端自行修改)
|
// 默认密码:手机号后6位(员工可在员工端自行修改)
|
||||||
const defaultPassword = data.phone ? data.phone.slice(-6) : '123456'
|
const defaultPassword = data.phone ? data.phone.slice(-6) : '123456'
|
||||||
const passwordHash = await bcrypt.hash(defaultPassword, 10)
|
const passwordHash = await bcrypt.hash(defaultPassword, 10)
|
||||||
|
// 查找该组织的第一个班次作为默认排班(长期=工作日班次)
|
||||||
|
const firstShift = await tx.shift.findFirst({ where: { orgId }, orderBy: { createdAt: 'asc' } })
|
||||||
const emp = await tx.employee.create({
|
const emp = await tx.employee.create({
|
||||||
data: {
|
data: {
|
||||||
orgId,
|
orgId,
|
||||||
@@ -420,6 +422,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
|||||||
isPregnant: data.isPregnant || false,
|
isPregnant: data.isPregnant || false,
|
||||||
isInMedicalPeriod: data.isInMedicalPeriod || false,
|
isInMedicalPeriod: data.isInMedicalPeriod || false,
|
||||||
isWorkInjured: data.isWorkInjured || false,
|
isWorkInjured: data.isWorkInjured || false,
|
||||||
|
defaultShiftId: firstShift?.id || null,
|
||||||
socialInsBase,
|
socialInsBase,
|
||||||
housingFundBase,
|
housingFundBase,
|
||||||
socialInsStartMonth,
|
socialInsStartMonth,
|
||||||
@@ -1027,6 +1030,25 @@ export async function addContract(orgId: string, userId: string, data: any) {
|
|||||||
throw { code: 'DUPLICATE', message: '该员工已存在相同日期的合同,请勿重复添加' }
|
throw { code: 'DUPLICATE', message: '该员工已存在相同日期的合同,请勿重复添加' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工资结构:baseSalary 为 0 时,用员工档案月工资填充
|
||||||
|
let contractBase = Number(data.baseSalary) || 0
|
||||||
|
let contractPerf = Number(data.performanceSalary) || 0
|
||||||
|
if (contractBase === 0 && contractPerf === 0) {
|
||||||
|
const emp = await prisma.employee.findFirst({
|
||||||
|
where: { id: data.employeeId, orgId },
|
||||||
|
select: { monthlySalary: true, baseSalary: true, performanceSalary: true },
|
||||||
|
})
|
||||||
|
if (emp) {
|
||||||
|
try {
|
||||||
|
contractBase = emp.baseSalary ? Number(decrypt(emp.baseSalary)) || 0 : 0
|
||||||
|
contractPerf = emp.performanceSalary ? Number(decrypt(emp.performanceSalary)) || 0 : 0
|
||||||
|
} catch {}
|
||||||
|
if (contractBase === 0 && contractPerf === 0) {
|
||||||
|
try { contractBase = Number(decrypt(emp.monthlySalary)) || 0 } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const contractMonths = data.endDate
|
const contractMonths = data.endDate
|
||||||
? Math.ceil(daysBetween(new Date(data.endDate), new Date(data.startDate)) / 30.44)
|
? Math.ceil(daysBetween(new Date(data.endDate), new Date(data.startDate)) / 30.44)
|
||||||
: data.contractYears * 12
|
: data.contractYears * 12
|
||||||
@@ -1048,8 +1070,8 @@ export async function addContract(orgId: string, userId: string, data: any) {
|
|||||||
contractYears: data.contractYears || 3,
|
contractYears: data.contractYears || 3,
|
||||||
probationMonths: data.probationMonths || 0,
|
probationMonths: data.probationMonths || 0,
|
||||||
probationSalary: data.probationSalary || 0,
|
probationSalary: data.probationSalary || 0,
|
||||||
baseSalary: data.baseSalary || 0,
|
baseSalary: contractBase,
|
||||||
performanceSalary: data.performanceSalary || 0,
|
performanceSalary: contractPerf,
|
||||||
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
||||||
attachmentUrl: data.attachmentUrl || null,
|
attachmentUrl: data.attachmentUrl || null,
|
||||||
electronicContractNo: data.electronicContractNo || null,
|
electronicContractNo: data.electronicContractNo || null,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const Settings = lazyRetry(() => import('./pages/Settings'))
|
|||||||
const Evidence = lazyRetry(() => import('./pages/Evidence'))
|
const Evidence = lazyRetry(() => import('./pages/Evidence'))
|
||||||
const Policies = lazyRetry(() => import('./pages/Policies'))
|
const Policies = lazyRetry(() => import('./pages/Policies'))
|
||||||
const Attendance = lazyRetry(() => import('./pages/Attendance'))
|
const Attendance = lazyRetry(() => import('./pages/Attendance'))
|
||||||
|
const Schedule = lazyRetry(() => import('./pages/Schedule'))
|
||||||
const Templates = lazyRetry(() => import('./pages/Templates'))
|
const Templates = lazyRetry(() => import('./pages/Templates'))
|
||||||
const AuditLog = lazyRetry(() => import('./pages/AuditLog'))
|
const AuditLog = lazyRetry(() => import('./pages/AuditLog'))
|
||||||
const Notifications = lazyRetry(() => import('./pages/Notifications'))
|
const Notifications = lazyRetry(() => import('./pages/Notifications'))
|
||||||
@@ -207,6 +208,7 @@ export default function App() {
|
|||||||
<Route path="/evidence" element={<ProtectedRoute><AdminLayout><Evidence /></AdminLayout></ProtectedRoute>} />
|
<Route path="/evidence" element={<ProtectedRoute><AdminLayout><Evidence /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/policies" element={<ProtectedRoute><AdminLayout><Policies /></AdminLayout></ProtectedRoute>} />
|
<Route path="/policies" element={<ProtectedRoute><AdminLayout><Policies /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/attendance" element={<ProtectedRoute><AdminLayout><Attendance /></AdminLayout></ProtectedRoute>} />
|
<Route path="/attendance" element={<ProtectedRoute><AdminLayout><Attendance /></AdminLayout></ProtectedRoute>} />
|
||||||
|
<Route path="/schedule" element={<ProtectedRoute><AdminLayout><Schedule /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/calendar" element={<ProtectedRoute><AdminLayout><CalendarPage /></AdminLayout></ProtectedRoute>} />
|
<Route path="/calendar" element={<ProtectedRoute><AdminLayout><CalendarPage /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/templates" element={<ProtectedRoute><AdminLayout><Templates /></AdminLayout></ProtectedRoute>} />
|
<Route path="/templates" element={<ProtectedRoute><AdminLayout><Templates /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/audit" element={<ProtectedRoute><AdminLayout><AuditLog /></AdminLayout></ProtectedRoute>} />
|
<Route path="/audit" element={<ProtectedRoute><AdminLayout><AuditLog /></AdminLayout></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||||
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
||||||
DollarSign, Clock,
|
DollarSign, Clock, Calendar,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { settingsApi } from '../../lib/api-services'
|
import { settingsApi } from '../../lib/api-services'
|
||||||
|
|
||||||
@@ -55,7 +55,8 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
|
{ path: '/attendance', label: '考勤管理', icon: CalendarCheck },
|
||||||
|
{ path: '/schedule', label: '排班管理', icon: Calendar },
|
||||||
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
|
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -316,6 +316,12 @@ export const attendanceApi = {
|
|||||||
/** 删除排班 */
|
/** 删除排班 */
|
||||||
removeAssignment: (id: string) =>
|
removeAssignment: (id: string) =>
|
||||||
del(`/attendance/shift-assignments/${id}`),
|
del(`/attendance/shift-assignments/${id}`),
|
||||||
|
/** 设置员工默认班次(长期排班) */
|
||||||
|
setDefaultShift: (employeeId: string, shiftId: string | null) =>
|
||||||
|
post('/attendance/default-shift', { employeeId, shiftId }),
|
||||||
|
/** 批量设置员工默认班次 */
|
||||||
|
batchSetDefaultShift: (items: { employeeId: string; shiftId: string | null }[]) =>
|
||||||
|
post('/attendance/default-shift/batch', { items }),
|
||||||
/** 创建请假记录 */
|
/** 创建请假记录 */
|
||||||
createLeave: (data: Record<string, unknown>) =>
|
createLeave: (data: Record<string, unknown>) =>
|
||||||
post('/attendance/leaves', data),
|
post('/attendance/leaves', data),
|
||||||
@@ -502,6 +508,15 @@ export const payrollApi = {
|
|||||||
/** 保存加班费配置 */
|
/** 保存加班费配置 */
|
||||||
saveOvertimeConfig: (data: Record<string, unknown>) =>
|
saveOvertimeConfig: (data: Record<string, unknown>) =>
|
||||||
post('/payroll/overtime/config', data),
|
post('/payroll/overtime/config', data),
|
||||||
|
/** 获取节假日配置列表 */
|
||||||
|
holidays: (year?: string) =>
|
||||||
|
get('/payroll/holidays', { params: year ? { year } : {} }).then(unwrap<any[]>()),
|
||||||
|
/** 批量保存节假日配置 */
|
||||||
|
saveHolidays: (data: { year: string; items: { date: string; type: string; name?: string }[] }) =>
|
||||||
|
post('/payroll/holidays/batch', data).then(unwrap<any>()),
|
||||||
|
/** 预置法定节假日 */
|
||||||
|
presetHolidays: (year: string) =>
|
||||||
|
post('/payroll/holidays/preset', { year }).then(unwrap<any>()),
|
||||||
/** 工资条列表 */
|
/** 工资条列表 */
|
||||||
payslips: (params: { month?: string; employeeId?: string }) =>
|
payslips: (params: { month?: string; employeeId?: string }) =>
|
||||||
get('/payroll/payslip', { params }).then(unwrap<any[]>()),
|
get('/payroll/payslip', { params }).then(unwrap<any[]>()),
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ const LEAVE_TYPES: Record<string, string> = {
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'confirm', label: '考勤确认', icon: CalendarCheck },
|
{ key: 'confirm', label: '考勤确认', icon: CalendarCheck },
|
||||||
{ key: 'shifts', label: '班次管理', icon: Clock },
|
|
||||||
{ key: 'schedule', label: '排班', icon: Calendar },
|
|
||||||
{ key: 'daily', label: '每日出勤', icon: Users },
|
{ key: 'daily', label: '每日出勤', icon: Users },
|
||||||
{ key: 'monthly', label: '月度报表', icon: BarChart3 },
|
{ key: 'monthly', label: '月度报表', icon: BarChart3 },
|
||||||
{ key: 'leaves', label: '休假记录', icon: Plane },
|
{ key: 'leaves', label: '休假记录', icon: Plane },
|
||||||
@@ -55,14 +53,14 @@ export default function Attendance() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageGuide>
|
<PageGuide>
|
||||||
考勤管理,支持班次设定、排班、出勤查询、月度报表和休假记录。流程:①设置班次 → ②排班 → ③每日打卡或导入考勤 → ④月度汇总。关联:考勤数据影响薪税管理的工资和加班费计算。
|
考勤管理,支持出勤查询、月度报表和休假记录。流程:①每日打卡或导入考勤 → ②月度汇总 → ③考勤确认。关联:考勤数据影响薪税管理的工资和加班费计算。排班设置请前往「排班管理」。
|
||||||
</PageGuide>
|
</PageGuide>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarCheck className="h-5 w-5 text-primary" />
|
<CalendarCheck className="h-5 w-5 text-primary" />
|
||||||
<h1 className="text-base font-semibold">考勤管理</h1>
|
<h1 className="text-base font-semibold">考勤管理</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-500">班次设定、排班、出勤查询、月度报表、休假记录</p>
|
<p className="mt-1 text-sm text-gray-500">出勤查询、月度报表、休假记录</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab 导航 */}
|
{/* Tab 导航 */}
|
||||||
@@ -87,8 +85,6 @@ export default function Attendance() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'confirm' && <ConfirmTab onGoToTab={setActiveTab} />}
|
{activeTab === 'confirm' && <ConfirmTab onGoToTab={setActiveTab} />}
|
||||||
{activeTab === 'shifts' && <ShiftsTab />}
|
|
||||||
{activeTab === 'schedule' && <ScheduleTab />}
|
|
||||||
{activeTab === 'daily' && <DailyTab />}
|
{activeTab === 'daily' && <DailyTab />}
|
||||||
{activeTab === 'monthly' && <MonthlyTab />}
|
{activeTab === 'monthly' && <MonthlyTab />}
|
||||||
{activeTab === 'leaves' && <LeavesTab />}
|
{activeTab === 'leaves' && <LeavesTab />}
|
||||||
@@ -650,349 +646,6 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 班次管理 Tab ==========
|
|
||||||
function ShiftsTab() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const confirm = useConfirm()
|
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
|
||||||
const [editShift, setEditShift] = useState<any>(null)
|
|
||||||
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
|
|
||||||
|
|
||||||
const { data: shifts, isLoading } = useQuery<any>({
|
|
||||||
queryKey: ['shifts'],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await attendanceApi.shifts()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: async (data: any) => {
|
|
||||||
if (editShift) {
|
|
||||||
return attendanceApi.saveShift(data, editShift.id)
|
|
||||||
}
|
|
||||||
return attendanceApi.saveShift(data)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['shifts'] })
|
|
||||||
setShowAdd(false)
|
|
||||||
setEditShift(null)
|
|
||||||
setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => attendanceApi.removeShift(id),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shifts'] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!form.name.trim()) return toast.error('请输入班次名称')
|
|
||||||
saveMutation.mutate(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />新增班次
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
|
||||||
) : !shifts || shifts.length === 0 ? (
|
|
||||||
<EmptyState title="暂无班次" description="请先创建班次" />
|
|
||||||
) : (
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{shifts.map((s: any) => (
|
|
||||||
<Card key={s.id}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full" style={{ background: s.color }} />
|
|
||||||
<span className="font-medium text-sm">{s.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button className="text-xs text-gray-400 hover:text-primary px-1" onClick={() => { setEditShift(s); setForm(s); setShowAdd(true) }}>编辑</button>
|
|
||||||
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={async () => { if (await confirm({ title: '删除班次', message: '确认删除?' })) deleteMutation.mutate(s.id) }}>删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-gray-500 space-y-0.5">
|
|
||||||
<div>上班时间:{s.startTime} — 下班时间:{s.endTime}</div>
|
|
||||||
<div>弹性时长:{s.flexibleMinutes} 分钟 — 休息时长:{s.restMinutes} 分钟</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal open={showAdd} onClose={() => setShowAdd(false)} title={editShift ? '编辑班次' : '新增班次'}>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label>班次名称</Label>
|
|
||||||
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="如:早班、白班、夜班" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>上班时间</Label>
|
|
||||||
<Input type="time" value={form.startTime} onChange={e => setForm({ ...form, startTime: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>下班时间</Label>
|
|
||||||
<Input type="time" value={form.endTime} onChange={e => setForm({ ...form, endTime: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>弹性时长(分钟)</Label>
|
|
||||||
<Input type="number" value={form.flexibleMinutes} onChange={e => setForm({ ...form, flexibleMinutes: Number(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>休息时长(分钟)</Label>
|
|
||||||
<Input type="number" value={form.restMinutes} onChange={e => setForm({ ...form, restMinutes: Number(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>显示颜色</Label>
|
|
||||||
<input type="color" value={form.color} onChange={e => setForm({ ...form, color: e.target.value })} className="h-9 w-16 rounded border border-gray-200" />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<Button variant="secondary" onClick={() => setShowAdd(false)}>取消</Button>
|
|
||||||
<Button onClick={handleSubmit} disabled={saveMutation.isPending}>{saveMutation.isPending ? '保存中...' : '保存'}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 排班 Tab ==========
|
|
||||||
function ScheduleTab() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
|
|
||||||
const [showAssign, setShowAssign] = useState(false)
|
|
||||||
const [selectedShiftId, setSelectedShiftId] = useState('')
|
|
||||||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [filterDept, setFilterDept] = useState('')
|
|
||||||
const pageSize = usePageSize()
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [inlineShiftId, setInlineShiftId] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
const { data: shifts } = useQuery<any>({
|
|
||||||
queryKey: ['shifts'],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await attendanceApi.shifts()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: assignments, isLoading } = useQuery<any>({
|
|
||||||
queryKey: ['shift-assignments', date],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await attendanceApi.shiftAssignments(date)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: dailyData } = useQuery<any>({
|
|
||||||
queryKey: ['daily-attendance', date],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await attendanceApi.daily(date)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const batchAssignMutation = useMutation({
|
|
||||||
mutationFn: (items: any[]) => attendanceApi.batchAssign(items),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
|
|
||||||
setShowAssign(false)
|
|
||||||
setSelectedEmployeeIds(new Set())
|
|
||||||
setSelectedShiftId('')
|
|
||||||
toast.success('排班成功')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteAssignmentMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => attendanceApi.removeAssignment(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleBatchAssign = () => {
|
|
||||||
if (!selectedShiftId) return toast.error('请选择班次')
|
|
||||||
if (selectedEmployeeIds.size === 0) return toast.error('请选择员工')
|
|
||||||
const items = Array.from(selectedEmployeeIds).map(empId => ({ employeeId: empId, shiftId: selectedShiftId, date }))
|
|
||||||
batchAssignMutation.mutate(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
const allEmployees = dailyData || []
|
|
||||||
const assignmentMap: Map<string, any> = new Map((assignments || []).map((a: any) => [a.employeeId, a]))
|
|
||||||
|
|
||||||
const filteredEmployees = allEmployees.filter((emp: any) => {
|
|
||||||
if (filterDept && emp.department !== filterDept) return false
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
const q = searchQuery.trim().toLowerCase()
|
|
||||||
if (!emp.name?.toLowerCase().includes(q) && !emp.department?.toLowerCase().includes(q)) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
const total = filteredEmployees.length
|
|
||||||
const employees = filteredEmployees.slice((page - 1) * pageSize, page * pageSize)
|
|
||||||
|
|
||||||
const toggleEmployee = (id: string) => {
|
|
||||||
const next = new Set(selectedEmployeeIds)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
setSelectedEmployeeIds(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInlineAssign = (employeeId: string) => {
|
|
||||||
const shiftId = inlineShiftId[employeeId]
|
|
||||||
if (!shiftId) return toast.error('请先选择班次')
|
|
||||||
batchAssignMutation.mutate([{ employeeId, shiftId, date }])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={date}
|
|
||||||
onChange={e => { setDate(e.target.value); setPage(1) }}
|
|
||||||
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索姓名或部门"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
|
|
||||||
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={filterDept}
|
|
||||||
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
|
|
||||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">全部部门</option>
|
|
||||||
{Array.from(new Set(allEmployees.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
|
|
||||||
<option key={d} value={d}>{d}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setShowAssign(true)}>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />批量排班
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
|
||||||
) : total === 0 ? (
|
|
||||||
<EmptyState title="暂无员工" description="没有可排班的员工" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Card className="overflow-hidden p-0">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-gray-50/90">
|
|
||||||
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
|
|
||||||
<th className="px-4 py-3 text-left">姓名</th>
|
|
||||||
<th className="px-4 py-3 text-left">部门</th>
|
|
||||||
<th className="px-4 py-3 text-left">班次</th>
|
|
||||||
<th className="px-4 py-3 text-center w-48">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{employees.map((emp: any) => {
|
|
||||||
const assignment = assignmentMap.get(emp.employeeId)
|
|
||||||
return (
|
|
||||||
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
|
|
||||||
<td className="px-4 py-3 font-medium">{emp.name}</td>
|
|
||||||
<td className="px-4 py-3 text-gray-500">{emp.department || '未分配'}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{assignment ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs" style={{ background: (assignment.shift as any)?.color + '20', color: (assignment.shift as any)?.color }}>
|
|
||||||
<div className="w-2 h-2 rounded-full" style={{ background: (assignment.shift as any)?.color }} />
|
|
||||||
{(assignment.shift as any)?.name} {(assignment.shift as any)?.startTime}-{(assignment.shift as any)?.endTime}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-400">未排班</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
{assignment ? (
|
|
||||||
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}>移除</button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
value={inlineShiftId[emp.employeeId] || ''}
|
|
||||||
onChange={e => setInlineShiftId(prev => ({ ...prev, [emp.employeeId]: e.target.value }))}
|
|
||||||
className="h-7 rounded border border-gray-200 text-xs px-1 max-w-[100px]"
|
|
||||||
>
|
|
||||||
<option value="">选班次</option>
|
|
||||||
{(shifts || []).map((s: any) => (
|
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
className="text-xs text-primary hover:underline whitespace-nowrap"
|
|
||||||
onClick={() => handleInlineAssign(emp.employeeId)}
|
|
||||||
>排班</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="批量排班">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label>选择班次</Label>
|
|
||||||
<Select value={selectedShiftId} onChange={e => setSelectedShiftId(e.target.value)}>
|
|
||||||
<option value="">请选择班次</option>
|
|
||||||
{(shifts || []).map((s: any) => (
|
|
||||||
<option key={s.id} value={s.id}>{s.name} ({s.startTime}-{s.endTime})</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>选择员工({selectedEmployeeIds.size} 人已选)</Label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索员工姓名或部门..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 mb-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
|
|
||||||
{filteredEmployees.map((emp: any) => (
|
|
||||||
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
|
|
||||||
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
|
|
||||||
<span className="text-sm">{emp.name}</span>
|
|
||||||
<span className="text-xs text-gray-400">{emp.department}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<Button variant="secondary" onClick={() => setShowAssign(false)}>取消</Button>
|
|
||||||
<Button onClick={handleBatchAssign} disabled={batchAssignMutation.isPending}>{batchAssignMutation.isPending ? '排班中...' : '确认排班'}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 每日出勤 Tab ==========
|
// ========== 每日出勤 Tab ==========
|
||||||
function DailyTab() {
|
function DailyTab() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -1073,9 +726,9 @@ function DailyTab() {
|
|||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={() => {
|
<Button variant="secondary" size="sm" onClick={() => {
|
||||||
if (!data || data.length === 0) return
|
if (!data || data.length === 0) return
|
||||||
const headers = ['姓名', '部门', '班次', '签到', '签退', '状态', '工时']
|
const headers = ['姓名', '身份证号', '部门', '班次', '签到', '签退', '状态', '工时']
|
||||||
const rows = data.map((emp: any) => [
|
const rows = data.map((emp: any) => [
|
||||||
emp.name, emp.department, emp.shift?.name || '', emp.checkInTime || '', emp.checkOutTime || '',
|
emp.name, emp.idCardNumber || '', emp.department, emp.shift?.name || '', emp.checkInTime || '', emp.checkOutTime || '',
|
||||||
ATTENDANCE_STATUS[emp.status] || emp.status, emp.workHours > 0 ? `${emp.workHours}h` : '0',
|
ATTENDANCE_STATUS[emp.status] || emp.status, emp.workHours > 0 ? `${emp.workHours}h` : '0',
|
||||||
])
|
])
|
||||||
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
|
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
|
||||||
@@ -1102,6 +755,7 @@ function DailyTab() {
|
|||||||
<thead className="bg-gray-50/90">
|
<thead className="bg-gray-50/90">
|
||||||
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
|
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
|
||||||
<th className="px-4 py-3 text-left">姓名</th>
|
<th className="px-4 py-3 text-left">姓名</th>
|
||||||
|
<th className="px-4 py-3 text-left">身份证号</th>
|
||||||
<th className="px-4 py-3 text-left">部门</th>
|
<th className="px-4 py-3 text-left">部门</th>
|
||||||
<th className="px-4 py-3 text-left">班次</th>
|
<th className="px-4 py-3 text-left">班次</th>
|
||||||
<th className="px-4 py-3 text-left">签到</th>
|
<th className="px-4 py-3 text-left">签到</th>
|
||||||
@@ -1115,6 +769,7 @@ function DailyTab() {
|
|||||||
{pagedData.map((emp: any) => (
|
{pagedData.map((emp: any) => (
|
||||||
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
|
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
|
||||||
<td className="px-4 py-3 font-medium">{emp.name}</td>
|
<td className="px-4 py-3 font-medium">{emp.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{emp.idCardNumber || '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
|
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
|
||||||
<td className="px-4 py-3 text-xs text-gray-500">{emp.shift ? `${emp.shift.name}` : '—'}</td>
|
<td className="px-4 py-3 text-xs text-gray-500">{emp.shift ? `${emp.shift.name}` : '—'}</td>
|
||||||
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime ? (() => { const d = new Date(emp.checkInTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
|
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime ? (() => { const d = new Date(emp.checkInTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function OrgChart() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post(editing ? `/departments/${editing.id}` : '/departments', data),
|
mutationFn: (data: any) => editing ? api.put(`/departments/${editing.id}`, data) : api.post('/departments', data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['departments'] })
|
queryClient.invalidateQueries({ queryKey: ['departments'] })
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
@@ -216,7 +216,7 @@ function PositionTab({ positions, departments }: { positions: any[]; departments
|
|||||||
const [form, setForm] = useState<any>({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
|
const [form, setForm] = useState<any>({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post(editing ? `/positions/${editing.id}` : '/positions', data),
|
mutationFn: (data: any) => editing ? api.put(`/positions/${editing.id}`, data) : api.post('/positions', data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['positions'] })
|
queryClient.invalidateQueries({ queryKey: ['positions'] })
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Calendar, Clock, Plus } from 'lucide-react'
|
||||||
|
import { attendanceApi } from '../lib/api-services'
|
||||||
|
import { usePageSize } from '../hooks/usePageSize'
|
||||||
|
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 Pagination from '../components/ui/Pagination'
|
||||||
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'shifts', label: '班次管理', icon: Clock },
|
||||||
|
{ key: 'schedule', label: '排班', icon: Calendar },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Schedule() {
|
||||||
|
const [activeTab, setActiveTab] = useState('shifts')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageGuide>
|
||||||
|
排班管理,支持班次设定和员工排班。流程:①创建班次 → ②为员工设置长期/临时班次。长期班次为员工默认工作班次,临时排班可按天覆盖。
|
||||||
|
</PageGuide>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="text-base font-semibold">排班管理</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">班次设定、员工排班</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 导航 */}
|
||||||
|
<div className="flex flex-wrap gap-1 border-b border-gray-200">
|
||||||
|
{TABS.map(tab => {
|
||||||
|
const Icon = tab.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'shifts' && <ShiftsTab />}
|
||||||
|
{activeTab === 'schedule' && <ScheduleTab />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 班次管理 Tab ==========
|
||||||
|
function ShiftsTab() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [editShift, setEditShift] = useState<any>(null)
|
||||||
|
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
|
||||||
|
|
||||||
|
const { data: shifts, isLoading } = useQuery<any>({
|
||||||
|
queryKey: ['shifts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await attendanceApi.shifts()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (data: any) => {
|
||||||
|
if (editShift) {
|
||||||
|
return attendanceApi.saveShift(data, editShift.id)
|
||||||
|
}
|
||||||
|
return attendanceApi.saveShift(data)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shifts'] })
|
||||||
|
setShowAdd(false)
|
||||||
|
setEditShift(null)
|
||||||
|
setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => attendanceApi.removeShift(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shifts'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.name.trim()) return toast.error('请输入班次名称')
|
||||||
|
saveMutation.mutate(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />新增班次
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||||
|
) : !shifts || shifts.length === 0 ? (
|
||||||
|
<EmptyState title="暂无班次" description="请先创建班次" />
|
||||||
|
) : (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{shifts.map((s: any) => (
|
||||||
|
<Card key={s.id}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ background: s.color }} />
|
||||||
|
<span className="font-medium text-sm">{s.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button className="text-xs text-gray-400 hover:text-primary px-1" onClick={() => { setEditShift(s); setForm(s); setShowAdd(true) }}>编辑</button>
|
||||||
|
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={async () => { if (await confirm({ title: '删除班次', message: '确认删除?' })) deleteMutation.mutate(s.id) }}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500 space-y-0.5">
|
||||||
|
<div>上班时间:{s.startTime} — 下班时间:{s.endTime}</div>
|
||||||
|
<div>弹性时长:{s.flexibleMinutes} 分钟 — 休息时长:{s.restMinutes} 分钟</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal open={showAdd} onClose={() => setShowAdd(false)} title={editShift ? '编辑班次' : '新增班次'}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>班次名称</Label>
|
||||||
|
<Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="如:早班、白班、夜班" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>上班时间</Label>
|
||||||
|
<Input type="time" value={form.startTime} onChange={e => setForm({ ...form, startTime: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>下班时间</Label>
|
||||||
|
<Input type="time" value={form.endTime} onChange={e => setForm({ ...form, endTime: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>弹性时长(分钟)</Label>
|
||||||
|
<Input type="number" value={form.flexibleMinutes} onChange={e => setForm({ ...form, flexibleMinutes: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>休息时长(分钟)</Label>
|
||||||
|
<Input type="number" value={form.restMinutes} onChange={e => setForm({ ...form, restMinutes: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>显示颜色</Label>
|
||||||
|
<input type="color" value={form.color} onChange={e => setForm({ ...form, color: e.target.value })} className="h-9 w-16 rounded border border-gray-200" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={() => setShowAdd(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={saveMutation.isPending}>{saveMutation.isPending ? '保存中...' : '保存'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 排班 Tab ==========
|
||||||
|
function ScheduleTab() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
|
||||||
|
const [showAssign, setShowAssign] = useState(false)
|
||||||
|
const [selectedShiftId, setSelectedShiftId] = useState('')
|
||||||
|
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [filterDept, setFilterDept] = useState('')
|
||||||
|
const pageSize = usePageSize()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [inlineShiftId, setInlineShiftId] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const { data: shifts } = useQuery<any>({
|
||||||
|
queryKey: ['shifts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await attendanceApi.shifts()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: assignments, isLoading } = useQuery<any>({
|
||||||
|
queryKey: ['shift-assignments', date],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await attendanceApi.shiftAssignments(date)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: dailyData } = useQuery<any>({
|
||||||
|
queryKey: ['daily-attendance', date],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await attendanceApi.daily(date)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchAssignMutation = useMutation({
|
||||||
|
mutationFn: (items: any[]) => attendanceApi.batchAssign(items),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
|
||||||
|
setShowAssign(false)
|
||||||
|
setSelectedEmployeeIds(new Set())
|
||||||
|
setSelectedShiftId('')
|
||||||
|
toast.success('排班成功')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteAssignmentMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => attendanceApi.removeAssignment(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置默认班次(长期排班)
|
||||||
|
const setDefaultShiftMutation = useMutation({
|
||||||
|
mutationFn: ({ employeeId, shiftId }: { employeeId: string; shiftId: string | null }) =>
|
||||||
|
attendanceApi.setDefaultShift(employeeId, shiftId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shift-assignments'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
|
||||||
|
toast.success('默认班次已更新')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('设置失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBatchAssign = () => {
|
||||||
|
if (!selectedShiftId) return toast.error('请选择班次')
|
||||||
|
if (selectedEmployeeIds.size === 0) return toast.error('请选择员工')
|
||||||
|
const items = Array.from(selectedEmployeeIds).map(empId => ({ employeeId: empId, shiftId: selectedShiftId, date }))
|
||||||
|
batchAssignMutation.mutate(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEmployees = dailyData || []
|
||||||
|
const assignmentMap: Map<string, any> = new Map((assignments || []).map((a: any) => [a.employeeId, a]))
|
||||||
|
|
||||||
|
const filteredEmployees = allEmployees.filter((emp: any) => {
|
||||||
|
if (filterDept && emp.department !== filterDept) return false
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.trim().toLowerCase()
|
||||||
|
if (!emp.name?.toLowerCase().includes(q) && !emp.department?.toLowerCase().includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const total = filteredEmployees.length
|
||||||
|
const employees = filteredEmployees.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
|
||||||
|
const toggleEmployee = (id: string) => {
|
||||||
|
const next = new Set(selectedEmployeeIds)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
setSelectedEmployeeIds(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInlineAssign = (employeeId: string) => {
|
||||||
|
const shiftId = inlineShiftId[employeeId]
|
||||||
|
if (!shiftId) return toast.error('请先选择班次')
|
||||||
|
batchAssignMutation.mutate([{ employeeId, shiftId, date }])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={e => { setDate(e.target.value); setPage(1) }}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索姓名或部门"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
|
||||||
|
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={filterDept}
|
||||||
|
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
|
||||||
|
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">全部部门</option>
|
||||||
|
{Array.from(new Set(allEmployees.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowAssign(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />批量排班
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||||
|
) : total === 0 ? (
|
||||||
|
<EmptyState title="暂无员工" description="没有可排班的员工" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className="overflow-hidden p-0">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50/90">
|
||||||
|
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
|
||||||
|
<th className="px-4 py-3 text-left">姓名</th>
|
||||||
|
<th className="px-4 py-3 text-left">身份证号</th>
|
||||||
|
<th className="px-4 py-3 text-left">部门</th>
|
||||||
|
<th className="px-4 py-3 text-left">班次</th>
|
||||||
|
<th className="px-4 py-3 text-center w-48">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{employees.map((emp: any) => {
|
||||||
|
const assignment = assignmentMap.get(emp.employeeId)
|
||||||
|
const isDefault = assignment?.isDefault === true
|
||||||
|
const hasShift = assignment && assignment.shift
|
||||||
|
return (
|
||||||
|
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
|
||||||
|
<td className="px-4 py-3 font-medium">{emp.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{emp.idCardNumber || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{emp.department || '未分配'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{hasShift ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs" style={{ background: (assignment.shift as any)?.color + '20', color: (assignment.shift as any)?.color }}>
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ background: (assignment.shift as any)?.color }} />
|
||||||
|
{(assignment.shift as any)?.name} {(assignment.shift as any)?.startTime}-{(assignment.shift as any)?.endTime}
|
||||||
|
{isDefault && <span className="ml-1 px-1 py-px rounded bg-gray-100 text-gray-500 text-[10px]">长期</span>}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">未排班</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{hasShift && !isDefault ? (
|
||||||
|
// 临时换班:移除后回退到默认班次
|
||||||
|
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}>移除</button>
|
||||||
|
) : isDefault ? (
|
||||||
|
// 长期排班:改班次 + 移除(清除默认班次)
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={inlineShiftId[emp.employeeId] || ''}
|
||||||
|
onChange={e => setInlineShiftId(prev => ({ ...prev, [emp.employeeId]: e.target.value }))}
|
||||||
|
className="h-7 rounded border border-gray-200 text-xs px-1 max-w-[100px]"
|
||||||
|
>
|
||||||
|
<option value="">选班次</option>
|
||||||
|
{(shifts || []).map((s: any) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="text-xs text-primary hover:underline whitespace-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
const shiftId = inlineShiftId[emp.employeeId]
|
||||||
|
if (shiftId) setDefaultShiftMutation.mutate({ employeeId: emp.employeeId, shiftId })
|
||||||
|
}}
|
||||||
|
>改班次</button>
|
||||||
|
<button
|
||||||
|
className="text-xs text-gray-400 hover:text-red-500 whitespace-nowrap"
|
||||||
|
onClick={() => setDefaultShiftMutation.mutate({ employeeId: emp.employeeId, shiftId: null })}
|
||||||
|
>移除</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 无排班:选班次 + 排班(设为长期默认班次)
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={inlineShiftId[emp.employeeId] || ''}
|
||||||
|
onChange={e => setInlineShiftId(prev => ({ ...prev, [emp.employeeId]: e.target.value }))}
|
||||||
|
className="h-7 rounded border border-gray-200 text-xs px-1 max-w-[100px]"
|
||||||
|
>
|
||||||
|
<option value="">选班次</option>
|
||||||
|
{(shifts || []).map((s: any) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="text-xs text-primary hover:underline whitespace-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
const shiftId = inlineShiftId[emp.employeeId]
|
||||||
|
if (!shiftId) return toast.error('请选择班次')
|
||||||
|
setDefaultShiftMutation.mutate({ employeeId: emp.employeeId, shiftId })
|
||||||
|
}}
|
||||||
|
>排班</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="批量排班">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>选择班次</Label>
|
||||||
|
<Select value={selectedShiftId} onChange={e => setSelectedShiftId(e.target.value)}>
|
||||||
|
<option value="">请选择班次</option>
|
||||||
|
{(shifts || []).map((s: any) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name} ({s.startTime}-{s.endTime})</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>选择员工({selectedEmployeeIds.size} 人已选)</Label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索员工姓名或部门..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 mb-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
|
||||||
|
{filteredEmployees.map((emp: any) => (
|
||||||
|
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
|
||||||
|
<span className="text-sm">{emp.name}</span>
|
||||||
|
<span className="text-xs text-gray-400">{emp.department}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={() => setShowAssign(false)}>取消</Button>
|
||||||
|
<Button onClick={handleBatchAssign} disabled={batchAssignMutation.isPending}>{batchAssignMutation.isPending ? '排班中...' : '确认排班'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X, Plus } from 'lucide-react'
|
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X, Plus, CalendarDays, Sparkles, Trash2 } from 'lucide-react'
|
||||||
import PageGuide from '../../components/ui/PageGuide'
|
import PageGuide from '../../components/ui/PageGuide'
|
||||||
import { payrollApi, employeeApi } from '../../lib/api-services'
|
import { payrollApi, employeeApi } from '../../lib/api-services'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
@@ -36,6 +36,41 @@ export function OvertimeCalculator() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 节假日配置
|
||||||
|
const [holidayYear, setHolidayYear] = useState(new Date().getFullYear().toString())
|
||||||
|
const [showHolidays, setShowHolidays] = useState(false)
|
||||||
|
const [holidayForm, setHolidayForm] = useState({ date: '', type: 'HOLIDAY', name: '' })
|
||||||
|
const [holidayItems, setHolidayItems] = useState<{ date: string; type: string; name?: string }[]>([])
|
||||||
|
|
||||||
|
const { data: holidays, refetch: refetchHolidays } = useQuery<any[]>({
|
||||||
|
queryKey: ['holidays', holidayYear],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await payrollApi.holidays(holidayYear)
|
||||||
|
},
|
||||||
|
enabled: showHolidays,
|
||||||
|
})
|
||||||
|
|
||||||
|
const presetHolidaysMutation = useMutation({
|
||||||
|
mutationFn: (year: string) => payrollApi.presetHolidays(year),
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
toast.success(`已预置 ${data.holidays || 0} 个法定节假日、${data.workdays || 0} 个调休工作日`)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['holidays', holidayYear] })
|
||||||
|
refetchHolidays()
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err.response?.data?.message || '预置失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveHolidaysMutation = useMutation({
|
||||||
|
mutationFn: (data: { year: string; items: { date: string; type: string; name?: string }[] }) =>
|
||||||
|
payrollApi.saveHolidays(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('节假日配置已保存')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['holidays', holidayYear] })
|
||||||
|
refetchHolidays()
|
||||||
|
},
|
||||||
|
onError: () => toast.error('保存失败'),
|
||||||
|
})
|
||||||
|
|
||||||
// 员工列表
|
// 员工列表
|
||||||
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
|
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
|
||||||
queryKey: ['employees-for-overtime'],
|
queryKey: ['employees-for-overtime'],
|
||||||
@@ -272,6 +307,95 @@ export function OvertimeCalculator() {
|
|||||||
{saveConfigMutation.isSuccess && (
|
{saveConfigMutation.isSuccess && (
|
||||||
<div className="text-xs text-safe flex items-center gap-1"><Check className="w-3.5 h-3.5" />规则已保存</div>
|
<div className="text-xs text-safe flex items-center gap-1"><Check className="w-3.5 h-3.5" />规则已保存</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 节假日配置 */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHolidays(!showHolidays)}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-primary"
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-4 h-4" />
|
||||||
|
节假日配置
|
||||||
|
<span className="text-xs text-gray-400 font-normal">(用于区分法定节假日和调休工作日)</span>
|
||||||
|
</button>
|
||||||
|
{showHolidays && (
|
||||||
|
<div className="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input type="number" value={holidayYear} onChange={(e) => setHolidayYear(e.target.value)} className="w-24" />
|
||||||
|
<span className="text-xs text-gray-500">年</span>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => presetHolidaysMutation.mutate(holidayYear)} disabled={presetHolidaysMutation.isPending}>
|
||||||
|
<Sparkles className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{presetHolidaysMutation.isPending ? '预置中...' : '一键预置法定节假日'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 节假日列表 */}
|
||||||
|
{holidays && holidays.length > 0 ? (
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{holidays.map((h: any) => (
|
||||||
|
<div key={h.id} className="flex items-center gap-3 bg-white rounded-md px-3 py-2 text-xs">
|
||||||
|
<span className="font-mono text-gray-700">{h.date.slice(0, 10)}</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded-full ${h.type === 'HOLIDAY' ? 'bg-red-50 text-red-600' : 'bg-blue-50 text-blue-600'}`}>
|
||||||
|
{h.type === 'HOLIDAY' ? '法定节假日' : '调休工作日'}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">{h.name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-400 text-center py-4">
|
||||||
|
{holidays ? '暂无节假日配置,点击上方按钮一键预置' : '加载中...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 手动添加 */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<Label>日期</Label>
|
||||||
|
<Input type="date" value={holidayForm.date} onChange={(e) => setHolidayForm({ ...holidayForm, date: e.target.value })} className="w-40" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>类型</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
|
||||||
|
value={holidayForm.type}
|
||||||
|
onChange={(e) => setHolidayForm({ ...holidayForm, type: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="HOLIDAY">法定节假日</option>
|
||||||
|
<option value="WORKDAY">调休工作日</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>名称</Label>
|
||||||
|
<Input type="text" value={holidayForm.name} onChange={(e) => setHolidayForm({ ...holidayForm, name: e.target.value })} placeholder="如:春节" className="w-32" />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => {
|
||||||
|
if (!holidayForm.date) return toast.error('请选择日期')
|
||||||
|
const newItems = [...(holidays || []), { date: holidayForm.date, type: holidayForm.type, name: holidayForm.name }].sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
saveHolidaysMutation.mutate({
|
||||||
|
year: holidayYear,
|
||||||
|
items: newItems.map(h => ({ date: h.date.slice(0, 10), type: h.type, name: h.name })),
|
||||||
|
})
|
||||||
|
setHolidayForm({ date: '', type: 'HOLIDAY', name: '' })
|
||||||
|
}}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" />添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
|
||||||
|
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p><b>法定节假日</b>:春节、国庆等法定假日,加班按 3 倍工资</p>
|
||||||
|
<p><b>调休工作日</b>:周末调休上班,按工作日 1.5 倍计算</p>
|
||||||
|
<p className="text-gray-500 mt-1">从考勤同步加班工时时,系统会根据此配置自动判断日期类型</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={() => setStep(2)}>下一步:导入考勤数据 →</Button>
|
<Button onClick={() => setStep(2)}>下一步:导入考勤数据 →</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,72 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Loader2, CalendarCheck } from 'lucide-react'
|
import { Loader2, CalendarCheck, Clock, LogIn, LogOut, CalendarDays, Clock3 } from 'lucide-react'
|
||||||
import { portalApi } from '../../lib/api-services'
|
import { portalApi } from '../../lib/api-services'
|
||||||
|
|
||||||
|
/** 考勤状态中文映射 */
|
||||||
|
const statusMap: Record<string, { label: string; color: string; dot: string }> = {
|
||||||
|
NORMAL: { label: '正常', color: 'bg-green-50 text-safe', dot: 'bg-safe' },
|
||||||
|
LATE: { label: '迟到', color: 'bg-amber-50 text-amber-700', dot: 'bg-amber-500' },
|
||||||
|
EARLY: { label: '早退', color: 'bg-orange-50 text-orange-700', dot: 'bg-orange-500' },
|
||||||
|
ABSENT: { label: '缺勤', color: 'bg-red-50 text-red-700', dot: 'bg-red-500' },
|
||||||
|
LEAVE: { label: '请假', color: 'bg-blue-50 text-blue-700', dot: 'bg-blue-500' },
|
||||||
|
BUSINESS: { label: '出差', color: 'bg-purple-50 text-purple-700', dot: 'bg-purple-500' },
|
||||||
|
WEEKEND: { label: '休息', color: 'bg-gray-50 text-gray-400', dot: 'bg-gray-300' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据日期类型和打卡状态,返回更准确的状态标签 */
|
||||||
|
function getDisplayStatus(record: any) {
|
||||||
|
const baseStatus = record.status || 'UNKNOWN'
|
||||||
|
const dateType = record.dateType // weekday / weekend / holiday
|
||||||
|
const hasCheckIn = !!record.checkInTime
|
||||||
|
|
||||||
|
// 有打卡记录的周末/节假日,状态改为"周末出勤"/"节假日出勤"
|
||||||
|
if (hasCheckIn && baseStatus === 'NORMAL') {
|
||||||
|
if (dateType === 'holiday') {
|
||||||
|
return { label: '节假日出勤', color: 'bg-red-50 text-red-600', dot: 'bg-red-500' }
|
||||||
|
}
|
||||||
|
if (dateType === 'weekend') {
|
||||||
|
return { label: '周末出勤', color: 'bg-amber-50 text-amber-600', dot: 'bg-amber-500' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无打卡的周末/节假日,显示"休息"
|
||||||
|
if (!hasCheckIn && (dateType === 'weekend' || dateType === 'holiday')) {
|
||||||
|
return { label: '休息', color: 'bg-gray-50 text-gray-400', dot: 'bg-gray-300' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusMap[baseStatus] || { label: record.statusText || baseStatus || '未知', color: 'bg-gray-50 text-gray-600', dot: 'bg-gray-300' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdayMap = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
|
||||||
|
/** 格式化时间:直接从 ISO 字符串提取 HH:mm,避免时区转换 */
|
||||||
|
function fmtTime(t: string): string {
|
||||||
|
if (!t) return ''
|
||||||
|
// 格式:2026-08-17T08:50:00.000Z → 08:50
|
||||||
|
const m = t.match(/T(\d{2}):(\d{2})/)
|
||||||
|
if (m) return `${m[1]}:${m[2]}`
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化日期:X月X日 周X(直接从字符串提取,避免时区转换) */
|
||||||
|
function fmtDate(d: string): { date: string; weekday: string; isWeekend: boolean } {
|
||||||
|
try {
|
||||||
|
const m = d.match(/(\d{4})-(\d{2})-(\d{2})/)
|
||||||
|
if (m) {
|
||||||
|
const month = parseInt(m[2])
|
||||||
|
const day = parseInt(m[3])
|
||||||
|
const dt = new Date(parseInt(m[1]), month - 1, day)
|
||||||
|
return {
|
||||||
|
date: `${month}月${day}日`,
|
||||||
|
weekday: `周${weekdayMap[dt.getDay()]}`,
|
||||||
|
isWeekend: dt.getDay() === 0 || dt.getDay() === 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { date: d, weekday: '', isWeekend: false }
|
||||||
|
}
|
||||||
|
|
||||||
export default function MyAttendance() {
|
export default function MyAttendance() {
|
||||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||||
|
|
||||||
@@ -24,23 +88,34 @@ export default function MyAttendance() {
|
|||||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统计(按显示状态归类)
|
||||||
|
const stats = records.reduce((acc: Record<string, { count: number; label: string; color: string; dot: string }>, r: any) => {
|
||||||
|
const cfg = getDisplayStatus(r)
|
||||||
|
if (!acc[cfg.label]) {
|
||||||
|
acc[cfg.label] = { count: 0, label: cfg.label, color: cfg.color, dot: cfg.dot }
|
||||||
|
}
|
||||||
|
acc[cfg.label].count++
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* 页面标题 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarCheck className="w-5 h-5 text-primary" />
|
<CalendarCheck className="w-5 h-5 text-primary" />
|
||||||
<h1 className="text-base font-bold">我的考勤</h1>
|
<h1 className="text-base font-bold">我的考勤</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 月份选择 */}
|
{/* 月份选择 — 胶囊式 */}
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||||
{months.map(m => (
|
{months.map(m => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
onClick={() => setMonth(m)}
|
onClick={() => setMonth(m)}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs whitespace-nowrap transition-colors ${
|
className={`px-3.5 py-1.5 rounded-full text-xs whitespace-nowrap transition-all ${
|
||||||
month === m
|
month === m
|
||||||
? 'bg-primary text-white font-medium'
|
? 'bg-primary text-white font-medium shadow-sm'
|
||||||
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
|
: 'bg-white border border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m}
|
{m}
|
||||||
@@ -53,43 +128,107 @@ export default function MyAttendance() {
|
|||||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
) : !published ? (
|
) : !published ? (
|
||||||
<div className="bg-white rounded-lg p-8 text-center">
|
<div className="bg-white rounded-2xl p-8 text-center border border-gray-100">
|
||||||
|
<CalendarDays className="w-10 h-10 text-gray-300 mx-auto mb-2" />
|
||||||
<p className="text-sm text-gray-400">{month} 月考勤表尚未发布</p>
|
<p className="text-sm text-gray-400">{month} 月考勤表尚未发布</p>
|
||||||
</div>
|
</div>
|
||||||
) : records.length === 0 ? (
|
) : records.length === 0 ? (
|
||||||
<div className="bg-white rounded-lg p-8 text-center">
|
<div className="bg-white rounded-2xl p-8 text-center border border-gray-100">
|
||||||
|
<CalendarDays className="w-10 h-10 text-gray-300 mx-auto mb-2" />
|
||||||
<p className="text-sm text-gray-400">暂无考勤记录</p>
|
<p className="text-sm text-gray-400">暂无考勤记录</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg overflow-hidden">
|
<>
|
||||||
|
{/* 统计概览 */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{Object.entries(stats).map(([label, info]: [string, any]) => {
|
||||||
|
return (
|
||||||
|
<div key={label} className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs ${info.color}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${info.dot}`} />
|
||||||
|
{info.label} {info.count}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{(() => {
|
||||||
|
const totalOt = records.reduce((sum: number, r: any) => sum + (r.overtimeHours || 0), 0)
|
||||||
|
if (totalOt <= 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs bg-orange-50 text-orange-600">
|
||||||
|
<Clock3 className="w-3.5 h-3.5" />
|
||||||
|
加班 {totalOt}h
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 考勤列表 — 卡片式时间线 */}
|
||||||
|
<div className="bg-white rounded-2xl overflow-hidden border border-gray-100">
|
||||||
<div className="px-4 py-3 border-b border-gray-100">
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
<h2 className="text-sm font-medium">{data?.title || `${month} 月考勤表`}</h2>
|
<h2 className="text-sm font-semibold text-gray-900">{data?.title || `${month}月考勤表`}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-50">
|
<div className="divide-y divide-gray-50">
|
||||||
{records.map((record: any) => (
|
{records.map((record: any) => {
|
||||||
<div key={record.id} className="flex items-center px-4 py-2.5">
|
const { date, weekday, isWeekend } = fmtDate(record.date)
|
||||||
<div className="flex-1 min-w-0">
|
const cfg = getDisplayStatus(record)
|
||||||
<div className="text-sm text-gray-900">
|
const hasOvertime = record.hasOvertime || (record.overtimeHours || 0) > 0
|
||||||
{new Date(record.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}
|
const otHours = record.overtimeHours || 0
|
||||||
|
const otRate = record.overtimeRate || 0
|
||||||
|
const dateTypeLabel = record.dateType === 'holiday' ? '法定节假日' : record.dateType === 'weekend' ? '休息日' : '工作日'
|
||||||
|
const dateTypeColor = record.dateType === 'holiday' ? 'text-red-500' : record.dateType === 'weekend' ? 'text-amber-600' : 'text-gray-400'
|
||||||
|
return (
|
||||||
|
<div key={record.id} className="flex items-center gap-3 px-4 py-3 hover:bg-gray-25 transition-colors">
|
||||||
|
{/* 日期 */}
|
||||||
|
<div className={`flex-shrink-0 w-16 text-center ${isWeekend ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||||
|
<div className="text-sm font-medium">{date}</div>
|
||||||
|
<div className="text-xs text-gray-400">{weekday}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
{/* 分割线 */}
|
||||||
{record.checkInTime ? `上班 ${record.checkInTime}` : '未打卡'}
|
<div className="w-px h-8 bg-gray-100 flex-shrink-0" />
|
||||||
{record.checkOutTime ? ` · 下班 ${record.checkOutTime}` : ''}
|
{/* 打卡时间 + 加班 */}
|
||||||
</div>
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
</div>
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
<LogIn className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||||
record.status === 'NORMAL' ? 'bg-green-50 text-safe' :
|
<span className="text-gray-500">上班</span>
|
||||||
record.status === 'LATE' ? 'bg-amber-50 text-amber-700' :
|
<span className={`font-mono ${record.checkInTime ? 'text-gray-700' : 'text-gray-300'}`}>
|
||||||
record.status === 'ABSENT' ? 'bg-red-50 text-red-700' :
|
{record.checkInTime ? fmtTime(record.checkInTime) : '未打卡'}
|
||||||
record.status === 'LEAVE' ? 'bg-blue-50 text-blue-700' :
|
|
||||||
'bg-gray-50 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{record.statusText || record.status || '未知'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<LogOut className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-gray-500">下班</span>
|
||||||
|
<span className={`font-mono ${record.checkOutTime ? 'text-gray-700' : 'text-gray-300'}`}>
|
||||||
|
{record.checkOutTime ? fmtTime(record.checkOutTime) : '未打卡'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasOvertime && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs pt-0.5">
|
||||||
|
<Clock3 className="w-3.5 h-3.5 text-orange-500 flex-shrink-0" />
|
||||||
|
<span className="text-gray-500">加班</span>
|
||||||
|
<span className="font-mono text-orange-600 font-medium">{otHours}h</span>
|
||||||
|
<span className="text-gray-300">·</span>
|
||||||
|
<span className={dateTypeColor}>{dateTypeLabel}</span>
|
||||||
|
<span className="text-gray-300">·</span>
|
||||||
|
<span className="text-orange-500">{otRate}倍</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 状态标签 */}
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${cfg.color}`}>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
{hasOvertime && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-50 text-orange-600 font-medium">
|
||||||
|
+{otHours}h
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { FileText, AlertCircle, Check, RefreshCw, Calendar, Briefcase, Clock } from 'lucide-react'
|
import { FileText, AlertCircle, Check, RefreshCw, Calendar, Briefcase, Clock, ShieldCheck, FileSignature } from 'lucide-react'
|
||||||
import { portalApi } from '../../lib/api-services'
|
import { portalApi } from '../../lib/api-services'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
import Button from '../../components/ui/Button'
|
import Button from '../../components/ui/Button'
|
||||||
@@ -9,6 +9,16 @@ import EmptyState from '../../components/ui/EmptyState'
|
|||||||
/** 金额格式化:保留两位小数 + 千分位 */
|
/** 金额格式化:保留两位小数 + 千分位 */
|
||||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
const contractTypeMap: Record<string, string> = {
|
||||||
|
FIXED: '固定期限劳动合同',
|
||||||
|
UNFIXED: '无固定期限劳动合同',
|
||||||
|
LABOR: '劳务协议',
|
||||||
|
INTERNSHIP: '实习协议',
|
||||||
|
PARTTIME: '兼职协议',
|
||||||
|
OUTSOURCING: '业务外包',
|
||||||
|
UNSIGNED: '未签订',
|
||||||
|
}
|
||||||
|
|
||||||
export default function MyContract() {
|
export default function MyContract() {
|
||||||
const [resending, setResending] = useState(false)
|
const [resending, setResending] = useState(false)
|
||||||
const [resendMsg, setResendMsg] = useState('')
|
const [resendMsg, setResendMsg] = useState('')
|
||||||
@@ -63,51 +73,79 @@ export default function MyContract() {
|
|||||||
<>
|
<>
|
||||||
{/* 到期提醒横幅 */}
|
{/* 到期提醒横幅 */}
|
||||||
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
|
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
|
||||||
<div className="flex items-start gap-2 px-4 py-3 rounded-xl bg-amber-50 text-amber-700 text-sm">
|
<div className="flex items-start gap-2 px-4 py-3 rounded-xl bg-amber-50 text-amber-700 text-sm border border-amber-200">
|
||||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||||
<span>您的合同还有 <strong>{daysToExpire}</strong> 天到期,请关注续签事宜</span>
|
<span>您的合同还有 <strong>{daysToExpire}</strong> 天到期,请关注续签事宜</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 合同概览卡片 */}
|
{/* 合同概览卡片 — 渐变头部 */}
|
||||||
<Card className="p-5">
|
<div className="rounded-2xl overflow-hidden shadow-sm border border-gray-100">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
{/* 头部 */}
|
||||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
<div className="bg-gradient-to-br from-indigo-600 to-indigo-700 px-5 py-4 text-white">
|
||||||
<FileText className="w-5 h-5 text-primary" />
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-white/20 flex items-center justify-center flex-shrink-0 backdrop-blur-sm">
|
||||||
|
<FileText className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-semibold text-gray-900 truncate">
|
<div className="text-base font-bold truncate">
|
||||||
{contract.contractType === 'FIXED' ? '固定期限劳动合同' : contract.contractType === 'UNFIXED' ? '无固定期限劳动合同' : '未签订'}
|
{contractTypeMap[contract.contractType] || '劳动合同'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-indigo-100 mt-0.5 flex items-center gap-1.5">
|
||||||
|
{contract.signMethod === 'PAPER' ? (
|
||||||
|
<><FileSignature className="w-3.5 h-3.5" />纸质合同</>
|
||||||
|
) : (
|
||||||
|
<><ShieldCheck className="w-3.5 h-3.5" />电子合同</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 签署状态徽章 */}
|
||||||
|
<div className={`px-2.5 py-1 rounded-full text-xs font-medium flex-shrink-0 ${
|
||||||
|
isConfirmed ? 'bg-green-400/20 text-green-100' : 'bg-amber-400/20 text-amber-100'
|
||||||
|
}`}>
|
||||||
|
{isConfirmed ? '已签署' : '待签署'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">{contract.signMethod === 'PAPER' ? '纸质合同' : '电子合同'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* 信息区 */}
|
||||||
<InfoRow icon={Calendar} label="合同开始" value={new Date(contract.startDate).toISOString().slice(0, 10)} />
|
<div className="bg-white px-5 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
|
||||||
|
<InfoCell icon={Calendar} label="合同开始" value={contract.startDate ? new Date(contract.startDate).toISOString().slice(0, 10) : '—'} />
|
||||||
{contract.endDate && (
|
{contract.endDate && (
|
||||||
<InfoRow icon={Calendar} label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />
|
<InfoCell icon={Calendar} label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />
|
||||||
)}
|
)}
|
||||||
{contract.contractYears > 0 && (
|
{contract.contractYears > 0 && (
|
||||||
<InfoRow icon={Briefcase} label="合同期限" value={`${contract.contractYears}年`} />
|
<InfoCell icon={Briefcase} label="合同期限" value={`${contract.contractYears}年`} />
|
||||||
)}
|
)}
|
||||||
{contract.signDate && (
|
{contract.signDate && (
|
||||||
<InfoRow icon={Calendar} label="签订日期" value={new Date(contract.signDate).toISOString().slice(0, 10)} />
|
<InfoCell icon={Calendar} label="签订日期" value={new Date(contract.signDate).toISOString().slice(0, 10)} />
|
||||||
|
)}
|
||||||
|
{contract.baseSalary > 0 && (
|
||||||
|
<InfoCell icon={Briefcase} label="基本工资" value={`¥${fmt(Number(contract.baseSalary))}`} />
|
||||||
|
)}
|
||||||
|
{contract.performanceSalary > 0 && (
|
||||||
|
<InfoCell icon={Briefcase} label="绩效工资" value={`¥${fmt(Number(contract.performanceSalary))}`} />
|
||||||
)}
|
)}
|
||||||
{contract.probationMonths > 0 && (
|
{contract.probationMonths > 0 && (
|
||||||
<InfoRow icon={Clock} label="试用期" value={`${contract.probationMonths}个月`} />
|
<InfoCell icon={Clock} label="试用期" value={`${contract.probationMonths}个月`} />
|
||||||
)}
|
)}
|
||||||
{contract.probationSalary > 0 && (
|
{contract.probationSalary > 0 && (
|
||||||
<InfoRow icon={Briefcase} label="试用期工资" value={`¥${fmt(Number(contract.probationSalary))}`} />
|
<InfoCell icon={Briefcase} label="试用期工资" value={`¥${fmt(Number(contract.probationSalary))}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 签署确认记录 */}
|
{/* 签署确认记录 */}
|
||||||
<Card className="p-4">
|
<div className="rounded-2xl overflow-hidden shadow-sm border border-gray-100">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">签署确认</h3>
|
<div className="bg-white px-5 py-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-primary" />
|
||||||
|
签署确认
|
||||||
|
</h3>
|
||||||
{isConfirmed ? (
|
{isConfirmed ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-green-50 border border-green-100">
|
||||||
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
|
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||||
<Check className="w-5 h-5 text-green-600" />
|
<Check className="w-5 h-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
@@ -120,9 +158,9 @@ export default function MyContract() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 text-sm text-amber-600">
|
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-amber-50 border border-amber-100">
|
||||||
<AlertCircle className="w-4 h-4" />
|
<AlertCircle className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||||
<span>合同尚未确认签署</span>
|
<span className="text-sm text-amber-700">合同尚未确认签署</span>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="secondary" onClick={handleResend} disabled={resending}>
|
<Button size="sm" variant="secondary" onClick={handleResend} disabled={resending}>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${resending ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${resending ? 'animate-spin' : ''}`} />
|
||||||
@@ -131,21 +169,23 @@ export default function MyContract() {
|
|||||||
{resendMsg && <div className="text-xs text-gray-500">{resendMsg}</div>}
|
{resendMsg && <div className="text-xs text-gray-500">{resendMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoRow({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
|
/** 信息单元格 — 图标 + 标签 + 值 的垂直排列 */
|
||||||
|
function InfoCell({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-1.5">
|
||||||
<Icon className="w-4 h-4 text-gray-400" />
|
<Icon className="w-3.5 h-3.5 text-gray-400" />
|
||||||
<span className="text-sm text-gray-500">{label}</span>
|
<span className="text-xs text-gray-400">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-gray-800 text-right truncate">{value}</span>
|
<span className="text-sm font-medium text-gray-800 pl-5">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
|
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
|
||||||
|
|
||||||
|
// 判断最新合同类型,劳务/实习/兼职/外包等非劳动合同不缴纳社保公积金
|
||||||
|
const latestContract = profile.contracts?.[0]
|
||||||
|
const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(latestContract.contractType)
|
||||||
|
|
||||||
|
// 从在保记录中提取社保/公积金账户名
|
||||||
|
const activeSocialAccount = profile.socialInsRecords?.find((r: any) => !r.endMonth)?.account
|
||||||
|
const activeHousingAccount = profile.housingFundRecords?.find((r: any) => !r.endMonth)?.account
|
||||||
|
|
||||||
const addAttachmentMutation = useMutation({
|
const addAttachmentMutation = useMutation({
|
||||||
mutationFn: (data: any) => attachmentApi.add(data),
|
mutationFn: (data: any) => attachmentApi.add(data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||||||
@@ -136,6 +144,18 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
enabled: !editing && !!profile.socialInsBase && !!profile.city,
|
enabled: !editing && !!profile.socialInsBase && !!profile.city,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 查询公积金费用明细
|
||||||
|
const { data: housingDetail } = useQuery<any>({
|
||||||
|
queryKey: ['housing-calc', profile.id, profile.housingFundBase, profile.city],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!profile.housingFundBase || !profile.city) return null
|
||||||
|
try {
|
||||||
|
return await socialInsuranceApi.housingCalculate(Number(profile.housingFundBase), profile.city)
|
||||||
|
} catch { return null }
|
||||||
|
},
|
||||||
|
enabled: !editing && !!profile.housingFundBase && !!profile.city,
|
||||||
|
})
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) {
|
if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) {
|
||||||
toast.error('参保城市变更必须填写变更原因')
|
toast.error('参保城市变更必须填写变更原因')
|
||||||
@@ -344,26 +364,27 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 薪税信息 */}
|
{/* 社保公积金信息(劳务/实习/兼职/外包等非劳动合同不显示) */}
|
||||||
|
{!isNoSocialContract && (
|
||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-4 pt-4 border-t">
|
||||||
<h3 className="text-xs font-medium text-gray-600 mb-3">薪税信息</h3>
|
<h3 className="text-xs font-medium text-gray-600 mb-3">社保公积金信息</h3>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<div className="grid md:grid-cols-4 gap-4">
|
<div className="grid md:grid-cols-4 gap-4">
|
||||||
<div className="flex justify-between border-b pb-2 text-xs">
|
<div className="flex justify-between border-b pb-2 text-xs">
|
||||||
<span className="text-gray-500">参保城市</span>
|
<span className="text-gray-500">社保账户</span>
|
||||||
<span className="font-medium">{profile.city || '未设置'}</span>
|
<span className="font-medium">{activeSocialAccount?.name || profile.city || '未设置'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between border-b pb-2 text-xs">
|
<div className="flex justify-between border-b pb-2 text-xs">
|
||||||
<span className="text-gray-500">社保缴费基数</span>
|
<span className="text-gray-500">社保缴费基数</span>
|
||||||
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
|
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between border-b pb-2 text-xs">
|
<div className="flex justify-between border-b pb-2 text-xs">
|
||||||
<span className="text-gray-500">公积金缴费基数</span>
|
<span className="text-gray-500">公积金账户</span>
|
||||||
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
|
<span className="font-medium">{activeHousingAccount?.name || profile.city || '未设置'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between border-b pb-2 text-xs">
|
<div className="flex justify-between border-b pb-2 text-xs">
|
||||||
<span className="text-gray-500">专项附加扣除</span>
|
<span className="text-gray-500">公积金缴费基数</span>
|
||||||
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
|
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
|
||||||
</div>
|
</div>
|
||||||
{socialDetail?.items?.length > 0 && (
|
{socialDetail?.items?.length > 0 && (
|
||||||
<div className="md:col-span-4 mt-2">
|
<div className="md:col-span-4 mt-2">
|
||||||
@@ -384,24 +405,45 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{housingDetail && (
|
||||||
|
<div className="md:col-span-4 mt-2">
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-2">公积金费用明细</div>
|
||||||
|
<div className="grid md:grid-cols-3 gap-2">
|
||||||
|
<div className="px-2 py-1.5 rounded bg-gray-50 text-xs">
|
||||||
|
<div className="font-medium text-gray-700">企业公积金({housingDetail.orgRate}%)</div>
|
||||||
|
<div className="text-gray-500 mt-0.5">¥{fmt(housingDetail.housingOrg)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1.5 rounded bg-gray-50 text-xs">
|
||||||
|
<div className="font-medium text-gray-700">个人公积金({housingDetail.empRate}%)</div>
|
||||||
|
<div className="text-gray-500 mt-0.5">¥{fmt(housingDetail.housingEmp)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1.5 rounded bg-gray-50 text-xs">
|
||||||
|
<div className="font-medium text-gray-700">合计</div>
|
||||||
|
<div className="text-gray-500 mt-0.5">¥{fmt(housingDetail.total)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{housingDetail.capped && <div className="text-xs text-amber-600 mt-1">提示:公积金基数已封顶(上限 ¥{fmt(housingDetail.actualBase)})</div>}
|
||||||
|
{housingDetail.floored && <div className="text-xs text-amber-600 mt-1">提示:公积金基数已保底(下限 ¥{fmt(housingDetail.actualBase)})</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid md:grid-cols-4 gap-4">
|
<div className="grid md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>参保城市</Label>
|
<Label>社保账户</Label>
|
||||||
<Input placeholder="如 北京" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} />
|
<Input placeholder="如 北京社保" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>社保缴费基数</Label>
|
<Label>社保缴费基数</Label>
|
||||||
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} />
|
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>公积金缴费基数</Label>
|
<Label>公积金账户</Label>
|
||||||
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
|
<Input placeholder="如 北京公积金" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} disabled />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>专项附加扣除(元/月)</Label>
|
<Label>公积金缴费基数</Label>
|
||||||
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
|
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
|
||||||
</div>
|
</div>
|
||||||
{form.city !== (profile.city || '') && (
|
{form.city !== (profile.city || '') && (
|
||||||
<div className="md:col-span-4">
|
<div className="md:col-span-4">
|
||||||
@@ -411,7 +453,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。</p>
|
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。</p>
|
||||||
{!editing && (!profile.socialInsBase || !profile.housingFundBase) && (
|
{!editing && (!profile.socialInsBase || !profile.housingFundBase) && (
|
||||||
<div className="mt-2 flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs">
|
<div className="mt-2 flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
@@ -419,6 +461,26 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="flex items-center gap-4">
|
||||||
|
<span className="text-xs text-gray-500">每月扣除额</span>
|
||||||
|
<span className="text-sm font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>专项附加扣除(元/月)</Label>
|
||||||
|
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 mt-2">由员工在portal端填报,无则为0。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 特殊状态 */}
|
{/* 特殊状态 */}
|
||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
|||||||
Reference in New Issue
Block a user