年假计算公式

parent 8f7cb21f
......@@ -10,6 +10,7 @@
},
"dependencies": {
"bcryptjs": "^2.4.3",
"chinese-holidays": "^1.8.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
......
......@@ -25,8 +25,8 @@ CREATE TABLE IF NOT EXISTS employees (
department VARCHAR(50) COMMENT '部门',
entry_date DATE COMMENT '入职时间',
status ENUM('在职','离职') NOT NULL DEFAULT '在职' COMMENT '状态',
annual_leave_days DECIMAL(5,1) DEFAULT 3.0 COMMENT '年假天数',
used_leave_days DECIMAL(5,1) DEFAULT 0.0 COMMENT '已用年假天数',
annual_leave_days DECIMAL(5,1) DEFAULT 0.0 COMMENT '年假天数(快照,实时计算以annual_leave_yearly为准)',
used_leave_days DECIMAL(5,1) DEFAULT 0.0 COMMENT '已用年假天数(快照,实时计算以annual_leave_yearly为准)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB COMMENT='员工表';
......@@ -108,17 +108,44 @@ CREATE TABLE IF NOT EXISTS compensatory_leaves (
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='调休表';
-- 法定假日表
CREATE TABLE IF NOT EXISTS holidays (
id INT PRIMARY KEY AUTO_INCREMENT,
holiday_date DATE NOT NULL COMMENT '日期',
is_holiday TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=假日, 0=调休工作日',
description VARCHAR(100) COMMENT '说明(如春节、国庆)',
UNIQUE KEY uk_date (holiday_date)
) ENGINE=InnoDB COMMENT='法定假日及调休表';
-- 年度年假额度表
CREATE TABLE IF NOT EXISTS annual_leave_yearly (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL COMMENT '员工ID',
year_start DATE NOT NULL COMMENT '年假周期起始日(满周年日)',
year_end DATE NOT NULL COMMENT '年假周期截止日(次年5月31日)',
total_days DECIMAL(5,1) NOT NULL COMMENT '该周期总年假天数',
used_days DECIMAL(5,1) DEFAULT 0.0 COMMENT '该周期已用天数',
status ENUM('active','expired') DEFAULT 'active' COMMENT 'active=使用中, expired=已过期',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
INDEX idx_emp_period (employee_id, year_start)
) ENGINE=InnoDB COMMENT='年度年假额度表';
-- 年假记录表
CREATE TABLE IF NOT EXISTS annual_leave_records (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL COMMENT '员工ID',
leave_date DATE NOT NULL COMMENT '请假日期',
days DECIMAL(4,1) NOT NULL COMMENT '请假天数',
leave_date DATE NOT NULL COMMENT '请假开始日期',
end_date DATE COMMENT '请假结束日期(日历日)',
days DECIMAL(4,1) NOT NULL COMMENT '请假天数(工作日)',
leave_year_id INT COMMENT '关联 annual_leave_yearly.id,标识属于哪个年假周期',
description TEXT COMMENT '说明',
status ENUM('待审批','已批准','已拒绝') DEFAULT '待审批' COMMENT '状态',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
FOREIGN KEY (leave_year_id) REFERENCES annual_leave_yearly(id) ON DELETE SET NULL
) ENGINE=InnoDB COMMENT='年假记录表';
-- 操作日志表
......
......@@ -2,14 +2,11 @@ import { Request, Response } from 'express';
import pool from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { logOperation } from '../utils/logger';
// 计算年假天数
const calcAnnualLeaveDays = (entryDate: string): number => {
const entry = new Date(entryDate);
const now = new Date();
const yearsWorked = now.getFullYear() - entry.getFullYear();
return Math.min(3 + yearsWorked, 15); // 最多15天
};
import {
getAnnualLeaveBalance as calcBalance,
getPendingLeaveDays,
calcWorkingDaysSpan,
} from '../services/annualleave.service';
// 获取年假列表
export const getAnnualLeaves = async (req: AuthRequest, res: Response): Promise<void> => {
......@@ -35,7 +32,7 @@ export const getAnnualLeaves = async (req: AuthRequest, res: Response): Promise<
) as any[];
const [rows] = await pool.execute(
`SELECT a.*, e.name as employee_name, e.annual_leave_days, e.used_leave_days
`SELECT a.*, e.name as employee_name
FROM annual_leave_records a
LEFT JOIN employees e ON a.employee_id = e.id
${where} ORDER BY a.leave_date DESC LIMIT ${Number(limit)} OFFSET ${offset}`,
......@@ -63,14 +60,32 @@ export const createAnnualLeave = async (req: AuthRequest, res: Response): Promis
const { leave_date, days, description } = req.body;
const employee_id = req.user?.id;
if (!employee_id) {
res.status(401).json({ code: 401, message: '未认证' });
return;
}
if (!leave_date || !days) {
res.status(400).json({ code: 400, message: '日期和天数不能为空' });
return;
}
// 获取员工年假信息
// 校验每次不少于0.5天
const leaveDays = Number(days);
if (leaveDays < 0.5) {
res.status(400).json({ code: 400, message: '每次请假不得少于0.5天' });
return;
}
// 校验0.5天的倍数
if (leaveDays % 0.5 !== 0) {
res.status(400).json({ code: 400, message: '请假天数必须为0.5的倍数' });
return;
}
// 获取员工入职日期
const [empRows] = await pool.execute(
'SELECT annual_leave_days, used_leave_days FROM employees WHERE id = ?',
'SELECT entry_date FROM employees WHERE id = ?',
[employee_id]
) as any[];
......@@ -79,20 +94,20 @@ export const createAnnualLeave = async (req: AuthRequest, res: Response): Promis
return;
}
const emp = (empRows as any[])[0];
const remaining = Number(emp.annual_leave_days) - Number(emp.used_leave_days);
const entryDate = (empRows as any[])[0].entry_date;
// 也统计待审批的申请
const [pendingRows] = await pool.execute(
`SELECT COALESCE(SUM(days), 0) as pending FROM annual_leave_records
WHERE employee_id = ? AND status = '待审批'`,
[employee_id]
) as any[];
const pendingDays = Number((pendingRows as any[])[0].pending);
const available = remaining - pendingDays;
// 计算年假余额
const balance = await calcBalance(employee_id as number, entryDate);
if (!balance.hasRight) {
res.status(400).json({ code: 400, message: '入职未满一年,暂无年假资格' });
return;
}
// 待审批天数
const pendingDays = await getPendingLeaveDays(employee_id as number);
const available = balance.remainingDays - pendingDays;
if (Number(days) > available) {
if (leaveDays > available) {
res.status(400).json({
code: 400,
message: `年假天数不足,当前可用年假:${available}天(含待审批)`
......@@ -100,16 +115,41 @@ export const createAnnualLeave = async (req: AuthRequest, res: Response): Promis
return;
}
// 计算工作日结束日期(排除周末+法定假日)
const { endDate, calendarDays } = await calcWorkingDaysSpan(leave_date, leaveDays);
// 确定该请假属于哪个年假周期
// 优先使用当前周期,其次使用结转周期
let leaveYearId = balance.currentPeriod?.id || null;
// 如果当前周期剩余不足,需要从结转周期扣
if (balance.currentPeriod && leaveDays > balance.currentPeriod.remaining) {
// 部分使用当前周期,部分使用结转周期
// 先全部记录在当前周期,审批时再细算
leaveYearId = balance.currentPeriod.id!;
} else if (!balance.currentPeriod || balance.currentPeriod.remaining <= 0) {
// 当前周期没有剩余,使用结转周期
leaveYearId = balance.carriedPeriod?.id || null;
}
const [result] = await pool.execute(
`INSERT INTO annual_leave_records (employee_id, leave_date, days, description)
VALUES (?, ?, ?, ?)`,
[employee_id, leave_date, days, description]
`INSERT INTO annual_leave_records (employee_id, leave_date, end_date, days, leave_year_id, description)
VALUES (?, ?, ?, ?, ?, ?)`,
[employee_id, leave_date, endDate, leaveDays, leaveYearId, description || null]
) as any[];
// 记录日志
logOperation('年假管理', `提交了${days}天年假申请`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
logOperation('年假管理', `提交了${leaveDays}天年假申请(${leave_date}~${endDate},占${calendarDays}个日历日)`, req.user?.id || null, req.user?.name || '系统', req.user?.role || '');
res.json({ code: 200, message: '年假申请已提交', data: { id: (result as any).insertId } });
res.json({
code: 200,
message: '年假申请已提交',
data: {
id: (result as any).insertId,
end_date: endDate,
calendar_days: calendarDays,
}
});
} catch (error) {
console.error(error);
res.status(500).json({ code: 500, message: '服务器错误' });
......@@ -128,7 +168,7 @@ export const approveAnnualLeave = async (req: AuthRequest, res: Response): Promi
}
const [rows] = await pool.execute(
'SELECT employee_id, days, status as cur_status FROM annual_leave_records WHERE id = ?',
'SELECT employee_id, days, leave_year_id, status as cur_status FROM annual_leave_records WHERE id = ?',
[id]
) as any[];
......@@ -139,11 +179,23 @@ export const approveAnnualLeave = async (req: AuthRequest, res: Response): Promi
const record = (rows as any[])[0];
// 如果批准,更新员工已用年假
// 如果批准,更新年假周期的used_days
if (status === '已批准' && record.cur_status === '待审批') {
const leaveDays = Number(record.days);
const yearId = record.leave_year_id;
if (yearId) {
// 更新对应年假周期
await pool.execute(
'UPDATE annual_leave_yearly SET used_days = used_days + ? WHERE id = ?',
[leaveDays, yearId]
);
}
// 同步更新员工表快照
await pool.execute(
'UPDATE employees SET used_leave_days = used_leave_days + ? WHERE id = ?',
[record.days, record.employee_id]
[leaveDays, record.employee_id]
);
}
......@@ -161,12 +213,16 @@ export const approveAnnualLeave = async (req: AuthRequest, res: Response): Promi
};
// 获取年假余额
export const getAnnualLeaveBalance = async (req: AuthRequest, res: Response): Promise<void> => {
export const getBalance = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const employee_id = req.user?.id;
if (!employee_id) {
res.status(401).json({ code: 401, message: '未认证' });
return;
}
const [rows] = await pool.execute(
'SELECT annual_leave_days, used_leave_days, entry_date FROM employees WHERE id = ?',
'SELECT entry_date FROM employees WHERE id = ?',
[employee_id]
) as any[];
......@@ -175,17 +231,48 @@ export const getAnnualLeaveBalance = async (req: AuthRequest, res: Response): Pr
return;
}
const emp = (rows as any[])[0];
// 重新计算(可能跨年)
const currentEntitlement = calcAnnualLeaveDays(emp.entry_date);
const entryDate = (rows as any[])[0].entry_date;
const balance = await calcBalance(employee_id, entryDate);
if (!balance.hasRight) {
res.json({
code: 200,
data: {
total_days: 0,
used_days: 0,
remaining_days: 0,
current_period: null,
carried_days: 0,
has_right: false,
message: '入职未满一年,暂无年假资格'
}
});
return;
}
res.json({
code: 200,
data: {
total_days: currentEntitlement,
used_days: emp.used_leave_days,
remaining_days: currentEntitlement - Number(emp.used_leave_days)
total_days: balance.totalDays,
used_days: balance.usedDays,
remaining_days: balance.remainingDays,
current_period: balance.currentPeriod ? {
start: balance.currentPeriod.periodStart.toISOString().slice(0, 10),
end: balance.currentPeriod.periodEnd.toISOString().slice(0, 10),
total: balance.currentPeriod.totalDays,
used: balance.currentPeriod.usedDays,
remaining: balance.currentPeriod.remaining,
is_carryover: balance.currentPeriod.isCarryover,
} : null,
carried_period: balance.carriedPeriod ? {
start: balance.carriedPeriod.periodStart.toISOString().slice(0, 10),
end: balance.carriedPeriod.periodEnd.toISOString().slice(0, 10),
total: balance.carriedPeriod.totalDays,
used: balance.carriedPeriod.usedDays,
remaining: balance.carriedPeriod.remaining,
} : null,
carried_days: balance.carriedPeriod?.remaining || 0,
has_right: true,
}
});
} catch (error) {
......
......@@ -61,19 +61,11 @@ export const createEmployee = async (req: Request, res: Response): Promise<void>
const hashedPassword = await bcrypt.hash(password, 10);
// 计算初始年假天数
let annualLeaveDays = 3;
if (entryDate) {
const entryYear = new Date(entryDate).getFullYear();
const currentYear = new Date().getFullYear();
const yearsWorked = currentYear - entryYear;
annualLeaveDays = Math.min(3 + yearsWorked, 15);
}
// 年假天数初始为0,实际额度由annual_leave_yearly表实时计算
const [result] = await pool.execute(
`INSERT INTO employees (name, password, email, phone, role, department, entry_date, status, annual_leave_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[name, hashedPassword, email, phone, role || '开发', department, entryDate, status || '在职', annualLeaveDays]
[name, hashedPassword, email, phone, role || '开发', department, entryDate, status || '在职', 0]
) as any[];
res.json({ code: 200, message: '员工创建成功', data: { id: result.insertId } });
......@@ -100,10 +92,6 @@ export const updateEmployee = async (req: Request, res: Response): Promise<void>
if (department !== undefined) { updates.push('department = ?'); params.push(department); }
if (entryDate) {
updates.push('entry_date = ?'); params.push(entryDate);
const entryYear = new Date(entryDate).getFullYear();
const currentYear = new Date().getFullYear();
const annualLeaveDays = Math.min(3 + (currentYear - entryYear), 15);
updates.push('annual_leave_days = ?'); params.push(annualLeaveDays);
}
if (status) { updates.push('status = ?'); params.push(status); }
if (password) {
......
......@@ -2,7 +2,9 @@ import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import path from 'path';
import cron from 'node-cron';
import pool from './config/database';
import authRoutes from './routes/auth.routes';
import employeeRoutes from './routes/employee.routes';
import projectRoutes from './routes/project.routes';
......@@ -14,9 +16,56 @@ import annualLeaveRoutes from './routes/annualleave.routes';
import roleRoutes from './routes/role.routes';
import logRoutes from './routes/log.routes';
import { startLogCleanupScheduler } from './utils/scheduler';
import { initHolidayData, startHolidayScheduler } from './services/holiday.service';
import { expireOldLeavePeriods, syncEmployeeLeaveSnapshot } from './services/annualleave.service';
dotenv.config();
/** 确保数据库表存在(针对新增的表) */
const ensureTables = async (): Promise<void> => {
try {
// holidays 表
await pool.execute(`
CREATE TABLE IF NOT EXISTS holidays (
id INT PRIMARY KEY AUTO_INCREMENT,
holiday_date DATE NOT NULL COMMENT '日期',
is_holiday TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=假日, 0=调休工作日',
description VARCHAR(100) COMMENT '说明',
UNIQUE KEY uk_date (holiday_date)
) ENGINE=InnoDB COMMENT='法定假日及调休表'
`);
// annual_leave_yearly 表
await pool.execute(`
CREATE TABLE IF NOT EXISTS annual_leave_yearly (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL COMMENT '员工ID',
year_start DATE NOT NULL COMMENT '年假周期起始日',
year_end DATE NOT NULL COMMENT '年假周期截止日',
total_days DECIMAL(5,1) NOT NULL COMMENT '该周期总年假天数',
used_days DECIMAL(5,1) DEFAULT 0.0 COMMENT '该周期已用天数',
status ENUM('active','expired') DEFAULT 'active' COMMENT 'active=使用中, expired=已过期',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
INDEX idx_emp_period (employee_id, year_start)
) ENGINE=InnoDB COMMENT='年度年假额度表'
`);
// annual_leave_records 表增加 end_date 和 leave_year_id 列(如果表已存在但缺少这些列)
try {
await pool.execute(`ALTER TABLE annual_leave_records ADD COLUMN end_date DATE COMMENT '请假结束日期'`);
} catch (_) { /* 列已存在则忽略 */ }
try {
await pool.execute(`ALTER TABLE annual_leave_records ADD COLUMN leave_year_id INT COMMENT '关联 annual_leave_yearly.id'`);
} catch (_) { /* 列已存在则忽略 */ }
console.log('[初始化] 数据库表检查完成');
} catch (error) {
console.error('[初始化] 数据库表检查失败:', error);
}
};
const app = express();
const PORT = process.env.PORT || 3000;
......@@ -49,10 +98,34 @@ app.get('/api/health', (req, res) => {
res.json({ code: 200, message: 'Server is running', timestamp: new Date() });
});
app.listen(PORT, () => {
// 年假周期过期检查定时器
const startLeaveExpireScheduler = () => {
// 每天凌晨3点检查过期周期,每年6月1日会真正触发过期
cron.schedule('0 3 * * *', async () => {
const now = new Date();
// 在6月1日或之后检查
if (now.getMonth() === 5 && now.getDate() >= 1) {
await expireOldLeavePeriods();
await syncEmployeeLeaveSnapshot();
}
});
console.log('[年假服务] 年假周期过期检查定时器已启动(每天3:00,6月1日起生效)');
};
app.listen(PORT, async () => {
console.log(`🚀 Server is running on http://localhost:${PORT}`);
// 确保数据库表存在
await ensureTables();
// 启动日志自动清理定时器
startLogCleanupScheduler();
// 初始化法定假日数据
await initHolidayData();
// 启动法定假日定时更新
startHolidayScheduler();
// 启动年假周期过期检查(每天凌晨3点执行)
startLeaveExpireScheduler();
// 同步年假快照
await syncEmployeeLeaveSnapshot();
});
export default app;
import { Router } from 'express';
import {
getAnnualLeaves, createAnnualLeave,
approveAnnualLeave, getAnnualLeaveBalance, deleteAnnualLeave
approveAnnualLeave, getBalance, deleteAnnualLeave
} from '../controllers/annualleave.controller';
import { authMiddleware } from '../middleware/auth';
const router = Router();
router.get('/balance', authMiddleware, getAnnualLeaveBalance);
router.get('/balance', authMiddleware, getBalance);
router.get('/', authMiddleware, getAnnualLeaves);
router.post('/', authMiddleware, createAnnualLeave);
router.put('/:id/approve', authMiddleware, approveAnnualLeave);
......
import pool from '../config/database';
import ChineseHolidays from 'chinese-holidays';
/**
* 法定假日服务
* - 项目启动时:同步当年+次年假日数据
* - 每年12月31日23:00:自动更新次年假日数据
*/
interface HolidayItem {
date: string;
isHoliday: boolean;
description: string;
}
/**
* 调用 chinese-holidays 库获取指定年份的假日数据
* chinese-holidays 使用方式:
* const book = await ChineseHolidays.ready();
* book.all() 获取所有假日事件
* 每个事件有 days() 返回日期数组,isHoliday() 判断是否休息日
*/
const fetchYearHolidays = async (year: number): Promise<HolidayItem[]> => {
const holidays: HolidayItem[] = [];
try {
const book = await ChineseHolidays.ready();
const allEvents = book.all(); // 获取所有假日事件(不包含调休工作日)
for (const event of allEvents) {
const days: Date[] = event.days();
for (const date of days) {
// days() 返回 Date 对象数组
const dateStr = date.toISOString().slice(0, 10);
// 只取目标年份的数据
if (!dateStr.startsWith(String(year))) continue;
holidays.push({
date: dateStr,
isHoliday: true, // all() 返回的都是休息日
description: event.name || '法定假日',
});
}
}
} catch (error) {
console.error(`[假日服务] 获取${year}年假日数据失败:`, error);
}
return holidays;
};
/**
* 将假日数据写入数据库
*/
const saveHolidays = async (holidays: HolidayItem[]): Promise<void> => {
if (holidays.length === 0) return;
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
for (const h of holidays) {
await conn.execute(
`INSERT INTO holidays (holiday_date, is_holiday, description)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE is_holiday = VALUES(is_holiday), description = VALUES(description)`,
[h.date, h.isHoliday ? 1 : 0, h.description]
);
}
await conn.commit();
console.log(`[假日服务] 已写入 ${holidays.length} 条假日数据`);
} catch (error) {
await conn.rollback();
console.error('[假日服务] 写入假日数据失败:', error);
throw error;
} finally {
conn.release();
}
};
/**
* 更新指定年份的假日数据
*/
const updateYearHolidays = async (year: number): Promise<void> => {
// 先检查该年份是否已有数据
const [rows] = await pool.execute(
'SELECT COUNT(*) as cnt FROM holidays WHERE YEAR(holiday_date) = ?',
[year]
) as any[];
if ((rows as any[])[0].cnt > 0) {
console.log(`[假日服务] ${year}年假日数据已存在,跳过更新`);
return;
}
const holidays = await fetchYearHolidays(year);
if (holidays.length > 0) {
await saveHolidays(holidays);
}
};
/**
* 项目启动时初始化假日数据
*/
export const initHolidayData = async (): Promise<void> => {
console.log('[假日服务] 开始初始化法定假日数据...');
const currentYear = new Date().getFullYear();
await updateYearHolidays(currentYear);
await updateYearHolidays(currentYear + 1);
console.log('[假日服务] 法定假日数据初始化完成');
};
/**
* 获取假日日期集合(用于工作日计算)
* 返回一个 Set,包含所有休息日(法定假日+周末调休休息日)
*/
export const getHolidaySet = async (): Promise<Set<string>> => {
const [rows] = await pool.execute(
'SELECT holiday_date, is_holiday FROM holidays'
) as any[];
const holidaySet = new Set<string>();
for (const row of rows as any[]) {
// is_holiday=1 表示休息日,需要排除
// is_holiday=0 表示调休工作日,是正常上班日
if (row.is_holiday === 1) {
holidaySet.add(row.holiday_date);
}
}
return holidaySet;
};
/**
* 启动假日定时更新任务
* 每年12月31日23:00更新次年假日数据
* 使用 node-cron 调度,避免 setTimeout 32位溢出问题
*/
export const startHolidayScheduler = (): void => {
// 每天检查一次,如果到了12月31日就更新
const checkAndUpdate = async () => {
const now = new Date();
// 12月31日执行更新
if (now.getMonth() === 11 && now.getDate() === 31 && now.getHours() >= 23) {
const nextYear = now.getFullYear() + 1;
console.log(`[假日服务] 开始更新${nextYear}年法定假日数据...`);
await updateYearHolidays(nextYear);
}
};
// 每小时检查一次
setInterval(checkAndUpdate, 60 * 60 * 1000);
// 启动时也检查一次(防止启动时正好是12月31日)
checkAndUpdate();
console.log('[假日服务] 假日定时更新任务已启动(每小时检查一次,12月31日23:00触发更新)');
};
declare module 'chinese-holidays' {
interface DaysEvent {
name: string;
days(): Date[];
isHoliday(): boolean;
isWorkingday(): boolean;
}
class Book {
all(): DaysEvent[];
isHoliday(date: Date | string): boolean;
isWorkingday(date: Date | string): boolean;
events(): DaysEvent[];
}
class ChineseHolidays {
static ready(options?: { offline?: boolean }): Promise<Book>;
}
export default ChineseHolidays;
}
......@@ -28,7 +28,7 @@
<div class="balance-item">
<div class="balance-label">年假剩余</div>
<div class="balance-value annual">{{ balance.remaining_days ?? '-' }} <span></span></div>
<div class="balance-sub">{{ balance.total_days ?? '-' }} 天 · 已用 {{ balance.used_days ?? '-' }} </div>
<div class="balance-sub">当前周期{{ balance.current_period?.remaining ?? '-' }}天 · 结转{{ balance.carried_days ?? 0 }}天 · 已用{{ balance.used_days ?? '-' }}</div>
</div>
<el-divider direction="vertical" style="height: 80px" />
<div class="balance-item">
......
......@@ -9,22 +9,27 @@
<el-card shadow="never" style="margin-bottom:16px">
<div class="annual-balance">
<div class="ab-item">
<div class="ab-val" style="color:#409eff">{{ balance.total_days ?? '-' }}</div>
<div class="ab-label">年假总额(天)</div>
<div class="ab-val" style="color:#409eff">{{ balance.remaining_days ?? '-' }}</div>
<div class="ab-label">可用年假(天)</div>
</div>
<el-divider direction="vertical" style="height:50px" />
<div class="ab-item">
<div class="ab-val" style="color:#f56c6c">{{ balance.used_days ?? '-' }}</div>
<div class="ab-label">已用(天)</div>
<div class="ab-val" style="color:#67c23a">{{ balance.current_period?.remaining ?? '-' }}</div>
<div class="ab-label">当前周期剩余(天)</div>
</div>
<el-divider direction="vertical" style="height:50px" />
<div class="ab-item">
<div class="ab-val" style="color:#67c23a">{{ balance.remaining_days ?? '-' }}</div>
<div class="ab-label">可用余额(天)</div>
<div class="ab-val" style="color:#e6a23c">{{ balance.carried_days ?? '-' }}</div>
<div class="ab-label">结转可用(天)</div>
</div>
<el-divider direction="vertical" style="height:50px" />
<div class="ab-item">
<div class="ab-val" style="color:#f56c6c">{{ balance.used_days ?? '-' }}</div>
<div class="ab-label">已用(天)</div>
</div>
<div class="ab-rule">
<el-icon><InfoFilled /></el-icon>
年假规则:入职满1年起计算,初始3天,每满1年+1天,最多15天
年假规则:满1年享5天,每满1年+1天(上限10天);周末法定假日不计入;未休完可结转至次年5月31日
</div>
</div>
</el-card>
......@@ -52,9 +57,12 @@
<el-card shadow="never">
<el-table :data="list" stripe v-loading="loading">
<el-table-column prop="employee_name" label="员工" width="90" v-if="isProjecter" />
<el-table-column label="请假日期" width="110">
<el-table-column label="开始日期" width="110">
<template #default="{ row }">{{ dayjs(row.leave_date).format('YYYY-MM-DD') }}</template>
</el-table-column>
<el-table-column label="结束日期" width="110">
<template #default="{ row }">{{ row.end_date ? dayjs(row.end_date).format('YYYY-MM-DD') : '-' }}</template>
</el-table-column>
<el-table-column prop="days" label="天数" width="80" />
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
......@@ -83,18 +91,26 @@
</div>
</el-card>
<el-dialog v-model="dialogVisible" title="申请年假" width="480px" destroy-on-close>
<el-dialog v-model="dialogVisible" title="申请年假" width="500px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="请假日期" prop="leave_date">
<el-form-item label="开始日期" prop="leave_date">
<el-date-picker v-model="form.leave_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item label="请假天数" prop="days">
<el-input-number v-model="form.days" :min="0.5" :max="balance.remaining_days || 0" :step="0.5" style="width:100%" />
<div style="font-size:12px;color:#909399;margin-top:4px">可用余额:{{ balance.remaining_days ?? '-' }} 天</div>
<el-input-number v-model="form.days" :min="0.5" :max="Math.max(0.5, balance.remaining_days || 0)" :step="0.5" style="width:100%" />
<div style="font-size:12px;color:#909399;margin-top:4px">
可用余额:{{ balance.remaining_days ?? '-' }} 天(当前周期{{ balance.current_period?.remaining ?? 0 }}天 + 结转{{ balance.carried_days ?? 0 }}天)
</div>
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="提示" v-if="form.leave_date && form.days >= 0.5">
<div style="font-size:12px;color:#e6a23c">
<el-icon style="vertical-align:middle"><Warning /></el-icon>
请假日期为工作日计算,实际结束日期将自动排除周末和法定假日
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
......@@ -130,7 +146,10 @@ const form = reactive<any>({ leave_date: dayjs().format('YYYY-MM-DD'), days: 1,
const rules = {
leave_date: [{ required: true, message: '请选择日期', trigger: 'change' }],
days: [{ required: true, message: '请输入天数', trigger: 'blur' }]
days: [
{ required: true, message: '请输入天数', trigger: 'blur' },
{ type: 'number', min: 0.5, message: '每次不得少于0.5天', trigger: 'blur' }
]
}
const statusType = (s: string) => ({ '待审批': 'warning', '已批准': 'success', '已拒绝': 'danger' }[s] || '')
......@@ -157,19 +176,24 @@ const openDialog = () => {
}
const handleSave = async () => {
await formRef.value?.validate(async (valid: boolean) => {
if (!valid) return
saving.value = true
try {
await createAnnualLeave(form)
ElMessage.success('申请已提交')
dialogVisible.value = false
loadData()
loadBalance()
} finally {
saving.value = false
}
})
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return // 验证不通过
}
saving.value = true
try {
const res: any = await createAnnualLeave(form)
const endDate = res.data?.end_date || ''
const calendarDays = res.data?.calendar_days || 0
ElMessage.success(`申请已提交,预计结束日期:${endDate}(占用${calendarDays}个日历日)`)
dialogVisible.value = false
loadData()
loadBalance()
} finally {
saving.value = false
}
}
const handleApprove = async (row: any, status: string) => {
......@@ -201,8 +225,9 @@ onMounted(async () => {
.annual-balance {
display: flex;
align-items: center;
gap: 32px;
gap: 24px;
padding: 8px 16px;
flex-wrap: wrap;
}
.ab-item {
text-align: center;
......@@ -223,6 +248,8 @@ onMounted(async () => {
font-size: 12px;
color: #909399;
margin-left: auto;
max-width: 320px;
line-height: 1.4;
}
.action-btns { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; }
</style>
......@@ -42,9 +42,9 @@
<el-table-column label="入职时间" width="110">
<template #default="{ row }">{{ dayjs(row.entry_date).format('YYYY-MM-DD') }}</template>
</el-table-column>
<el-table-column label="年假" width="80">
<el-table-column label="年假(剩余/总额)" width="110">
<template #default="{ row }">
{{ row.annual_leave_days - row.used_leave_days }}/{{ row.annual_leave_days }}
{{ Number(row.annual_leave_days) - Number(row.used_leave_days) }}/{{ row.annual_leave_days }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
......@@ -108,7 +108,7 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="入职时间">
<el-form-item label="入职时间" prop="entry_date">
<el-date-picker v-model="form.entry_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-col>
......@@ -155,7 +155,8 @@ const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
email: [{ required: true, type: 'email', message: '请输入有效邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
entry_date: [{ required: true, message: '请选择入职时间(用于计算年假)', trigger: 'change' }]
}
const roleType = (r: string) => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment