feat(tksupport): 휴가 보정 관리 페이지 추가 — 캘린더 기반 추가/삭제
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -343,6 +343,80 @@ const vacationController = {
|
||||
console.error('사용자 목록 조회 오류:', error);
|
||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||
}
|
||||
},
|
||||
|
||||
// ─── 관리자 보정 ───
|
||||
|
||||
async adminCreateRequest(req, res) {
|
||||
const db = getPool();
|
||||
const conn = await db.getConnection();
|
||||
try {
|
||||
const { user_id, vacation_type_id, start_date, end_date, days_used, reason } = req.body;
|
||||
if (!user_id || !vacation_type_id || !start_date || !end_date || !days_used) {
|
||||
return res.status(400).json({ success: false, error: '필수 필드가 누락되었습니다' });
|
||||
}
|
||||
const daysVal = parseFloat(days_used);
|
||||
if (daysVal <= 0 || daysVal > 30) {
|
||||
return res.status(400).json({ success: false, error: '일수는 0 초과 30 이하여야 합니다' });
|
||||
}
|
||||
if (new Date(start_date).getFullYear() !== new Date(end_date).getFullYear()) {
|
||||
return res.status(400).json({ success: false, error: '연도를 걸친 휴가는 연도별로 분리하여 입력해주세요' });
|
||||
}
|
||||
|
||||
const adminId = req.user.user_id || req.user.id;
|
||||
const year = new Date(start_date).getFullYear();
|
||||
|
||||
await conn.beginTransaction();
|
||||
const result = await vacationRequestModel.create({
|
||||
user_id, vacation_type_id, start_date, end_date,
|
||||
days_used: daysVal, reason: reason || null,
|
||||
status: 'approved', reviewed_by: adminId, review_note: '관리자 보정 추가'
|
||||
}, conn);
|
||||
await vacationBalanceModel.deductDays(user_id, vacation_type_id, year, daysVal, conn);
|
||||
await conn.commit();
|
||||
|
||||
res.status(201).json({ success: true, message: '휴가가 등록되었습니다', data: { request_id: result.insertId } });
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
console.error('관리자 보정 추가 오류:', error);
|
||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
},
|
||||
|
||||
async adminDeleteRequest(req, res) {
|
||||
const db = getPool();
|
||||
const conn = await db.getConnection();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const results = await vacationRequestModel.getById(id);
|
||||
if (results.length === 0) {
|
||||
return res.status(404).json({ success: false, error: '해당 휴가 기록을 찾을 수 없습니다' });
|
||||
}
|
||||
const existing = results[0];
|
||||
if (existing.status !== 'approved') {
|
||||
return res.status(400).json({ success: false, error: '승인된 기록만 삭제할 수 있습니다' });
|
||||
}
|
||||
|
||||
const adminId = req.user.user_id || req.user.id;
|
||||
const year = new Date(existing.start_date).getFullYear();
|
||||
|
||||
await conn.beginTransaction();
|
||||
await vacationBalanceModel.restoreDays(existing.user_id, existing.vacation_type_id, year, parseFloat(existing.days_used), conn);
|
||||
await vacationRequestModel.updateStatus(id, {
|
||||
status: 'cancelled', reviewed_by: adminId, review_note: '관리자 보정 삭제'
|
||||
}, conn);
|
||||
await conn.commit();
|
||||
|
||||
res.json({ success: true, message: '휴가가 삭제되었습니다' });
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
console.error('관리자 보정 삭제 오류:', error);
|
||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ const vacationBalanceModel = {
|
||||
return result;
|
||||
},
|
||||
|
||||
async deductDays(userId, vacationTypeId, year, daysToDeduct) {
|
||||
const db = getPool();
|
||||
async deductDays(userId, vacationTypeId, year, daysToDeduct, conn) {
|
||||
const db = conn || getPool();
|
||||
const [result] = await db.query(`
|
||||
UPDATE sp_vacation_balances
|
||||
SET used_days = used_days + ?, updated_at = NOW()
|
||||
@@ -71,8 +71,8 @@ const vacationBalanceModel = {
|
||||
return result;
|
||||
},
|
||||
|
||||
async restoreDays(userId, vacationTypeId, year, daysToRestore) {
|
||||
const db = getPool();
|
||||
async restoreDays(userId, vacationTypeId, year, daysToRestore, conn) {
|
||||
const db = conn || getPool();
|
||||
const [result] = await db.query(`
|
||||
UPDATE sp_vacation_balances
|
||||
SET used_days = GREATEST(0, used_days - ?), updated_at = NOW()
|
||||
|
||||
@@ -90,7 +90,7 @@ const vacationDashboardModel = {
|
||||
const [rows] = await db.query(`
|
||||
SELECT
|
||||
su.user_id, su.name, su.username,
|
||||
vr.start_date, vr.end_date, vr.days_used,
|
||||
vr.request_id, vr.start_date, vr.end_date, vr.days_used,
|
||||
vt.type_code, vt.type_name
|
||||
FROM sso_users su
|
||||
LEFT JOIN sp_vacation_requests vr
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const { getPool } = require('../middleware/auth');
|
||||
|
||||
const vacationRequestModel = {
|
||||
async create(data) {
|
||||
const db = getPool();
|
||||
async create(data, conn) {
|
||||
const db = conn || getPool();
|
||||
const [result] = await db.query('INSERT INTO sp_vacation_requests SET ?', data);
|
||||
return result;
|
||||
},
|
||||
@@ -85,8 +85,8 @@ const vacationRequestModel = {
|
||||
return result;
|
||||
},
|
||||
|
||||
async updateStatus(requestId, statusData) {
|
||||
const db = getPool();
|
||||
async updateStatus(requestId, statusData, conn) {
|
||||
const db = conn || getPool();
|
||||
const [result] = await db.query(`
|
||||
UPDATE sp_vacation_requests
|
||||
SET status = ?, reviewed_by = ?, reviewed_at = NOW(), review_note = ?
|
||||
|
||||
@@ -29,6 +29,10 @@ router.get('/balance/all', requireAdmin, ctrl.getAllBalances);
|
||||
router.get('/balance/:userId', requireAdmin, ctrl.getUserBalance);
|
||||
router.post('/balance/allocate', requireAdmin, ctrl.allocateBalance);
|
||||
|
||||
// 관리자 보정
|
||||
router.post('/admin/correct', requireAdmin, ctrl.adminCreateRequest);
|
||||
router.delete('/admin/requests/:id', requireAdmin, ctrl.adminDeleteRequest);
|
||||
|
||||
// 사용자 목록 (관리자)
|
||||
router.get('/users', requireAdmin, ctrl.getUsers);
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032501"></script>
|
||||
<script>
|
||||
async function initPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032501"></script>
|
||||
<script>
|
||||
let vacationTypes = [];
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ function renderNavbar() {
|
||||
{ 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'] },
|
||||
{ href: '/vacation-admin.html', icon: 'fa-user-edit', label: '휴가 보정', match: ['vacation-admin.html'], admin: true },
|
||||
];
|
||||
const nav = document.getElementById('sideNav');
|
||||
if (!nav) return;
|
||||
|
||||
302
tksupport/web/vacation-admin.html
Normal file
302
tksupport/web/vacation-admin.html
Normal file
@@ -0,0 +1,302 @@
|
||||
<!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">
|
||||
<style>
|
||||
.cal-cell { width: 28px; height: 28px; font-size: 11px; display: flex; align-items: center; justify-content: center; border-radius: 4px; flex-shrink: 0; }
|
||||
.cal-cell.weekend { background: #fef2f2; color: #f87171; }
|
||||
.cal-cell.clickable { cursor: pointer; transition: box-shadow 0.15s; }
|
||||
.cal-cell.clickable:hover { box-shadow: 0 0 0 2px #a78bfa; }
|
||||
.cal-cell.has-vacation { cursor: pointer; }
|
||||
.cal-cell.has-vacation:hover { box-shadow: 0 0 0 2px #ef4444; }
|
||||
</style>
|
||||
</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">
|
||||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||
<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="monthSelect" 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>
|
||||
<button onclick="loadCalendar()" 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>
|
||||
</div>
|
||||
<!-- 범례 -->
|
||||
<div class="flex flex-wrap gap-2 mb-4 text-xs">
|
||||
<span class="px-2 py-1 rounded bg-red-50 text-red-400">휴일</span>
|
||||
<span class="px-2 py-1 rounded bg-blue-100 text-blue-800">연 연차</span>
|
||||
<span class="px-2 py-1 rounded bg-green-100 text-green-800">반 반차</span>
|
||||
<span class="px-2 py-1 rounded bg-teal-100 text-teal-800">반반 반반차</span>
|
||||
<span class="px-2 py-1 rounded bg-amber-100 text-amber-800">조 조퇴</span>
|
||||
<span class="px-2 py-1 rounded bg-indigo-100 text-indigo-800">유 유급</span>
|
||||
<span class="px-2 py-1 rounded bg-purple-100 text-purple-800">특 특별</span>
|
||||
<span class="px-2 py-1 rounded bg-orange-100 text-orange-800">병 병가</span>
|
||||
<span class="ml-2 text-gray-500"><i class="fas fa-plus-circle text-purple-400 mr-1"></i>빈 셀 클릭 = 추가 · 색상 셀 클릭 = 삭제</span>
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-3" id="calTitle"><i class="fas fa-user-edit text-purple-500 mr-2"></i>휴가 보정</h2>
|
||||
<div id="calendarContainer" class="space-y-4">
|
||||
<div class="text-center text-gray-400 py-8">부서를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가 모달 -->
|
||||
<div id="addModal" class="fixed inset-0 bg-black/40 z-50 hidden flex items-center justify-center">
|
||||
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
|
||||
<h3 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-plus-circle text-purple-500 mr-2"></i>휴가 보정 추가</h3>
|
||||
<div class="space-y-3">
|
||||
<div><label class="block text-xs text-gray-500 mb-1">직원</label><div id="addUserName" class="text-sm font-medium text-gray-800"></div></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-xs text-gray-500 mb-1">시작일</label><input type="date" id="addStartDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm"></div>
|
||||
<div><label class="block text-xs text-gray-500 mb-1">종료일</label><input type="date" id="addEndDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-xs text-gray-500 mb-1">종류</label><select id="addType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm"></select></div>
|
||||
<div><label class="block text-xs text-gray-500 mb-1">일수</label><input type="number" id="addDays" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" step="0.5" min="0.25" max="30" value="1"></div>
|
||||
</div>
|
||||
<div><label class="block text-xs text-gray-500 mb-1">사유 (선택)</label><input type="text" id="addReason" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="보정 사유"></div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<button onclick="submitAdd()" class="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700">저장</button>
|
||||
<button onclick="closeAddModal()" class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 모달 -->
|
||||
<div id="deleteModal" class="fixed inset-0 bg-black/40 z-50 hidden flex items-center justify-center">
|
||||
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
|
||||
<h3 class="text-base font-semibold text-red-600 mb-4"><i class="fas fa-trash-alt mr-2"></i>휴가 보정 삭제</h3>
|
||||
<div id="deleteInfo" class="text-sm text-gray-700 space-y-1 mb-4"></div>
|
||||
<p class="text-sm text-gray-500 mb-4">삭제 시 잔여일이 복구됩니다.</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="submitDelete()" class="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700">삭제</button>
|
||||
<button onclick="closeDeleteModal()" class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026032501"></script>
|
||||
<script>
|
||||
const TYPE_COLOR = {
|
||||
ANNUAL_FULL: { bg: 'bg-blue-100', text: 'text-blue-800', label: '연' },
|
||||
ANNUAL_HALF: { bg: 'bg-green-100', text: 'text-green-800', label: '반' },
|
||||
ANNUAL_QUARTER: { bg: 'bg-teal-100', text: 'text-teal-800', label: '반반' },
|
||||
EARLY_LEAVE: { bg: 'bg-amber-100', text: 'text-amber-800', label: '조' },
|
||||
PAID: { bg: 'bg-indigo-100', text: 'text-indigo-800', label: '유' },
|
||||
SPECIAL: { bg: 'bg-purple-100', text: 'text-purple-800', label: '특' },
|
||||
SICK: { bg: 'bg-orange-100', text: 'text-orange-800', label: '병' },
|
||||
LONG_SERVICE: { bg: 'bg-purple-100', text: 'text-purple-800', label: '특' }
|
||||
};
|
||||
const DEFAULT_TYPE = { bg: 'bg-gray-100', text: 'text-gray-800', label: '?' };
|
||||
|
||||
let vacTypes = [];
|
||||
let pendingAddData = {};
|
||||
let pendingDeleteId = null;
|
||||
|
||||
async function initPage() {
|
||||
if (!initAuth()) return;
|
||||
if (!currentUser || !['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 thisYear = new Date().getFullYear();
|
||||
const ySel = document.getElementById('yearSelect');
|
||||
for (let y = thisYear + 1; y >= thisYear - 2; y--) ySel.innerHTML += `<option value="${y}" ${y === thisYear ? 'selected' : ''}>${y}년</option>`;
|
||||
const mSel = document.getElementById('monthSelect');
|
||||
for (let m = 1; m <= 12; m++) mSel.innerHTML += `<option value="${m}" ${m === new Date().getMonth() + 1 ? 'selected' : ''}>${m}월</option>`;
|
||||
|
||||
try {
|
||||
const [dashRes, typesRes] = await Promise.all([
|
||||
api('/vacation/dashboard?year=' + thisYear),
|
||||
api('/vacation/types')
|
||||
]);
|
||||
vacTypes = typesRes.data || [];
|
||||
const dSel = document.getElementById('deptFilter');
|
||||
(dashRes.data.summary || []).forEach(s => { dSel.innerHTML += `<option value="${s.department_id || ''}">${escapeHtml(s.department_name)}</option>`; });
|
||||
populateTypeSelect();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
function populateTypeSelect() {
|
||||
const sel = document.getElementById('addType');
|
||||
sel.innerHTML = '';
|
||||
vacTypes.forEach(t => { sel.innerHTML += `<option value="${t.id}">${escapeHtml(t.type_name)}</option>`; });
|
||||
}
|
||||
|
||||
async function loadCalendar() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
const deptId = document.getElementById('deptFilter').value;
|
||||
if (!deptId) { document.getElementById('calendarContainer').innerHTML = '<div class="text-center text-gray-400 py-8">부서를 선택하세요</div>'; return; }
|
||||
|
||||
try {
|
||||
const res = await api(`/vacation/dashboard/monthly-detail?year=${year}&month=${month}&department_id=${deptId}`);
|
||||
renderCalendar(res.data, parseInt(year), parseInt(month));
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
function renderCalendar(data, year, month) {
|
||||
const { records, holidays } = data;
|
||||
const container = document.getElementById('calendarContainer');
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
const holidaySet = {};
|
||||
const deductionSet = {};
|
||||
holidays.forEach(h => {
|
||||
const d = new Date(h.holiday_date).getDate();
|
||||
if (h.holiday_type === 'ANNUAL_DEDUCT' && h.deduction_applied_at) deductionSet[d] = h.holiday_name;
|
||||
else holidaySet[d] = h.holiday_name;
|
||||
});
|
||||
|
||||
// user_id별 그룹핑
|
||||
const userMap = {};
|
||||
records.forEach(r => {
|
||||
if (!userMap[r.user_id]) userMap[r.user_id] = { user_id: r.user_id, name: r.name, username: r.username, records: [] };
|
||||
if (r.start_date) userMap[r.user_id].records.push(r);
|
||||
});
|
||||
|
||||
if (Object.keys(userMap).length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-gray-400 py-8">직원이 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
Object.values(userMap).forEach(user => {
|
||||
const dayVacation = {};
|
||||
let monthTotal = 0;
|
||||
user.records.forEach(r => {
|
||||
const start = new Date(r.start_date);
|
||||
const end = new Date(r.end_date);
|
||||
const startDay = start.getMonth() + 1 === month ? start.getDate() : 1;
|
||||
const endDay = end.getMonth() + 1 === month ? end.getDate() : daysInMonth;
|
||||
for (let d = startDay; d <= endDay; d++) dayVacation[d] = { type_code: r.type_code, request_id: r.request_id, type_name: r.type_name, days_used: r.days_used, start_date: r.start_date, end_date: r.end_date };
|
||||
monthTotal += parseFloat(r.days_used);
|
||||
});
|
||||
|
||||
html += `<div class="border rounded-lg p-3">`;
|
||||
html += `<div class="flex justify-between items-center mb-2">`;
|
||||
html += `<span class="font-medium text-gray-800 text-sm">${escapeHtml(user.name || user.username)}</span>`;
|
||||
html += `<span class="text-xs text-gray-500">합계 <strong class="text-purple-600">${monthTotal % 1 === 0 ? monthTotal : monthTotal.toFixed(1)}</strong>일</span>`;
|
||||
html += `</div><div class="flex gap-0.5 flex-wrap">`;
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = new Date(year, month - 1, d);
|
||||
const dow = date.getDay();
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isHoliday = holidaySet[d];
|
||||
const vac = dayVacation[d];
|
||||
const dateStr = `${year}-${String(month).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
|
||||
if (vac) {
|
||||
const tc = TYPE_COLOR[vac.type_code] || DEFAULT_TYPE;
|
||||
html += `<div class="cal-cell ${tc.bg} ${tc.text} font-medium has-vacation" title="${vac.type_name} (${vac.days_used}일) - 클릭하여 삭제" onclick="openDeleteModal(${vac.request_id}, '${escapeHtml(user.name)}', '${vac.type_name}', '${vac.start_date}', '${vac.end_date}', ${vac.days_used})">${tc.label}</div>`;
|
||||
} else if (isWeekend || isHoliday) {
|
||||
html += `<div class="cal-cell weekend" title="${isHoliday || (dow === 0 ? '일' : '토')}">${d}</div>`;
|
||||
} else {
|
||||
html += `<div class="cal-cell text-gray-400 clickable" title="${d}일 - 클릭하여 추가" onclick="openAddModal(${user.user_id}, '${escapeHtml(user.name)}', '${dateStr}')">${d}</div>`;
|
||||
}
|
||||
}
|
||||
html += `</div></div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ===== 추가 모달 =====
|
||||
function openAddModal(userId, userName, dateStr) {
|
||||
pendingAddData = { user_id: userId };
|
||||
document.getElementById('addUserName').textContent = userName;
|
||||
document.getElementById('addStartDate').value = dateStr;
|
||||
document.getElementById('addEndDate').value = dateStr;
|
||||
document.getElementById('addDays').value = '1';
|
||||
document.getElementById('addReason').value = '';
|
||||
document.getElementById('addModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAddModal() { document.getElementById('addModal').classList.add('hidden'); }
|
||||
|
||||
async function submitAdd() {
|
||||
const data = {
|
||||
user_id: pendingAddData.user_id,
|
||||
vacation_type_id: parseInt(document.getElementById('addType').value),
|
||||
start_date: document.getElementById('addStartDate').value,
|
||||
end_date: document.getElementById('addEndDate').value,
|
||||
days_used: parseFloat(document.getElementById('addDays').value),
|
||||
reason: document.getElementById('addReason').value || null
|
||||
};
|
||||
if (!data.vacation_type_id || !data.start_date || !data.end_date || !data.days_used) {
|
||||
showToast('필수 항목을 입력하세요', 'error'); return;
|
||||
}
|
||||
try {
|
||||
await api('/vacation/admin/correct', { method: 'POST', body: JSON.stringify(data) });
|
||||
showToast('휴가가 등록되었습니다');
|
||||
closeAddModal();
|
||||
loadCalendar();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
// ===== 삭제 모달 =====
|
||||
function openDeleteModal(requestId, userName, typeName, startDate, endDate, daysUsed) {
|
||||
pendingDeleteId = requestId;
|
||||
document.getElementById('deleteInfo').innerHTML = `
|
||||
<div><strong>${escapeHtml(userName)}</strong></div>
|
||||
<div>${formatDate(startDate)}${startDate !== endDate ? ' ~ ' + formatDate(endDate) : ''}</div>
|
||||
<div>${escapeHtml(typeName)} · ${daysUsed}일</div>`;
|
||||
document.getElementById('deleteModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeleteModal() { document.getElementById('deleteModal').classList.add('hidden'); }
|
||||
|
||||
async function submitDelete() {
|
||||
if (!pendingDeleteId) return;
|
||||
try {
|
||||
await api('/vacation/admin/requests/' + pendingDeleteId, { method: 'DELETE' });
|
||||
showToast('휴가가 삭제되었습니다');
|
||||
closeDeleteModal();
|
||||
loadCalendar();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
function formatDate(d) { if (!d) return ''; return d.substring(0, 10); }
|
||||
|
||||
initPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -203,7 +203,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032501"></script>
|
||||
<script>
|
||||
let reviewAction = '';
|
||||
let reviewRequestId = null;
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032501"></script>
|
||||
<script>
|
||||
const TYPE_COLOR = {
|
||||
ANNUAL_FULL: { bg: 'bg-blue-100', text: 'text-blue-800', label: '연' },
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032501"></script>
|
||||
<script>
|
||||
async function initRequestPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksupport-core.js?v=2026032301"></script>
|
||||
<script src="/static/js/tksupport-core.js?v=2026032501"></script>
|
||||
<script>
|
||||
let cachedRequests = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user