- SEC-42: JWT algorithm HS256 명시 (sign 5곳, verify 3곳) - SEC-44: MariaDB/PhpMyAdmin 포트 127.0.0.1 바인딩 - SEC-29: escHtml = escapeHtml alias 추가 (XSS 방지) - SEC-39: Python Dockerfile 4개 non-root user + chown - SEC-43: deploy-remote.sh 삭제 (평문 비밀번호 포함) - SEC-11,12: SQL SET ? → 명시적 컬럼 whitelist + IN절 parameterized - QA-34: vacation approveRequest/cancelRequest 트랜잭션 래핑 - SEC-32,34: material_comparison.py 5개 엔드포인트 인증 + confirmed_by - SEC-33: files.py 17개 미인증 엔드포인트 인증 추가 - SEC-37: chatbot 프롬프트 인젝션 방어 (sanitize + XML 구분자) - SEC-38: fastapi-bridge 프록시 JWT 검증 + 캐시 키 user_id 포함 - SEC-58/QA-98: monthly-comparison API_BASE_URL 수정 + 401 처리 - SEC-61: monthlyComparisonModel SELECT FOR UPDATE 추가 - SEC-63: proxyInputController 에러 메시지 노출 제거 - QA-103: pageAccessRoutes error→message 통일 - SEC-62: tbm-create onclick 인젝션 → data-attribute event delegation - QA-99: tbm-mobile/create 캐시 버스팅 갱신 - QA-100,101: ESC 키 리스너 cleanup 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
401 lines
18 KiB
JavaScript
401 lines
18 KiB
JavaScript
/**
|
|
* my-monthly-confirm.js — 작업자 월간 근무 확인 (모바일 캘린더)
|
|
*/
|
|
|
|
var DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
|
var currentYear, currentMonth;
|
|
var isProcessing = false;
|
|
var selectedCell = null;
|
|
var currentConfStatus = null; // 현재 confirmation 상태
|
|
var pendingChanges = {}; // 수정 내역 { 'YYYY-MM-DD': { from: '반차', to: '정시', hours: 8 } }
|
|
var loadedRecords = []; // 로드된 daily_records
|
|
|
|
// ===== Init =====
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var now = new Date();
|
|
currentYear = now.getFullYear();
|
|
currentMonth = now.getMonth() + 1;
|
|
var params = new URLSearchParams(location.search);
|
|
if (params.get('year')) currentYear = parseInt(params.get('year'));
|
|
if (params.get('month')) currentMonth = parseInt(params.get('month'));
|
|
|
|
setTimeout(function() {
|
|
var user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser;
|
|
if (!user) return;
|
|
window._mmcUser = user;
|
|
updateMonthLabel();
|
|
loadData();
|
|
}, 500);
|
|
});
|
|
|
|
function updateMonthLabel() {
|
|
document.getElementById('monthLabel').textContent = currentYear + '년 ' + currentMonth + '월';
|
|
}
|
|
|
|
function changeMonth(delta) {
|
|
currentMonth += delta;
|
|
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
|
|
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
|
|
selectedCell = null;
|
|
updateMonthLabel();
|
|
loadData();
|
|
}
|
|
|
|
// ===== Data Load =====
|
|
async function loadData() {
|
|
var calWrap = document.getElementById('tableWrap');
|
|
calWrap.innerHTML = '<div class="mmc-skeleton"></div><div class="mmc-skeleton"></div>';
|
|
|
|
try {
|
|
var user = window._mmcUser || (typeof getCurrentUser === 'function' ? getCurrentUser() : null) || {};
|
|
var userId = user.user_id || user.id;
|
|
var [recordsRes, balanceRes] = await Promise.all([
|
|
window.apiCall('/monthly-comparison/my-records?year=' + currentYear + '&month=' + currentMonth),
|
|
window.apiCall('/vacation-balances/worker/' + userId + '/year/' + currentYear).catch(function() { return { success: true, data: [] }; })
|
|
]);
|
|
|
|
if (!recordsRes || !recordsRes.success) {
|
|
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-calendar-xmark text-2xl text-gray-300"></i><p>데이터가 없습니다</p></div>';
|
|
document.getElementById('bottomActions').classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
var data = recordsRes.data;
|
|
renderUserInfo(data.user);
|
|
renderCalendar(data.daily_records || []);
|
|
renderSummaryCards(data.daily_records || []);
|
|
loadedRecords = data.daily_records || [];
|
|
currentConfStatus = data.confirmation ? data.confirmation.status : 'pending';
|
|
pendingChanges = {};
|
|
renderVacationBalance(balanceRes.data || []);
|
|
renderConfirmStatus(data.confirmation);
|
|
} catch (e) {
|
|
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
|
}
|
|
}
|
|
|
|
// ===== Render =====
|
|
function renderUserInfo(user) {
|
|
if (!user) return;
|
|
document.getElementById('userName').textContent = user.worker_name || user.name || '-';
|
|
document.getElementById('userDept').textContent =
|
|
(user.job_type ? user.job_type + ' · ' : '') + (user.department_name || '');
|
|
}
|
|
|
|
// 셀 텍스트 판정
|
|
// 8h 기준 고정 (scheduled_hours 미존재 — 단축근무 미대응)
|
|
function getCellInfo(r) {
|
|
var hrs = r.attendance ? parseFloat(r.attendance.total_work_hours) || 0 : 0;
|
|
var vacType = r.attendance ? r.attendance.vacation_type : null;
|
|
var isHoliday = r.is_holiday;
|
|
|
|
if (vacType) return { text: vacType, cls: 'vac', detail: vacType };
|
|
if (isHoliday && hrs <= 0) return { text: '휴무', cls: 'off', detail: r.holiday_name || '휴무' };
|
|
if (isHoliday && hrs > 0) return { text: '특 ' + hrs + 'h', cls: 'special', detail: '특근 ' + hrs + '시간' };
|
|
if (hrs === 8) return { text: '정시', cls: 'normal', detail: '정시근로 8시간' };
|
|
if (hrs > 8) return { text: '+' + (hrs - 8) + 'h', cls: 'overtime', detail: '연장근로 ' + hrs + '시간 (+' + (hrs - 8) + ')' };
|
|
if (hrs > 0) return { text: hrs + 'h', cls: 'partial', detail: hrs + '시간 근무' };
|
|
return { text: '-', cls: 'none', detail: '미입력' };
|
|
}
|
|
|
|
function renderCalendar(records) {
|
|
var el = document.getElementById('tableWrap');
|
|
if (!records.length) {
|
|
el.innerHTML = '<div class="mmc-empty"><p>해당 월 데이터가 없습니다</p></div>';
|
|
return;
|
|
}
|
|
|
|
// 날짜별 맵
|
|
var recMap = {};
|
|
records.forEach(function(r) { recMap[parseInt(r.date.substring(8))] = r; });
|
|
|
|
var firstDay = new Date(currentYear, currentMonth - 1, 1).getDay();
|
|
var daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
|
|
|
|
// 헤더
|
|
var html = '<div class="cal-grid">';
|
|
html += '<div class="cal-header">';
|
|
DAYS_KR.forEach(function(d, i) {
|
|
var cls = i === 0 ? ' sun' : i === 6 ? ' sat' : '';
|
|
html += '<div class="cal-dow' + cls + '">' + d + '</div>';
|
|
});
|
|
html += '</div>';
|
|
|
|
// 셀
|
|
html += '<div class="cal-body">';
|
|
// 빈 셀 (월 시작 전)
|
|
for (var i = 0; i < firstDay; i++) {
|
|
html += '<div class="cal-cell empty"></div>';
|
|
}
|
|
|
|
for (var day = 1; day <= daysInMonth; day++) {
|
|
var r = recMap[day];
|
|
var info = r ? getCellInfo(r) : { text: '-', cls: 'none', detail: '데이터 없음' };
|
|
var dow = (firstDay + day - 1) % 7;
|
|
var dowCls = dow === 0 ? ' sun' : dow === 6 ? ' sat' : '';
|
|
|
|
html += '<div class="cal-cell ' + info.cls + dowCls + '" onclick="selectDay(' + day + ')">';
|
|
html += '<span class="cal-day">' + day + '</span>';
|
|
html += '<span class="cal-val">' + escHtml(info.text) + '</span>';
|
|
html += '</div>';
|
|
}
|
|
html += '</div></div>';
|
|
|
|
// 상세 영역
|
|
html += '<div class="cal-detail" id="calDetail"></div>';
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
function selectDay(day) {
|
|
selectedCell = day;
|
|
var el = document.getElementById('calDetail');
|
|
var cells = document.querySelectorAll('.cal-cell');
|
|
cells.forEach(function(c) { c.classList.remove('selected'); });
|
|
|
|
var allCells = document.querySelectorAll('.cal-cell:not(.empty)');
|
|
if (allCells[day - 1]) allCells[day - 1].classList.add('selected');
|
|
|
|
var dateStr = currentYear + '-' + String(currentMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
|
var d = new Date(currentYear, currentMonth - 1, day);
|
|
var dow = DAYS_KR[d.getDay()];
|
|
var record = loadedRecords.find(function(r) { return parseInt(r.date.substring(8)) === day; });
|
|
var currentVal = record ? getCellInfo(record).text : '-';
|
|
|
|
var html = '<div class="cal-detail-inner">';
|
|
html += '<strong>' + currentMonth + '/' + day + ' (' + dow + ')</strong> — ' + escHtml(currentVal);
|
|
|
|
// review_sent 상태에서만 수정 드롭다운 표시
|
|
if (currentConfStatus === 'review_sent') {
|
|
var changed = pendingChanges[dateStr];
|
|
html += '<div class="cal-edit-row">';
|
|
html += '<select id="editType-' + day + '" onchange="onCellChange(' + day + ')" class="cal-edit-select">';
|
|
html += '<option value="">변경 없음</option>';
|
|
html += '<option value="정시"' + (changed && changed.to === '정시' ? ' selected' : '') + '>정시 (8h)</option>';
|
|
html += '<option value="연차"' + (changed && changed.to === '연차' ? ' selected' : '') + '>연차 (0h)</option>';
|
|
html += '<option value="반차"' + (changed && changed.to === '반차' ? ' selected' : '') + '>반차 (4h)</option>';
|
|
html += '<option value="반반차"' + (changed && changed.to === '반반차' ? ' selected' : '') + '>반반차 (6h)</option>';
|
|
html += '<option value="조퇴"' + (changed && changed.to === '조퇴' ? ' selected' : '') + '>조퇴 (2h)</option>';
|
|
html += '<option value="휴무"' + (changed && changed.to === '휴무' ? ' selected' : '') + '>휴무 (0h)</option>';
|
|
html += '</select>';
|
|
if (changed) html += ' <span class="cal-changed-badge">수정</span>';
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
el.innerHTML = html;
|
|
el.style.display = 'block';
|
|
updateChangeRequestBtn();
|
|
}
|
|
|
|
function onCellChange(day) {
|
|
var dateStr = currentYear + '-' + String(currentMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
|
var sel = document.getElementById('editType-' + day);
|
|
var newType = sel ? sel.value : '';
|
|
var record = loadedRecords.find(function(r) { return parseInt(r.date.substring(8)) === day; });
|
|
var currentType = record ? getCellInfo(record).text : '-';
|
|
|
|
if (newType && newType !== currentType) {
|
|
var hoursMap = { '정시': 8, '연차': 0, '반차': 4, '반반차': 6, '조퇴': 2, '휴무': 0 };
|
|
pendingChanges[dateStr] = { from: currentType, to: newType, hours: hoursMap[newType] || 0 };
|
|
// 셀에 수정 뱃지
|
|
var allCells = document.querySelectorAll('.cal-cell:not(.empty)');
|
|
if (allCells[day - 1]) allCells[day - 1].classList.add('changed');
|
|
} else {
|
|
delete pendingChanges[dateStr];
|
|
var allCells2 = document.querySelectorAll('.cal-cell:not(.empty)');
|
|
if (allCells2[day - 1]) allCells2[day - 1].classList.remove('changed');
|
|
}
|
|
updateChangeRequestBtn();
|
|
// 상세 영역 재렌더
|
|
selectDay(day);
|
|
}
|
|
|
|
function updateChangeRequestBtn() {
|
|
var rejectBtn = document.getElementById('rejectBtn');
|
|
if (!rejectBtn) return;
|
|
var changeCount = Object.keys(pendingChanges).length;
|
|
if (currentConfStatus === 'review_sent' && changeCount > 0) {
|
|
rejectBtn.disabled = false;
|
|
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청 (' + changeCount + '건)';
|
|
} else if (currentConfStatus === 'review_sent') {
|
|
rejectBtn.disabled = true;
|
|
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
|
|
}
|
|
}
|
|
|
|
function renderSummaryCards(records) {
|
|
var workDays = 0, overtimeHours = 0, vacDays = 0;
|
|
|
|
records.forEach(function(r) {
|
|
var hrs = r.attendance ? parseFloat(r.attendance.total_work_hours) || 0 : 0;
|
|
var vacType = r.attendance ? r.attendance.vacation_type : null;
|
|
var isHoliday = r.is_holiday;
|
|
|
|
if (!isHoliday && (hrs > 0 || vacType)) workDays++;
|
|
if (hrs > 8) overtimeHours += (hrs - 8);
|
|
if (vacType) {
|
|
var vd = r.attendance.vacation_days ? parseFloat(r.attendance.vacation_days) : 0;
|
|
if (vd > 0) { vacDays += vd; }
|
|
else {
|
|
// fallback: vacation_type 이름으로 차감일수 매핑
|
|
var deductMap = { '연차': 1, '반차': 0.5, '반반차': 0.25, '조퇴': 0.75, '병가': 1 };
|
|
vacDays += deductMap[vacType] || 1;
|
|
}
|
|
}
|
|
});
|
|
|
|
var el = document.getElementById('summaryCards');
|
|
if (!el) return;
|
|
el.innerHTML =
|
|
'<div class="mmc-sum-card"><div class="mmc-sum-num">' + workDays + '</div><div class="mmc-sum-label">근무일</div></div>' +
|
|
'<div class="mmc-sum-card"><div class="mmc-sum-num ot">' + fmtNum(overtimeHours) + 'h</div><div class="mmc-sum-label">연장근로</div></div>' +
|
|
'<div class="mmc-sum-card"><div class="mmc-sum-num vac">' + fmtNum(vacDays) + '일</div><div class="mmc-sum-label">연차</div></div>';
|
|
}
|
|
|
|
function renderVacationBalance(balances) {
|
|
var el = document.getElementById('vacationCards');
|
|
var total = 0, used = 0;
|
|
|
|
if (Array.isArray(balances)) {
|
|
balances.forEach(function(b) {
|
|
total += parseFloat(b.total_days || 0);
|
|
used += parseFloat(b.used_days || 0);
|
|
});
|
|
}
|
|
|
|
var remaining = total - used;
|
|
el.innerHTML =
|
|
'<div class="mmc-vac-title">연차 현황</div>' +
|
|
'<div class="mmc-vac-grid">' +
|
|
'<div class="mmc-vac-card"><div class="mmc-vac-num">' + fmtNum(total) + '</div><div class="mmc-vac-label">부여</div></div>' +
|
|
'<div class="mmc-vac-card"><div class="mmc-vac-num used">' + fmtNum(used) + '</div><div class="mmc-vac-label">사용</div></div>' +
|
|
'<div class="mmc-vac-card"><div class="mmc-vac-num remain">' + fmtNum(remaining) + '</div><div class="mmc-vac-label">잔여</div></div>' +
|
|
'</div>';
|
|
}
|
|
|
|
function renderConfirmStatus(conf) {
|
|
var actions = document.getElementById('bottomActions');
|
|
var statusEl = document.getElementById('confirmedStatus');
|
|
var badge = document.getElementById('statusBadge');
|
|
var confirmBtn = document.getElementById('confirmBtn');
|
|
var rejectBtn = document.getElementById('rejectBtn');
|
|
var status = conf ? conf.status : 'pending';
|
|
|
|
// 기본: 버튼 숨김 + 상태 숨김
|
|
actions.classList.add('hidden');
|
|
statusEl.classList.add('hidden');
|
|
|
|
if (status === 'pending') {
|
|
badge.textContent = '검토대기';
|
|
badge.className = 'mmc-status-badge pending';
|
|
statusEl.classList.remove('hidden');
|
|
document.getElementById('confirmedText').textContent = '관리자 검토 대기 중입니다';
|
|
} else if (status === 'review_sent') {
|
|
badge.textContent = '확인요청';
|
|
badge.className = 'mmc-status-badge review_sent';
|
|
actions.classList.remove('hidden');
|
|
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>확인 완료';
|
|
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
|
|
rejectBtn.disabled = true; // 수정 내역 없으면 비활성화
|
|
rejectBtn.onclick = function() { submitChangeRequest(); };
|
|
} else if (status === 'confirmed') {
|
|
badge.textContent = '확인완료';
|
|
badge.className = 'mmc-status-badge confirmed';
|
|
statusEl.classList.remove('hidden');
|
|
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : '';
|
|
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
|
|
} else if (status === 'change_request') {
|
|
badge.textContent = '수정요청';
|
|
badge.className = 'mmc-status-badge change_request';
|
|
statusEl.classList.remove('hidden');
|
|
document.getElementById('confirmedText').textContent = '수정요청이 제출되었습니다. 관리자 확인 대기 중';
|
|
} else if (status === 'rejected') {
|
|
badge.textContent = '반려';
|
|
badge.className = 'mmc-status-badge rejected';
|
|
actions.classList.remove('hidden');
|
|
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>동의(재확인)';
|
|
rejectBtn.classList.add('hidden');
|
|
statusEl.classList.remove('hidden');
|
|
document.getElementById('confirmedText').textContent = '반려 사유: ' + (conf.reject_reason || '-') + '\n반려 사유를 확인하고 동의하시면 확인 완료 버튼을 눌러주세요.';
|
|
}
|
|
}
|
|
|
|
function openChangeRequestModal() {
|
|
document.getElementById('rejectReason').value = '';
|
|
document.getElementById('rejectModal').classList.remove('hidden');
|
|
// 모달 제목/버튼 수정요청용으로 변경
|
|
var header = document.querySelector('.mmc-modal-header span');
|
|
if (header) header.innerHTML = '<i class="fas fa-edit text-blue-500 mr-2"></i>수정요청';
|
|
var submitBtn = document.querySelector('.mmc-modal-submit');
|
|
if (submitBtn) submitBtn.textContent = '수정요청 제출';
|
|
var desc = document.querySelector('.mmc-modal-desc');
|
|
if (desc) desc.textContent = '수정이 필요한 내용을 입력해주세요:';
|
|
var note = document.querySelector('.mmc-modal-note');
|
|
if (note) note.innerHTML = '<i class="fas fa-info-circle text-blue-400 mr-1"></i>수정요청 시 관리자에게 알림이 전달됩니다.';
|
|
}
|
|
|
|
// ===== Actions =====
|
|
async function confirmMonth() {
|
|
if (isProcessing) return;
|
|
if (!confirm(currentYear + '년 ' + currentMonth + '월 근무 내역을 확인하시겠습니까?')) return;
|
|
isProcessing = true;
|
|
try {
|
|
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
|
year: currentYear, month: currentMonth, status: 'confirmed'
|
|
});
|
|
if (res && res.success) { showToast(res.message || '확인 완료', 'success'); loadData(); }
|
|
else { showToast(res && res.message || '처리 실패', 'error'); }
|
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
|
finally { isProcessing = false; }
|
|
}
|
|
|
|
async function submitChangeRequest() {
|
|
if (isProcessing) return;
|
|
var changeCount = Object.keys(pendingChanges).length;
|
|
if (changeCount === 0) { showToast('수정 내역이 없습니다', 'error'); return; }
|
|
if (!confirm(changeCount + '건의 수정요청을 제출하시겠습니까?')) return;
|
|
isProcessing = true;
|
|
try {
|
|
var changes = Object.keys(pendingChanges).map(function(date) {
|
|
return { date: date, from: pendingChanges[date].from, to: pendingChanges[date].to };
|
|
});
|
|
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
|
year: currentYear, month: currentMonth, status: 'change_request',
|
|
change_details: { changes: changes }
|
|
});
|
|
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); loadData(); }
|
|
else { showToast(res && res.message || '처리 실패', 'error'); }
|
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
|
finally { isProcessing = false; }
|
|
}
|
|
|
|
function openRejectModal() {
|
|
document.getElementById('rejectReason').value = '';
|
|
document.getElementById('rejectModal').classList.remove('hidden');
|
|
}
|
|
function closeRejectModal() { document.getElementById('rejectModal').classList.add('hidden'); }
|
|
|
|
async function submitReject() {
|
|
if (isProcessing) return;
|
|
var reason = document.getElementById('rejectReason').value.trim();
|
|
if (!reason) { showToast('수정 내용을 입력해주세요', 'error'); return; }
|
|
isProcessing = true;
|
|
try {
|
|
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
|
year: currentYear, month: currentMonth, status: 'change_request',
|
|
change_details: { description: reason }
|
|
});
|
|
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); closeRejectModal(); loadData(); }
|
|
else { showToast(res && res.message || '처리 실패', 'error'); }
|
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
|
finally { isProcessing = false; }
|
|
}
|
|
|
|
// ===== Helpers =====
|
|
function fmtNum(v) { var n = parseFloat(v) || 0; return n % 1 === 0 ? n.toString() : n.toFixed(1); }
|
|
function escHtml(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
|
|
function handleEscKey(e) { if (e.key === 'Escape') closeRejectModal(); }
|
|
document.addEventListener('keydown', handleEscKey);
|
|
window.addEventListener('beforeunload', function() { document.removeEventListener('keydown', handleEscKey); });
|