- my-monthly-confirm.html/js/css: 출근부 형식 1인용 확인 페이지 - monthly-comparison.js: 비관리자 → my-monthly-confirm으로 리다이렉트 - 마이그레이션: pages 테이블에 attendance.my_monthly_confirm 등록 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
272 lines
9.2 KiB
JavaScript
272 lines
9.2 KiB
JavaScript
/**
|
|
* my-monthly-confirm.js — 작업자 월간 근무 확인 (모바일 전용)
|
|
* Sprint 004 Section B
|
|
*/
|
|
|
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
|
const VAC_TEXT = {
|
|
'ANNUAL_FULL': '연차', 'ANNUAL_HALF': '반차', 'ANNUAL_QUARTER': '반반차',
|
|
'EARLY_LEAVE': '조퇴', 'SICK': '병가', 'SICK_FULL': '병가', 'SICK_HALF': '병가(반)',
|
|
'SPECIAL': '경조사', 'SPOUSE_BIRTH': '출산휴가'
|
|
};
|
|
|
|
let currentYear, currentMonth;
|
|
let isProcessing = false;
|
|
|
|
// ===== 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() {
|
|
if (!window.currentUser) return;
|
|
updateMonthLabel();
|
|
loadData();
|
|
}, 500);
|
|
});
|
|
|
|
// ===== Month Nav =====
|
|
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--; }
|
|
updateMonthLabel();
|
|
loadData();
|
|
}
|
|
|
|
// ===== Data Load =====
|
|
async function loadData() {
|
|
var tableWrap = document.getElementById('tableWrap');
|
|
tableWrap.innerHTML = '<div class="mmc-skeleton"></div><div class="mmc-skeleton"></div><div class="mmc-skeleton"></div>';
|
|
|
|
try {
|
|
var userId = window.currentUser.user_id || window.currentUser.id;
|
|
var [recordsRes, balanceRes] = await Promise.all([
|
|
window.apiCall('/monthly-comparison/my-records?year=' + currentYear + '&month=' + currentMonth),
|
|
window.apiCall('/attendance/vacation-balance/' + userId).catch(function() { return { success: true, data: [] }; })
|
|
]);
|
|
|
|
if (!recordsRes || !recordsRes.success) {
|
|
tableWrap.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);
|
|
renderAttendanceTable(data.daily_records || []);
|
|
renderVacationBalance(balanceRes.data || []);
|
|
renderConfirmStatus(data.confirmation);
|
|
} catch (e) {
|
|
tableWrap.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 || '');
|
|
}
|
|
|
|
function renderAttendanceTable(records) {
|
|
var el = document.getElementById('tableWrap');
|
|
if (!records.length) {
|
|
el.innerHTML = '<div class="mmc-empty"><p>해당 월 데이터가 없습니다</p></div>';
|
|
return;
|
|
}
|
|
|
|
var totalHours = 0;
|
|
var html = '<table class="mmc-table"><tbody>';
|
|
|
|
records.forEach(function(r) {
|
|
var day = parseInt(r.date.substring(8));
|
|
var dow = r.day_of_week || DAYS_KR[new Date(r.date).getDay()];
|
|
var isHoliday = r.is_holiday;
|
|
var rowClass = isHoliday ? ' class="row-holiday"' : '';
|
|
|
|
// 셀 값 결정
|
|
var val = '-';
|
|
var valClass = '';
|
|
|
|
if (r.attendance && r.attendance.vacation_type) {
|
|
val = r.attendance.vacation_type;
|
|
valClass = 'val-vacation';
|
|
} else if (isHoliday && (!r.attendance || !r.attendance.total_work_hours)) {
|
|
val = '휴무';
|
|
valClass = 'val-holiday';
|
|
} else if (r.attendance && r.attendance.total_work_hours > 0) {
|
|
var hrs = parseFloat(r.attendance.total_work_hours);
|
|
val = hrs.toFixed(2);
|
|
totalHours += hrs;
|
|
} else if (r.work_report && r.work_report.total_hours > 0) {
|
|
var hrs2 = parseFloat(r.work_report.total_hours);
|
|
val = hrs2.toFixed(2);
|
|
totalHours += hrs2;
|
|
}
|
|
|
|
// 주말 근무
|
|
if (isHoliday && r.attendance && r.attendance.total_work_hours > 0) {
|
|
val = parseFloat(r.attendance.total_work_hours).toFixed(2);
|
|
totalHours += parseFloat(r.attendance.total_work_hours);
|
|
rowClass = ' class="row-holiday-work"';
|
|
valClass = '';
|
|
}
|
|
|
|
var dowClass = (dow === '일') ? 'dow-sun' : (dow === '토') ? 'dow-sat' : '';
|
|
|
|
html += '<tr' + rowClass + '>';
|
|
html += '<td class="col-day">' + day + '</td>';
|
|
html += '<td class="col-dow ' + dowClass + '">' + dow + '</td>';
|
|
html += '<td class="col-val ' + valClass + '">' + escHtml(val) + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
|
|
html += '</tbody>';
|
|
html += '<tfoot><tr class="row-total">';
|
|
html += '<td colspan="2">총 근무시간</td>';
|
|
html += '<td class="col-val"><strong>' + totalHours.toFixed(2) + 'h</strong></td>';
|
|
html += '</tr></tfoot></table>';
|
|
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
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 || b.remaining_days_total || 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');
|
|
|
|
if (!conf || conf.status === 'pending') {
|
|
actions.classList.remove('hidden');
|
|
statusEl.classList.add('hidden');
|
|
badge.textContent = '미확인';
|
|
badge.className = 'mmc-status-badge pending';
|
|
return;
|
|
}
|
|
|
|
if (conf.status === 'confirmed') {
|
|
actions.classList.add('hidden');
|
|
statusEl.classList.remove('hidden');
|
|
badge.textContent = '확인완료';
|
|
badge.className = 'mmc-status-badge confirmed';
|
|
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : '';
|
|
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
|
|
} else if (conf.status === 'rejected') {
|
|
actions.classList.remove('hidden');
|
|
statusEl.classList.add('hidden');
|
|
badge.textContent = '반려';
|
|
badge.className = 'mmc-status-badge rejected';
|
|
}
|
|
}
|
|
|
|
// ===== 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;
|
|
}
|
|
}
|
|
|
|
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: 'rejected', reject_reason: 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, '"');
|
|
}
|
|
|
|
function showToast(msg, type) {
|
|
if (window.showToast) { window.showToast(msg, type); return; }
|
|
var c = document.getElementById('toastContainer');
|
|
var t = document.createElement('div');
|
|
t.className = 'toast toast-' + (type || 'info');
|
|
t.textContent = msg;
|
|
c.appendChild(t);
|
|
setTimeout(function() { t.remove(); }, 3000);
|
|
}
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') closeRejectModal();
|
|
});
|