feat(tksupport): Sprint 001 Section C — 전사 휴가관리 구현
- 전사 휴가 부여/관리 (company-holidays) CRUD + 연차차감 트랜잭션 - 전체 휴가관리 대시보드 (vacation-dashboard) 부서별/직원별 현황 - 내 휴가 현황 개선 (/my-status) balance_type별 카드, 전사 휴가일 - requireSupportTeam 미들웨어, 부서명 JOIN, 마이그레이션 002 추가 - 사이드바 roles 기반 메뉴 필터링 (하위호환 유지) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
76
tksupport/api/controllers/companyHolidayController.js
Normal file
76
tksupport/api/controllers/companyHolidayController.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const companyHolidayModel = require('../models/companyHolidayModel');
|
||||||
|
|
||||||
|
const companyHolidayController = {
|
||||||
|
async getHolidays(req, res) {
|
||||||
|
try {
|
||||||
|
const year = parseInt(req.query.year) || new Date().getFullYear();
|
||||||
|
const holidays = await companyHolidayModel.getByYear(year);
|
||||||
|
res.json({ success: true, data: holidays });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('전사 휴가 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createHoliday(req, res) {
|
||||||
|
try {
|
||||||
|
const { holiday_date, holiday_name, holiday_type, description } = req.body;
|
||||||
|
if (!holiday_date || !holiday_name || !holiday_type) {
|
||||||
|
return res.status(400).json({ success: false, error: '필수 필드가 누락되었습니다' });
|
||||||
|
}
|
||||||
|
if (!['PAID', 'ANNUAL_DEDUCT'].includes(holiday_type)) {
|
||||||
|
return res.status(400).json({ success: false, error: '유효하지 않은 휴가 유형입니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created_by = req.user.user_id || req.user.id;
|
||||||
|
const result = await companyHolidayModel.create({ holiday_date, holiday_name, holiday_type, description, created_by });
|
||||||
|
res.status(201).json({ success: true, message: '전사 휴가가 등록되었습니다', data: { id: result.insertId } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') {
|
||||||
|
return res.status(400).json({ success: false, error: '해당 날짜에 이미 전사 휴가가 등록되어 있습니다' });
|
||||||
|
}
|
||||||
|
console.error('전사 휴가 등록 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteHoliday(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const holiday = await companyHolidayModel.getById(id);
|
||||||
|
if (!holiday) {
|
||||||
|
return res.status(404).json({ success: false, error: '해당 전사 휴가를 찾을 수 없습니다' });
|
||||||
|
}
|
||||||
|
if (holiday.deduction_applied_at) {
|
||||||
|
return res.status(400).json({ success: false, error: '차감이 실행된 휴가는 삭제할 수 없습니다' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await companyHolidayModel.delete(id);
|
||||||
|
res.json({ success: true, message: '전사 휴가가 삭제되었습니다' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('전사 휴가 삭제 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyDeduction(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await companyHolidayModel.applyAnnualDeduction(id);
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
message: `연차 차감이 완료되었습니다 (${result.affected_count}명 적용)`,
|
||||||
|
data: result
|
||||||
|
};
|
||||||
|
if (result.missing_balance_count > 0) {
|
||||||
|
response.warning = `${result.missing_balance_count}명의 사원은 연차 잔여일 데이터가 없어 차감되지 않았습니다`;
|
||||||
|
}
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('연차 차감 오류:', error);
|
||||||
|
res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = companyHolidayController;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const vacationRequestModel = require('../models/vacationRequestModel');
|
const vacationRequestModel = require('../models/vacationRequestModel');
|
||||||
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
||||||
|
const companyHolidayModel = require('../models/companyHolidayModel');
|
||||||
const { getPool } = require('../middleware/auth');
|
const { getPool } = require('../middleware/auth');
|
||||||
|
|
||||||
const vacationController = {
|
const vacationController = {
|
||||||
@@ -228,6 +229,29 @@ const vacationController = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── 내 휴가 현황 ───
|
||||||
|
|
||||||
|
async getMyStatus(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id || req.user.id;
|
||||||
|
const year = parseInt(req.query.year) || new Date().getFullYear();
|
||||||
|
const startOfYear = `${year}-01-01`;
|
||||||
|
const endOfYear = `${year}-12-31`;
|
||||||
|
const [balances, requests, holidays] = await Promise.all([
|
||||||
|
vacationBalanceModel.getByUserAndYear(userId, year),
|
||||||
|
vacationRequestModel.getAll({ user_id: userId, start_date: startOfYear, end_date: endOfYear }),
|
||||||
|
companyHolidayModel.getByYear(year)
|
||||||
|
]);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { balances, requests, company_holidays: holidays, overtime_hours: null }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('내 휴가 현황 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ─── 잔여일 ───
|
// ─── 잔여일 ───
|
||||||
|
|
||||||
async getMyBalance(req, res) {
|
async getMyBalance(req, res) {
|
||||||
|
|||||||
52
tksupport/api/controllers/vacationDashboardController.js
Normal file
52
tksupport/api/controllers/vacationDashboardController.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const vacationDashboardModel = require('../models/vacationDashboardModel');
|
||||||
|
|
||||||
|
const vacationDashboardController = {
|
||||||
|
async getDashboard(req, res) {
|
||||||
|
try {
|
||||||
|
const year = parseInt(req.query.year) || new Date().getFullYear();
|
||||||
|
const filters = {
|
||||||
|
department_id: req.query.department_id || null,
|
||||||
|
search_name: req.query.search_name || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const [summary, rows] = await Promise.all([
|
||||||
|
vacationDashboardModel.getSummary(year),
|
||||||
|
vacationDashboardModel.getEmployeeList(year, filters)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// user_id별 그룹핑
|
||||||
|
const employeeMap = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (!employeeMap[row.user_id]) {
|
||||||
|
employeeMap[row.user_id] = {
|
||||||
|
user_id: row.user_id,
|
||||||
|
name: row.name,
|
||||||
|
username: row.username,
|
||||||
|
department_name: row.department_name,
|
||||||
|
hire_date: row.hire_date,
|
||||||
|
balances: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row.id) {
|
||||||
|
employeeMap[row.user_id].balances.push({
|
||||||
|
balance_type: row.balance_type,
|
||||||
|
type_name: row.type_name,
|
||||||
|
type_code: row.type_code,
|
||||||
|
total_days: row.total_days,
|
||||||
|
used_days: row.used_days,
|
||||||
|
remaining_days: row.remaining_days,
|
||||||
|
expires_at: row.expires_at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const employees = Object.values(employeeMap);
|
||||||
|
|
||||||
|
res.json({ success: true, data: { summary, employees } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('휴가 대시보드 조회 오류:', error);
|
||||||
|
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = vacationDashboardController;
|
||||||
25
tksupport/api/db/migrations/002_section_c_additions.sql
Normal file
25
tksupport/api/db/migrations/002_section_c_additions.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Section A 동시 개발 대비: 필요 테이블/컬럼 IF NOT EXISTS로 안전 생성
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS company_holidays (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
holiday_date DATE NOT NULL,
|
||||||
|
holiday_name VARCHAR(100) NOT NULL,
|
||||||
|
holiday_type ENUM('PAID', 'ANNUAL_DEDUCT') NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by INT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES sso_users(user_id),
|
||||||
|
UNIQUE KEY uq_holiday_date (holiday_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Section C 전용: 중복 차감 방지
|
||||||
|
ALTER TABLE company_holidays
|
||||||
|
ADD COLUMN IF NOT EXISTS deduction_applied_at TIMESTAMP NULL
|
||||||
|
COMMENT '연차차감 실행 시각 (NULL=미실행)';
|
||||||
|
|
||||||
|
-- sp_vacation_balances 컬럼 추가 (Section A 대비)
|
||||||
|
ALTER TABLE sp_vacation_balances
|
||||||
|
ADD COLUMN IF NOT EXISTS balance_type ENUM('AUTO','MANUAL','CARRY_OVER','LONG_SERVICE','COMPANY_GRANT')
|
||||||
|
DEFAULT 'AUTO';
|
||||||
|
ALTER TABLE sp_vacation_balances
|
||||||
|
ADD COLUMN IF NOT EXISTS expires_at DATE NULL;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const vacationRoutes = require('./routes/vacationRoutes');
|
const vacationRoutes = require('./routes/vacationRoutes');
|
||||||
|
const companyHolidayRoutes = require('./routes/companyHolidayRoutes');
|
||||||
|
const vacationDashboardRoutes = require('./routes/vacationDashboardRoutes');
|
||||||
const vacationRequestModel = require('./models/vacationRequestModel');
|
const vacationRequestModel = require('./models/vacationRequestModel');
|
||||||
const { requireAuth } = require('./middleware/auth');
|
const { requireAuth } = require('./middleware/auth');
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ app.get('/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
|
app.use('/api/vacation/company', companyHolidayRoutes);
|
||||||
|
app.use('/api/vacation/dashboard', vacationDashboardRoutes);
|
||||||
app.use('/api/vacation', vacationRoutes);
|
app.use('/api/vacation', vacationRoutes);
|
||||||
|
|
||||||
// 404
|
// 404
|
||||||
|
|||||||
@@ -75,4 +75,21 @@ function requirePage(pageName) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getPool, extractToken, requireAuth, requireAdmin, requirePage };
|
function requireSupportTeam(req, res, next) {
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ success: false, error: '인증이 필요합니다' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
if (!['support_team', 'admin', 'system'].includes((decoded.role || '').toLowerCase())) {
|
||||||
|
return res.status(403).json({ success: false, error: '지원팀 이상 권한이 필요합니다' });
|
||||||
|
}
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ success: false, error: '유효하지 않은 토큰입니다' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPool, extractToken, requireAuth, requireAdmin, requireSupportTeam, requirePage };
|
||||||
|
|||||||
93
tksupport/api/models/companyHolidayModel.js
Normal file
93
tksupport/api/models/companyHolidayModel.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const { getPool } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const companyHolidayModel = {
|
||||||
|
async getByYear(year) {
|
||||||
|
const db = getPool();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT * FROM company_holidays WHERE YEAR(holiday_date) = ? ORDER BY holiday_date',
|
||||||
|
[year]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const db = getPool();
|
||||||
|
const [rows] = await db.query('SELECT * FROM company_holidays WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data) {
|
||||||
|
const db = getPool();
|
||||||
|
const [result] = await db.query(
|
||||||
|
'INSERT INTO company_holidays (holiday_date, holiday_name, holiday_type, description, created_by) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[data.holiday_date, data.holiday_name, data.holiday_type, data.description || null, data.created_by]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id) {
|
||||||
|
const db = getPool();
|
||||||
|
const [result] = await db.query('DELETE FROM company_holidays WHERE id = ?', [id]);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyAnnualDeduction(holidayId) {
|
||||||
|
const db = getPool();
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 1. SELECT FOR UPDATE — 동시 실행 방지
|
||||||
|
const [holidays] = await conn.query(
|
||||||
|
'SELECT * FROM company_holidays WHERE id = ? FOR UPDATE', [holidayId]
|
||||||
|
);
|
||||||
|
if (holidays.length === 0) {
|
||||||
|
throw new Error('해당 전사 휴가를 찾을 수 없습니다');
|
||||||
|
}
|
||||||
|
const holiday = holidays[0];
|
||||||
|
if (holiday.holiday_type !== 'ANNUAL_DEDUCT') {
|
||||||
|
throw new Error('연차차감 유형의 휴가만 차감할 수 있습니다');
|
||||||
|
}
|
||||||
|
if (holiday.deduction_applied_at) {
|
||||||
|
throw new Error('이미 차감이 실행된 휴가입니다');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. vacation_types에서 ANNUAL_FULL id 조회
|
||||||
|
const [types] = await conn.query(
|
||||||
|
"SELECT id FROM vacation_types WHERE type_code = 'ANNUAL_FULL'"
|
||||||
|
);
|
||||||
|
if (types.length === 0) {
|
||||||
|
throw new Error('연차(ANNUAL_FULL) 유형이 존재하지 않습니다');
|
||||||
|
}
|
||||||
|
const typeId = types[0].id;
|
||||||
|
|
||||||
|
// 3. 전 활성 사원 연차 차감 (잔여일 무관 일괄 적용)
|
||||||
|
const [result] = await conn.query(`
|
||||||
|
UPDATE sp_vacation_balances SET used_days = used_days + 1, updated_at = NOW()
|
||||||
|
WHERE vacation_type_id = ? AND year = YEAR(?) AND balance_type = 'AUTO'
|
||||||
|
AND user_id IN (SELECT user_id FROM sso_users WHERE is_active = 1)
|
||||||
|
`, [typeId, holiday.holiday_date]);
|
||||||
|
|
||||||
|
// 4. 차감 완료 표시
|
||||||
|
await conn.query(
|
||||||
|
'UPDATE company_holidays SET deduction_applied_at = NOW() WHERE id = ?', [holidayId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. balance 없는 사원 수 체크 (경고용)
|
||||||
|
const [activeUsers] = await conn.query(
|
||||||
|
'SELECT COUNT(*) as cnt FROM sso_users WHERE is_active = 1'
|
||||||
|
);
|
||||||
|
const missing = activeUsers[0].cnt - result.affectedRows;
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { affected_count: result.affectedRows, missing_balance_count: missing };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = companyHolidayModel;
|
||||||
@@ -6,6 +6,8 @@ const vacationBalanceModel = {
|
|||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vb.*,
|
vb.*,
|
||||||
|
vb.balance_type,
|
||||||
|
vb.expires_at,
|
||||||
vt.type_name,
|
vt.type_name,
|
||||||
vt.type_code,
|
vt.type_code,
|
||||||
vt.priority,
|
vt.priority,
|
||||||
@@ -13,7 +15,7 @@ const vacationBalanceModel = {
|
|||||||
(vb.total_days - vb.used_days) as remaining_days
|
(vb.total_days - vb.used_days) as remaining_days
|
||||||
FROM sp_vacation_balances vb
|
FROM sp_vacation_balances vb
|
||||||
INNER JOIN vacation_types vt ON vb.vacation_type_id = vt.id
|
INNER JOIN vacation_types vt ON vb.vacation_type_id = vt.id
|
||||||
WHERE vb.user_id = ? AND vb.year = ?
|
WHERE vb.user_id = ? AND (vb.year = ? OR (vb.balance_type = 'LONG_SERVICE' AND vb.expires_at IS NULL))
|
||||||
ORDER BY vt.priority ASC, vt.type_name ASC
|
ORDER BY vt.priority ASC, vt.type_name ASC
|
||||||
`, [userId, year]);
|
`, [userId, year]);
|
||||||
return rows;
|
return rows;
|
||||||
@@ -24,9 +26,12 @@ const vacationBalanceModel = {
|
|||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vb.*,
|
vb.*,
|
||||||
|
vb.balance_type,
|
||||||
su.name as user_name,
|
su.name as user_name,
|
||||||
su.username,
|
su.username,
|
||||||
su.hire_date,
|
su.hire_date,
|
||||||
|
su.department_id,
|
||||||
|
COALESCE(d.department_name, '미배정') as department_name,
|
||||||
vt.type_name,
|
vt.type_name,
|
||||||
vt.type_code,
|
vt.type_code,
|
||||||
vt.priority,
|
vt.priority,
|
||||||
@@ -34,7 +39,8 @@ const vacationBalanceModel = {
|
|||||||
FROM sp_vacation_balances vb
|
FROM sp_vacation_balances vb
|
||||||
INNER JOIN sso_users su ON vb.user_id = su.user_id
|
INNER JOIN sso_users su ON vb.user_id = su.user_id
|
||||||
INNER JOIN vacation_types vt ON vb.vacation_type_id = vt.id
|
INNER JOIN vacation_types vt ON vb.vacation_type_id = vt.id
|
||||||
WHERE vb.year = ? AND su.is_active = 1
|
LEFT JOIN departments d ON su.department_id = d.department_id
|
||||||
|
WHERE (vb.year = ? OR (vb.balance_type = 'LONG_SERVICE' AND vb.expires_at IS NULL)) AND su.is_active = 1
|
||||||
ORDER BY su.name ASC, vt.priority ASC
|
ORDER BY su.name ASC, vt.priority ASC
|
||||||
`, [year]);
|
`, [year]);
|
||||||
return rows;
|
return rows;
|
||||||
@@ -42,14 +48,16 @@ const vacationBalanceModel = {
|
|||||||
|
|
||||||
async allocate(data) {
|
async allocate(data) {
|
||||||
const db = getPool();
|
const db = getPool();
|
||||||
|
const balanceType = data.balance_type || 'AUTO';
|
||||||
|
const expiresAt = data.expires_at || null;
|
||||||
const [result] = await db.query(`
|
const [result] = await db.query(`
|
||||||
INSERT INTO sp_vacation_balances (user_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
INSERT INTO sp_vacation_balances (user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at)
|
||||||
VALUES (?, ?, ?, ?, 0, ?, ?)
|
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
total_days = VALUES(total_days),
|
total_days = VALUES(total_days),
|
||||||
notes = VALUES(notes),
|
notes = VALUES(notes),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
`, [data.user_id, data.vacation_type_id, data.year, data.total_days, data.notes || null, data.created_by]);
|
`, [data.user_id, data.vacation_type_id, data.year, data.total_days, data.notes || null, data.created_by, balanceType, expiresAt]);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
55
tksupport/api/models/vacationDashboardModel.js
Normal file
55
tksupport/api/models/vacationDashboardModel.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const { getPool } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const vacationDashboardModel = {
|
||||||
|
async getSummary(year) {
|
||||||
|
const db = getPool();
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT
|
||||||
|
d.department_id, COALESCE(d.department_name, '미배정') as department_name,
|
||||||
|
COUNT(DISTINCT su.user_id) as employee_count,
|
||||||
|
AVG(vb.total_days - vb.used_days) as avg_remaining,
|
||||||
|
SUM(CASE WHEN (vb.total_days - vb.used_days) <= 2 THEN 1 ELSE 0 END) as low_balance_count
|
||||||
|
FROM sso_users su
|
||||||
|
LEFT JOIN departments d ON su.department_id = d.department_id
|
||||||
|
LEFT JOIN sp_vacation_balances vb ON su.user_id = vb.user_id
|
||||||
|
AND vb.year = ? AND vb.balance_type = 'AUTO'
|
||||||
|
WHERE su.is_active = 1
|
||||||
|
GROUP BY d.department_id
|
||||||
|
`, [year]);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEmployeeList(year, filters = {}) {
|
||||||
|
const db = getPool();
|
||||||
|
let query = `
|
||||||
|
SELECT su.user_id, su.name, su.username, su.hire_date, su.department_id,
|
||||||
|
COALESCE(d.department_name, '미배정') as department_name,
|
||||||
|
vb.id, vb.balance_type, vb.total_days, vb.used_days,
|
||||||
|
(vb.total_days - vb.used_days) as remaining_days,
|
||||||
|
vb.expires_at, vt.type_name, vt.type_code
|
||||||
|
FROM sso_users su
|
||||||
|
LEFT JOIN departments d ON su.department_id = d.department_id
|
||||||
|
LEFT JOIN sp_vacation_balances vb ON su.user_id = vb.user_id
|
||||||
|
AND (vb.year = ? OR (vb.balance_type = 'LONG_SERVICE' AND vb.expires_at IS NULL))
|
||||||
|
LEFT JOIN vacation_types vt ON vb.vacation_type_id = vt.id
|
||||||
|
WHERE su.is_active = 1
|
||||||
|
`;
|
||||||
|
const params = [year];
|
||||||
|
|
||||||
|
if (filters.department_id) {
|
||||||
|
query += ' AND su.department_id = ?';
|
||||||
|
params.push(filters.department_id);
|
||||||
|
}
|
||||||
|
if (filters.search_name) {
|
||||||
|
query += ' AND su.name LIKE ?';
|
||||||
|
params.push(`%${filters.search_name}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY su.name ASC';
|
||||||
|
|
||||||
|
const [rows] = await db.query(query, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = vacationDashboardModel;
|
||||||
@@ -14,11 +14,13 @@ const vacationRequestModel = {
|
|||||||
vr.*,
|
vr.*,
|
||||||
su.name as user_name,
|
su.name as user_name,
|
||||||
su.username,
|
su.username,
|
||||||
|
COALESCE(d.department_name, '미배정') as department_name,
|
||||||
vt.type_name as vacation_type_name,
|
vt.type_name as vacation_type_name,
|
||||||
vt.type_code,
|
vt.type_code,
|
||||||
reviewer.name as reviewer_name
|
reviewer.name as reviewer_name
|
||||||
FROM sp_vacation_requests vr
|
FROM sp_vacation_requests vr
|
||||||
INNER JOIN sso_users su ON vr.user_id = su.user_id
|
INNER JOIN sso_users su ON vr.user_id = su.user_id
|
||||||
|
LEFT JOIN departments d ON su.department_id = d.department_id
|
||||||
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
||||||
LEFT JOIN sso_users reviewer ON vr.reviewed_by = reviewer.user_id
|
LEFT JOIN sso_users reviewer ON vr.reviewed_by = reviewer.user_id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
@@ -45,6 +47,10 @@ const vacationRequestModel = {
|
|||||||
query += ' AND vr.vacation_type_id = ?';
|
query += ' AND vr.vacation_type_id = ?';
|
||||||
params.push(filters.vacation_type_id);
|
params.push(filters.vacation_type_id);
|
||||||
}
|
}
|
||||||
|
if (filters.department_id) {
|
||||||
|
query += ' AND su.department_id = ?';
|
||||||
|
params.push(filters.department_id);
|
||||||
|
}
|
||||||
|
|
||||||
query += ' ORDER BY vr.created_at DESC';
|
query += ' ORDER BY vr.created_at DESC';
|
||||||
|
|
||||||
@@ -59,11 +65,13 @@ const vacationRequestModel = {
|
|||||||
vr.*,
|
vr.*,
|
||||||
su.name as user_name,
|
su.name as user_name,
|
||||||
su.username,
|
su.username,
|
||||||
|
COALESCE(d.department_name, '미배정') as department_name,
|
||||||
vt.type_name as vacation_type_name,
|
vt.type_name as vacation_type_name,
|
||||||
vt.type_code,
|
vt.type_code,
|
||||||
reviewer.name as reviewer_name
|
reviewer.name as reviewer_name
|
||||||
FROM sp_vacation_requests vr
|
FROM sp_vacation_requests vr
|
||||||
INNER JOIN sso_users su ON vr.user_id = su.user_id
|
INNER JOIN sso_users su ON vr.user_id = su.user_id
|
||||||
|
LEFT JOIN departments d ON su.department_id = d.department_id
|
||||||
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
||||||
LEFT JOIN sso_users reviewer ON vr.reviewed_by = reviewer.user_id
|
LEFT JOIN sso_users reviewer ON vr.reviewed_by = reviewer.user_id
|
||||||
WHERE vr.request_id = ?
|
WHERE vr.request_id = ?
|
||||||
@@ -113,10 +121,12 @@ const vacationRequestModel = {
|
|||||||
vr.*,
|
vr.*,
|
||||||
su.name as user_name,
|
su.name as user_name,
|
||||||
su.username,
|
su.username,
|
||||||
|
COALESCE(d.department_name, '미배정') as department_name,
|
||||||
vt.type_name as vacation_type_name,
|
vt.type_name as vacation_type_name,
|
||||||
vt.type_code
|
vt.type_code
|
||||||
FROM sp_vacation_requests vr
|
FROM sp_vacation_requests vr
|
||||||
INNER JOIN sso_users su ON vr.user_id = su.user_id
|
INNER JOIN sso_users su ON vr.user_id = su.user_id
|
||||||
|
LEFT JOIN departments d ON su.department_id = d.department_id
|
||||||
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
||||||
WHERE vr.status = 'pending'
|
WHERE vr.status = 'pending'
|
||||||
ORDER BY vr.created_at ASC
|
ORDER BY vr.created_at ASC
|
||||||
@@ -128,17 +138,21 @@ const vacationRequestModel = {
|
|||||||
const db = getPool();
|
const db = getPool();
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const sqlFile = path.join(__dirname, '..', 'db', 'migrations', '001_create_sp_tables.sql');
|
const migrationFiles = ['001_create_sp_tables.sql', '002_section_c_additions.sql'];
|
||||||
const sql = fs.readFileSync(sqlFile, 'utf8');
|
for (const file of migrationFiles) {
|
||||||
const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
|
const sqlFile = path.join(__dirname, '..', 'db', 'migrations', file);
|
||||||
for (const stmt of statements) {
|
if (!fs.existsSync(sqlFile)) continue;
|
||||||
try {
|
const sql = fs.readFileSync(sqlFile, 'utf8');
|
||||||
await db.query(stmt);
|
const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
|
||||||
} catch (err) {
|
for (const stmt of statements) {
|
||||||
if (err.code === 'ER_DUP_FIELDNAME' || err.code === 'ER_TABLE_EXISTS_ERROR') {
|
try {
|
||||||
// Already migrated
|
await db.query(stmt);
|
||||||
} else {
|
} catch (err) {
|
||||||
throw err;
|
if (err.code === 'ER_DUP_FIELDNAME' || err.code === 'ER_TABLE_EXISTS_ERROR') {
|
||||||
|
// Already migrated
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
tksupport/api/routes/companyHolidayRoutes.js
Normal file
13
tksupport/api/routes/companyHolidayRoutes.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { requireAuth, requireSupportTeam } = require('../middleware/auth');
|
||||||
|
const ctrl = require('../controllers/companyHolidayController');
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get('/holidays', ctrl.getHolidays);
|
||||||
|
router.post('/holidays', requireSupportTeam, ctrl.createHoliday);
|
||||||
|
router.delete('/holidays/:id', requireSupportTeam, ctrl.deleteHoliday);
|
||||||
|
router.post('/holidays/:id/apply-deduction', requireSupportTeam, ctrl.applyDeduction);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
10
tksupport/api/routes/vacationDashboardRoutes.js
Normal file
10
tksupport/api/routes/vacationDashboardRoutes.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { requireAuth, requireSupportTeam } = require('../middleware/auth');
|
||||||
|
const ctrl = require('../controllers/vacationDashboardController');
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get('/', requireSupportTeam, ctrl.getDashboard);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -20,6 +20,9 @@ router.get('/pending', requireAdmin, ctrl.getPending);
|
|||||||
router.patch('/requests/:id/approve', requireAdmin, ctrl.approveRequest);
|
router.patch('/requests/:id/approve', requireAdmin, ctrl.approveRequest);
|
||||||
router.patch('/requests/:id/reject', requireAdmin, ctrl.rejectRequest);
|
router.patch('/requests/:id/reject', requireAdmin, ctrl.rejectRequest);
|
||||||
|
|
||||||
|
// 내 휴가 현황
|
||||||
|
router.get('/my-status', ctrl.getMyStatus);
|
||||||
|
|
||||||
// 잔여일
|
// 잔여일
|
||||||
router.get('/balance', ctrl.getMyBalance);
|
router.get('/balance', ctrl.getMyBalance);
|
||||||
router.get('/balance/all', requireAdmin, ctrl.getAllBalances);
|
router.get('/balance/all', requireAdmin, ctrl.getAllBalances);
|
||||||
|
|||||||
229
tksupport/web/company-holidays.html
Normal file
229
tksupport/web/company-holidays.html
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>전사 휴가 관리 - TK 행정지원</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-14">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button id="mobileMenuBtn" onclick="toggleMobileMenu()" class="lg:hidden text-purple-200 hover:text-white">
|
||||||
|
<i class="fas fa-bars text-xl"></i>
|
||||||
|
</button>
|
||||||
|
<i class="fas fa-building text-xl text-purple-200"></i>
|
||||||
|
<h1 class="text-lg font-semibold">TK 행정지원</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div id="headerUserName" class="text-sm font-medium hidden sm:block">-</div>
|
||||||
|
<div id="headerUserAvatar" class="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||||
|
<button onclick="doLogout()" class="text-purple-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-calendar-day text-purple-500 mr-2"></i>전사 휴가 관리</h2>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<select id="yearSelect" class="input-field px-3 py-2 rounded-lg text-sm" onchange="loadHolidays()">
|
||||||
|
</select>
|
||||||
|
<button onclick="openAddModal()" class="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">
|
||||||
|
<i class="fas fa-plus mr-1"></i>추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>날짜</th>
|
||||||
|
<th>휴가명</th>
|
||||||
|
<th>유형</th>
|
||||||
|
<th class="hide-mobile">설명</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th class="text-right">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="holidaysBody">
|
||||||
|
<tr><td colspan="6" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 추가 모달 -->
|
||||||
|
<div id="addModal" class="hidden modal-overlay" onclick="if(event.target===this)closeAddModal()">
|
||||||
|
<div class="modal-content p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">전사 휴가 등록</h3>
|
||||||
|
<button onclick="closeAddModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<form id="addForm">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">날짜 <span class="text-red-400">*</span></label>
|
||||||
|
<input type="date" id="addDate" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">휴가명 <span class="text-red-400">*</span></label>
|
||||||
|
<input type="text" id="addName" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="예: 창립기념일" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">유형 <span class="text-red-400">*</span></label>
|
||||||
|
<div class="flex gap-4 mt-1">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="radio" name="addType" value="PAID" checked class="text-purple-600"> 유급휴가
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="radio" name="addType" value="ANNUAL_DEDUCT" class="text-purple-600"> 연차차감
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
|
||||||
|
<input type="text" id="addDesc" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="설명 (선택)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-4 gap-2">
|
||||||
|
<button type="button" onclick="closeAddModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||||
|
<button type="submit" class="px-6 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700">등록</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||||
|
<script>
|
||||||
|
async function initPage() {
|
||||||
|
if (!initAuth()) return;
|
||||||
|
if (!currentUser || !['support_team','admin','system'].includes(currentUser.role)) {
|
||||||
|
document.querySelector('.flex-1').innerHTML = '<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-500"><i class="fas fa-lock text-4xl mb-3 block"></i>지원팀 이상 권한이 필요합니다</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연도 셀렉트
|
||||||
|
const sel = document.getElementById('yearSelect');
|
||||||
|
const thisYear = new Date().getFullYear();
|
||||||
|
for (let y = thisYear + 1; y >= thisYear - 2; y--) {
|
||||||
|
sel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHolidays();
|
||||||
|
|
||||||
|
document.getElementById('addForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = {
|
||||||
|
holiday_date: document.getElementById('addDate').value,
|
||||||
|
holiday_name: document.getElementById('addName').value,
|
||||||
|
holiday_type: document.querySelector('input[name="addType"]:checked').value,
|
||||||
|
description: document.getElementById('addDesc').value
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await api('/vacation/company/holidays', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
showToast('전사 휴가가 등록되었습니다');
|
||||||
|
closeAddModal();
|
||||||
|
loadHolidays();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHolidays() {
|
||||||
|
const year = document.getElementById('yearSelect').value;
|
||||||
|
try {
|
||||||
|
const res = await api('/vacation/company/holidays?year=' + year);
|
||||||
|
renderHolidays(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHolidays(holidays) {
|
||||||
|
const tbody = document.getElementById('holidaysBody');
|
||||||
|
if (holidays.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><i class="fas fa-calendar-times block"></i>등록된 전사 휴가가 없습니다</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = holidays.map(h => {
|
||||||
|
const typeBadge = h.holiday_type === 'PAID'
|
||||||
|
? '<span class="badge badge-green">유급</span>'
|
||||||
|
: '<span class="badge badge-amber">연차차감</span>';
|
||||||
|
const statusText = h.holiday_type === 'ANNUAL_DEDUCT'
|
||||||
|
? (h.deduction_applied_at ? '<span class="badge badge-blue">차감완료</span>' : '<span class="badge badge-gray">미차감</span>')
|
||||||
|
: '<span class="badge badge-green">해당없음</span>';
|
||||||
|
const actions = [];
|
||||||
|
if (h.holiday_type === 'ANNUAL_DEDUCT' && !h.deduction_applied_at) {
|
||||||
|
actions.push(`<button onclick="applyDeduction(${h.id})" class="text-amber-600 hover:text-amber-800 text-sm mr-1" title="차감 실행"><i class="fas fa-calculator"></i></button>`);
|
||||||
|
}
|
||||||
|
if (!h.deduction_applied_at) {
|
||||||
|
actions.push(`<button onclick="deleteHoliday(${h.id})" class="text-red-500 hover:text-red-700 text-sm" title="삭제"><i class="fas fa-trash"></i></button>`);
|
||||||
|
}
|
||||||
|
return `<tr>
|
||||||
|
<td class="whitespace-nowrap">${formatDate(h.holiday_date)}</td>
|
||||||
|
<td class="font-medium">${escapeHtml(h.holiday_name)}</td>
|
||||||
|
<td>${typeBadge}</td>
|
||||||
|
<td class="hide-mobile text-gray-500 text-sm max-w-[200px] truncate">${escapeHtml(h.description || '-')}</td>
|
||||||
|
<td>${statusText}</td>
|
||||||
|
<td class="text-right whitespace-nowrap">${actions.join('') || '-'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDeduction(id) {
|
||||||
|
if (!confirm('이 전사 휴가에 대해 전 직원 연차 차감을 실행하시겠습니까?\n\n실행 후 되돌릴 수 없습니다.')) return;
|
||||||
|
try {
|
||||||
|
const res = await api('/vacation/company/holidays/' + id + '/apply-deduction', { method: 'POST' });
|
||||||
|
showToast(res.message);
|
||||||
|
if (res.warning) {
|
||||||
|
setTimeout(() => showToast(res.warning, 'error'), 1500);
|
||||||
|
}
|
||||||
|
loadHolidays();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteHoliday(id) {
|
||||||
|
if (!confirm('이 전사 휴가를 삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await api('/vacation/company/holidays/' + id, { method: 'DELETE' });
|
||||||
|
showToast('전사 휴가가 삭제되었습니다');
|
||||||
|
loadHolidays();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
document.getElementById('addDate').value = '';
|
||||||
|
document.getElementById('addName').value = '';
|
||||||
|
document.getElementById('addDesc').value = '';
|
||||||
|
document.querySelector('input[name="addType"][value="PAID"]').checked = true;
|
||||||
|
document.getElementById('addModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddModal() {
|
||||||
|
document.getElementById('addModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
initPage();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>대시보드 - TK 행정지원</title>
|
<title>대시보드 - TK 행정지원</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026031401">
|
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/tksupport-core.js?v=2026031401"></script>
|
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||||
<script>
|
<script>
|
||||||
let vacationTypes = [];
|
let vacationTypes = [];
|
||||||
|
|
||||||
|
|||||||
@@ -106,10 +106,16 @@ function renderNavbar() {
|
|||||||
{ href: '/vacation-request.html', icon: 'fa-paper-plane', label: '휴가 신청', match: ['vacation-request.html'] },
|
{ href: '/vacation-request.html', icon: 'fa-paper-plane', label: '휴가 신청', match: ['vacation-request.html'] },
|
||||||
{ href: '/vacation-status.html', icon: 'fa-calendar-check', label: '내 휴가 현황', match: ['vacation-status.html'] },
|
{ href: '/vacation-status.html', icon: 'fa-calendar-check', label: '내 휴가 현황', match: ['vacation-status.html'] },
|
||||||
{ href: '/vacation-approval.html', icon: 'fa-clipboard-check', label: '휴가 승인', match: ['vacation-approval.html'], admin: true },
|
{ href: '/vacation-approval.html', icon: 'fa-clipboard-check', label: '휴가 승인', match: ['vacation-approval.html'], admin: true },
|
||||||
|
{ href: '/company-holidays.html', icon: 'fa-calendar-day', label: '전사 휴가 관리', match: ['company-holidays.html'], roles: ['support_team','admin','system'] },
|
||||||
|
{ href: '/vacation-dashboard.html', icon: 'fa-chart-bar', label: '전체 휴가관리', match: ['vacation-dashboard.html'], roles: ['support_team','admin','system'] },
|
||||||
];
|
];
|
||||||
const nav = document.getElementById('sideNav');
|
const nav = document.getElementById('sideNav');
|
||||||
if (!nav) return;
|
if (!nav) return;
|
||||||
nav.innerHTML = links.filter(l => !l.admin || isAdmin).map(l => {
|
nav.innerHTML = links.filter(l => {
|
||||||
|
if (l.roles) return currentUser && l.roles.includes(currentUser.role);
|
||||||
|
if (l.admin) return isAdmin;
|
||||||
|
return true;
|
||||||
|
}).map(l => {
|
||||||
const active = l.match.some(m => currentPage === m || currentPage.endsWith(m));
|
const active = l.match.some(m => currentPage === m || currentPage.endsWith(m));
|
||||||
return `<a href="${l.href}" class="nav-link flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm transition-colors ${active ? 'active' : 'text-gray-600 hover:bg-gray-100'}">
|
return `<a href="${l.href}" class="nav-link flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm transition-colors ${active ? 'active' : 'text-gray-600 hover:bg-gray-100'}">
|
||||||
<i class="fas ${l.icon} w-5 text-center"></i><span>${l.label}</span></a>`;
|
<i class="fas ${l.icon} w-5 text-center"></i><span>${l.label}</span></a>`;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>휴가 승인 - TK 행정지원</title>
|
<title>휴가 승인 - TK 행정지원</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026031401">
|
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>신청자</th>
|
<th>신청자</th>
|
||||||
|
<th class="hide-mobile">부서</th>
|
||||||
<th>유형</th>
|
<th>유형</th>
|
||||||
<th>기간</th>
|
<th>기간</th>
|
||||||
<th class="text-center">일수</th>
|
<th class="text-center">일수</th>
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pendingBody">
|
<tbody id="pendingBody">
|
||||||
<tr><td colspan="7" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
<tr><td colspan="8" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +84,12 @@
|
|||||||
<option value="cancelled">취소</option>
|
<option value="cancelled">취소</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||||
|
<select id="filterAllDept" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||||
|
<option value="">전체</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button onclick="loadAllRequests()" class="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">
|
<button onclick="loadAllRequests()" class="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">
|
||||||
<i class="fas fa-search mr-1"></i>조회
|
<i class="fas fa-search mr-1"></i>조회
|
||||||
</button>
|
</button>
|
||||||
@@ -92,6 +99,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>신청자</th>
|
<th>신청자</th>
|
||||||
|
<th class="hide-mobile">부서</th>
|
||||||
<th>유형</th>
|
<th>유형</th>
|
||||||
<th>기간</th>
|
<th>기간</th>
|
||||||
<th class="text-center">일수</th>
|
<th class="text-center">일수</th>
|
||||||
@@ -101,7 +109,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="allRequestsBody">
|
<tbody id="allRequestsBody">
|
||||||
<tr><td colspan="7" class="text-center text-gray-400 py-8">조회 버튼을 클릭하세요</td></tr>
|
<tr><td colspan="8" class="text-center text-gray-400 py-8">조회 버튼을 클릭하세요</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,7 +203,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/tksupport-core.js?v=2026031401"></script>
|
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||||
<script>
|
<script>
|
||||||
let reviewAction = '';
|
let reviewAction = '';
|
||||||
let reviewRequestId = null;
|
let reviewRequestId = null;
|
||||||
@@ -260,12 +268,13 @@
|
|||||||
document.getElementById('pendingCount').textContent = data.length;
|
document.getElementById('pendingCount').textContent = data.length;
|
||||||
const tbody = document.getElementById('pendingBody');
|
const tbody = document.getElementById('pendingBody');
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><i class="fas fa-check-circle block text-green-400"></i>대기 중인 신청이 없습니다</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="8" class="empty-state"><i class="fas fa-check-circle block text-green-400"></i>대기 중인 신청이 없습니다</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = data.map(r => `
|
tbody.innerHTML = data.map(r => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="font-medium">${escapeHtml(r.user_name || r.username)}</td>
|
<td class="font-medium">${escapeHtml(r.user_name || r.username)}</td>
|
||||||
|
<td class="hide-mobile text-gray-500 text-sm">${escapeHtml(r.department_name || '-')}</td>
|
||||||
<td>${escapeHtml(r.vacation_type_name)}</td>
|
<td>${escapeHtml(r.vacation_type_name)}</td>
|
||||||
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? ' ~ ' + formatDate(r.end_date) : ''}</td>
|
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? ' ~ ' + formatDate(r.end_date) : ''}</td>
|
||||||
<td class="text-center">${r.days_used}</td>
|
<td class="text-center">${r.days_used}</td>
|
||||||
@@ -284,18 +293,33 @@
|
|||||||
|
|
||||||
async function loadAllRequests() {
|
async function loadAllRequests() {
|
||||||
const status = document.getElementById('filterAllStatus').value;
|
const status = document.getElementById('filterAllStatus').value;
|
||||||
const params = status ? `?status=${status}` : '';
|
const deptId = document.getElementById('filterAllDept').value;
|
||||||
|
let params = [];
|
||||||
|
if (status) params.push('status=' + status);
|
||||||
|
if (deptId) params.push('department_id=' + deptId);
|
||||||
|
const qs = params.length > 0 ? '?' + params.join('&') : '';
|
||||||
try {
|
try {
|
||||||
const res = await api('/vacation/requests' + params);
|
const res = await api('/vacation/requests' + qs);
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
const tbody = document.getElementById('allRequestsBody');
|
const tbody = document.getElementById('allRequestsBody');
|
||||||
|
|
||||||
|
// 부서 필터 옵션 갱신
|
||||||
|
const deptSel = document.getElementById('filterAllDept');
|
||||||
|
const currentDept = deptSel.value;
|
||||||
|
const depts = [...new Set(data.map(r => r.department_name).filter(Boolean))].sort();
|
||||||
|
deptSel.innerHTML = '<option value="">전체</option>';
|
||||||
|
depts.forEach(d => {
|
||||||
|
deptSel.innerHTML += `<option value="" ${d === currentDept ? 'selected' : ''}>${escapeHtml(d)}</option>`;
|
||||||
|
});
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">내역이 없습니다</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">내역이 없습니다</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = data.map(r => `
|
tbody.innerHTML = data.map(r => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="font-medium">${escapeHtml(r.user_name || r.username)}</td>
|
<td class="font-medium">${escapeHtml(r.user_name || r.username)}</td>
|
||||||
|
<td class="hide-mobile text-gray-500 text-sm">${escapeHtml(r.department_name || '-')}</td>
|
||||||
<td>${escapeHtml(r.vacation_type_name)}</td>
|
<td>${escapeHtml(r.vacation_type_name)}</td>
|
||||||
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? ' ~ ' + formatDate(r.end_date) : ''}</td>
|
<td class="whitespace-nowrap">${formatDate(r.start_date)}${r.start_date !== r.end_date ? ' ~ ' + formatDate(r.end_date) : ''}</td>
|
||||||
<td class="text-center">${r.days_used}</td>
|
<td class="text-center">${r.days_used}</td>
|
||||||
|
|||||||
231
tksupport/web/vacation-dashboard.html
Normal file
231
tksupport/web/vacation-dashboard.html
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>전체 휴가관리 - TK 행정지원</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-14">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button id="mobileMenuBtn" onclick="toggleMobileMenu()" class="lg:hidden text-purple-200 hover:text-white">
|
||||||
|
<i class="fas fa-bars text-xl"></i>
|
||||||
|
</button>
|
||||||
|
<i class="fas fa-building text-xl text-purple-200"></i>
|
||||||
|
<h1 class="text-lg font-semibold">TK 행정지원</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div id="headerUserName" class="text-sm font-medium hidden sm:block">-</div>
|
||||||
|
<div id="headerUserAvatar" class="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||||
|
<button onclick="doLogout()" class="text-purple-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- 필터 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">연도</label>
|
||||||
|
<select id="yearSelect" class="input-field px-3 py-2 rounded-lg text-sm"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
|
||||||
|
<select id="deptFilter" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||||
|
<option value="">전체</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">이름</label>
|
||||||
|
<input type="text" id="nameSearch" class="input-field px-3 py-2 rounded-lg text-sm" placeholder="이름 검색">
|
||||||
|
</div>
|
||||||
|
<button onclick="loadDashboard()" class="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">
|
||||||
|
<i class="fas fa-search mr-1"></i>조회
|
||||||
|
</button>
|
||||||
|
<button disabled class="px-4 py-2 bg-gray-300 text-gray-500 rounded-lg text-sm cursor-not-allowed" title="추후 지원 예정">
|
||||||
|
<i class="fas fa-file-excel mr-1"></i>엑셀
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 요약 카드 -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-5">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value text-purple-600" id="statTotal">-</div>
|
||||||
|
<div class="stat-label">전체 직원</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value text-blue-600" id="statAvgRemaining">-</div>
|
||||||
|
<div class="stat-label">평균 잔여일</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value text-red-600" id="statLowBalance">-</div>
|
||||||
|
<div class="stat-label">소진 임박</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 부서별 현황 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||||
|
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-sitemap text-purple-500 mr-2"></i>부서별 현황</h2>
|
||||||
|
<div id="deptSummary" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
<div class="text-center text-gray-400 py-4 col-span-full">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 직원별 상세 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||||
|
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-users text-purple-500 mr-2"></i>직원별 상세</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>부서</th>
|
||||||
|
<th class="text-center">기본연차</th>
|
||||||
|
<th class="text-center hide-mobile">이월</th>
|
||||||
|
<th class="text-center hide-mobile">장기근속</th>
|
||||||
|
<th class="text-center">총 잔여</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="employeesBody">
|
||||||
|
<tr><td colspan="6" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||||
|
<script>
|
||||||
|
async function initPage() {
|
||||||
|
if (!initAuth()) return;
|
||||||
|
if (!currentUser || !['support_team','admin','system'].includes(currentUser.role)) {
|
||||||
|
document.querySelector('.flex-1').innerHTML = '<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-500"><i class="fas fa-lock text-4xl mb-3 block"></i>지원팀 이상 권한이 필요합니다</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sel = document.getElementById('yearSelect');
|
||||||
|
const thisYear = new Date().getFullYear();
|
||||||
|
for (let y = thisYear + 1; y >= thisYear - 2; y--) {
|
||||||
|
sel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const year = document.getElementById('yearSelect').value;
|
||||||
|
const deptId = document.getElementById('deptFilter').value;
|
||||||
|
const searchName = document.getElementById('nameSearch').value;
|
||||||
|
let url = '/vacation/dashboard?year=' + year;
|
||||||
|
if (deptId) url += '&department_id=' + deptId;
|
||||||
|
if (searchName) url += '&search_name=' + encodeURIComponent(searchName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api(url);
|
||||||
|
const { summary, employees } = res.data;
|
||||||
|
renderSummary(summary);
|
||||||
|
renderEmployees(employees);
|
||||||
|
populateDeptFilter(summary);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateDeptFilter(summary) {
|
||||||
|
const sel = document.getElementById('deptFilter');
|
||||||
|
const currentVal = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">전체</option>';
|
||||||
|
summary.forEach(s => {
|
||||||
|
sel.innerHTML += `<option value="${s.department_id || ''}" ${String(s.department_id) === currentVal ? 'selected' : ''}>${escapeHtml(s.department_name)}</option>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(summary) {
|
||||||
|
let totalEmployees = 0, totalAvg = 0, totalLow = 0, avgCount = 0;
|
||||||
|
summary.forEach(s => {
|
||||||
|
totalEmployees += parseInt(s.employee_count || 0);
|
||||||
|
if (s.avg_remaining !== null) { totalAvg += parseFloat(s.avg_remaining) * parseInt(s.employee_count); avgCount += parseInt(s.employee_count); }
|
||||||
|
totalLow += parseInt(s.low_balance_count || 0);
|
||||||
|
});
|
||||||
|
document.getElementById('statTotal').textContent = totalEmployees;
|
||||||
|
document.getElementById('statAvgRemaining').textContent = avgCount > 0 ? (totalAvg / avgCount).toFixed(1) : '-';
|
||||||
|
document.getElementById('statLowBalance').textContent = totalLow;
|
||||||
|
|
||||||
|
const container = document.getElementById('deptSummary');
|
||||||
|
if (summary.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center text-gray-400 py-4 col-span-full">데이터가 없습니다</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = summary.map(s => `
|
||||||
|
<div class="border rounded-lg p-4">
|
||||||
|
<div class="font-medium text-gray-800 mb-2">${escapeHtml(s.department_name)}</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-center text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-bold text-purple-600">${s.employee_count}</div>
|
||||||
|
<div class="text-xs text-gray-500">직원</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-bold text-blue-600">${s.avg_remaining !== null ? parseFloat(s.avg_remaining).toFixed(1) : '-'}</div>
|
||||||
|
<div class="text-xs text-gray-500">평균잔여</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-bold ${parseInt(s.low_balance_count) > 0 ? 'text-red-600' : 'text-green-600'}">${s.low_balance_count || 0}</div>
|
||||||
|
<div class="text-xs text-gray-500">소진임박</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmployees(employees) {
|
||||||
|
const tbody = document.getElementById('employeesBody');
|
||||||
|
if (employees.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">직원 데이터가 없습니다</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = employees.map(emp => {
|
||||||
|
const auto = emp.balances.find(b => b.balance_type === 'AUTO');
|
||||||
|
const carry = emp.balances.find(b => b.balance_type === 'CARRY_OVER');
|
||||||
|
const longSvc = emp.balances.find(b => b.balance_type === 'LONG_SERVICE');
|
||||||
|
|
||||||
|
const autoText = auto ? `${auto.used_days}/${auto.total_days}` : '-';
|
||||||
|
const carryText = carry ? `${carry.remaining_days}` : '-';
|
||||||
|
const longText = longSvc && parseFloat(longSvc.total_days) > 0 ? `${longSvc.remaining_days}` : '-';
|
||||||
|
|
||||||
|
let totalRemaining = 0;
|
||||||
|
emp.balances.forEach(b => { totalRemaining += parseFloat(b.remaining_days || 0); });
|
||||||
|
|
||||||
|
const isLow = auto && parseFloat(auto.total_days - auto.used_days) <= 2;
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td class="font-medium">${escapeHtml(emp.name || emp.username)}</td>
|
||||||
|
<td class="text-gray-600 text-sm">${escapeHtml(emp.department_name)}</td>
|
||||||
|
<td class="text-center">${autoText}</td>
|
||||||
|
<td class="text-center hide-mobile">${carryText}</td>
|
||||||
|
<td class="text-center hide-mobile">${longText}</td>
|
||||||
|
<td class="text-center font-bold ${isLow ? 'text-red-600' : 'text-purple-600'}">
|
||||||
|
${totalRemaining % 1 === 0 ? totalRemaining : totalRemaining.toFixed(1)}
|
||||||
|
${isLow ? ' <i class="fas fa-exclamation-triangle text-red-400 text-xs"></i>' : ''}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
initPage();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>내 휴가 현황 - TK 행정지원</title>
|
<title>내 휴가 현황 - TK 행정지원</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026031401">
|
<link rel="stylesheet" href="/static/css/tksupport.css?v=2026032301">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
<header class="bg-purple-700 text-white sticky top-0 z-50">
|
||||||
@@ -33,19 +33,46 @@
|
|||||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<!-- 잔여일 -->
|
<!-- 연도 선택 -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-calendar-check text-purple-500 mr-2"></i>내 휴가 현황</h2>
|
||||||
|
<select id="yearSelect" class="input-field px-3 py-2 rounded-lg text-sm" onchange="loadMyStatus()"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 잔여일 카드 -->
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||||
<h2 class="text-base font-semibold text-gray-800 mb-3"><i class="fas fa-chart-pie text-purple-500 mr-2"></i>잔여일 현황</h2>
|
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-chart-pie text-purple-500 mr-2"></i>잔여일 현황</h3>
|
||||||
<div id="balanceCards" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
<div id="balanceCards" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
<div class="text-center text-gray-400 py-4 col-span-full">로딩 중...</div>
|
<div class="text-center text-gray-400 py-4 col-span-full">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="totalBalance" class="mt-3 pt-3 border-t text-center hidden">
|
||||||
|
<span class="text-sm text-gray-500">총 잔여일: </span>
|
||||||
|
<span id="totalRemainingValue" class="text-lg font-bold text-purple-700"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 필터 -->
|
<!-- 연장근로 -->
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 mb-2"><i class="fas fa-clock text-purple-500 mr-2"></i>연장근로</h3>
|
||||||
|
<div class="text-center text-gray-400 py-3">
|
||||||
|
<div class="text-2xl font-bold text-gray-300">--시간</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">(추후 업데이트 예정)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 전사 휴가일 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-calendar-day text-purple-500 mr-2"></i>전사 휴가일</h3>
|
||||||
|
<div id="companyHolidays" class="space-y-2">
|
||||||
|
<div class="text-center text-gray-400 py-3">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 휴가 사용 이력 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||||
<div>
|
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-history text-purple-500 mr-2"></i>휴가 사용 이력</h3>
|
||||||
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
|
<div class="ml-auto">
|
||||||
<select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
|
<select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
<option value="pending">대기</option>
|
<option value="pending">대기</option>
|
||||||
@@ -93,42 +120,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/tksupport-core.js?v=2026031401"></script>
|
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||||
<script>
|
<script>
|
||||||
|
let cachedRequests = [];
|
||||||
|
|
||||||
async function initStatusPage() {
|
async function initStatusPage() {
|
||||||
if (!initAuth()) return;
|
if (!initAuth()) return;
|
||||||
|
|
||||||
try {
|
// 연도 셀렉트
|
||||||
const balanceRes = await api('/vacation/balance');
|
const sel = document.getElementById('yearSelect');
|
||||||
const balances = balanceRes.data.balances;
|
const thisYear = new Date().getFullYear();
|
||||||
const container = document.getElementById('balanceCards');
|
for (let y = thisYear + 1; y >= thisYear - 2; y--) {
|
||||||
if (balances.length === 0) {
|
sel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
||||||
container.innerHTML = '<div class="text-center text-gray-400 py-4 col-span-full">배정된 휴가가 없습니다</div>';
|
|
||||||
} else {
|
|
||||||
container.innerHTML = balances.map(b => `
|
|
||||||
<div class="border rounded-lg p-3 text-center">
|
|
||||||
<div class="text-xs text-gray-500 mb-1">${escapeHtml(b.type_name)}</div>
|
|
||||||
<div class="text-xl font-bold text-purple-600">${b.remaining_days}</div>
|
|
||||||
<div class="text-xs text-gray-400 mt-1">${b.used_days} / ${b.total_days} 사용</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRequests();
|
loadMyStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyStatus() {
|
||||||
|
const year = document.getElementById('yearSelect').value;
|
||||||
|
try {
|
||||||
|
const res = await api('/vacation/my-status?year=' + year);
|
||||||
|
const { balances, requests, company_holidays } = res.data;
|
||||||
|
|
||||||
|
renderBalanceCards(balances);
|
||||||
|
renderCompanyHolidays(company_holidays);
|
||||||
|
cachedRequests = requests;
|
||||||
|
renderRequests(requests);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function balanceTypeLabel(bt) {
|
||||||
|
const m = { AUTO: '기본연차', CARRY_OVER: '이월연차', LONG_SERVICE: '장기근속', MANUAL: '추가부여', COMPANY_GRANT: '회사부여' };
|
||||||
|
return m[bt] || bt || '기본연차';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBalanceCards(balances) {
|
||||||
|
const container = document.getElementById('balanceCards');
|
||||||
|
// 장기근속 total_days=0이면 숨김
|
||||||
|
const visible = balances.filter(b => {
|
||||||
|
if ((b.balance_type === 'LONG_SERVICE') && parseFloat(b.total_days) === 0) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visible.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center text-gray-400 py-4 col-span-full">배정된 휴가가 없습니다</div>';
|
||||||
|
document.getElementById('totalBalance').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalRemaining = 0;
|
||||||
|
container.innerHTML = visible.map(b => {
|
||||||
|
const remaining = parseFloat(b.remaining_days);
|
||||||
|
totalRemaining += remaining;
|
||||||
|
const isNegative = remaining < 0;
|
||||||
|
const label = balanceTypeLabel(b.balance_type);
|
||||||
|
let subtitle = `${b.used_days} / ${b.total_days} 사용`;
|
||||||
|
if (b.balance_type === 'CARRY_OVER' && b.expires_at) {
|
||||||
|
subtitle += `<br><span class="text-xs text-amber-500">만료: ${formatDate(b.expires_at)}</span>`;
|
||||||
|
}
|
||||||
|
if (b.balance_type === 'LONG_SERVICE') {
|
||||||
|
subtitle += `<br><span class="text-xs text-blue-500">만료없음</span>`;
|
||||||
|
}
|
||||||
|
return `<div class="border rounded-lg p-3 text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">${escapeHtml(label)}</div>
|
||||||
|
<div class="text-xl font-bold ${isNegative ? 'text-red-600' : 'text-purple-600'}">${remaining % 1 === 0 ? remaining : remaining.toFixed(1)}</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">${subtitle}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const totalEl = document.getElementById('totalBalance');
|
||||||
|
totalEl.classList.remove('hidden');
|
||||||
|
const totalVal = document.getElementById('totalRemainingValue');
|
||||||
|
totalVal.textContent = (totalRemaining % 1 === 0 ? totalRemaining : totalRemaining.toFixed(1)) + '일';
|
||||||
|
totalVal.className = `text-lg font-bold ${totalRemaining < 0 ? 'text-red-700' : 'text-purple-700'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompanyHolidays(holidays) {
|
||||||
|
const container = document.getElementById('companyHolidays');
|
||||||
|
if (!holidays || holidays.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center text-gray-400 py-3">등록된 전사 휴가일이 없습니다</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = holidays.map(h => {
|
||||||
|
const typeBadge = h.holiday_type === 'PAID'
|
||||||
|
? '<span class="badge badge-green text-xs">유급</span>'
|
||||||
|
: '<span class="badge badge-amber text-xs">연차차감</span>';
|
||||||
|
return `<div class="flex items-center justify-between border rounded-lg px-3 py-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700">${formatDate(h.holiday_date)}</span>
|
||||||
|
<span class="text-sm text-gray-600">${escapeHtml(h.holiday_name)}</span>
|
||||||
|
</div>
|
||||||
|
${typeBadge}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRequests() {
|
async function loadRequests() {
|
||||||
const status = document.getElementById('filterStatus').value;
|
const status = document.getElementById('filterStatus').value;
|
||||||
const params = status ? `?status=${status}` : '';
|
const filtered = status ? cachedRequests.filter(r => r.status === status) : cachedRequests;
|
||||||
try {
|
renderRequests(filtered);
|
||||||
const res = await api('/vacation/requests' + params);
|
|
||||||
renderRequests(res.data);
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRequests(requests) {
|
function renderRequests(requests) {
|
||||||
@@ -186,7 +280,7 @@
|
|||||||
try {
|
try {
|
||||||
await api('/vacation/requests/' + id + '/cancel', { method: 'PATCH' });
|
await api('/vacation/requests/' + id + '/cancel', { method: 'PATCH' });
|
||||||
showToast('휴가 신청이 취소되었습니다');
|
showToast('휴가 신청이 취소되었습니다');
|
||||||
loadRequests();
|
loadMyStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user