feat(monthly-confirm): 캘린더 UI + 연차 API 수정 + 요약 카드
- 테이블 → 7열 캘린더 그리드 (모바일 최적화) - 8h=정시, >8h=+연장h, 연차/반차/휴무 텍스트 표시 - 요약 카드: 근무일/연장근로/연차일수 - vacation-balance API → vacation-balances/worker API (sp_vacation_balances 기반) - "신규" → "부여" 라벨 변경 - 셀 탭 시 하단 상세 표시 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,17 @@
|
||||
/**
|
||||
* my-monthly-confirm.js — 작업자 월간 근무 확인 (모바일 전용)
|
||||
* Sprint 004 Section B
|
||||
* my-monthly-confirm.js — 작업자 월간 근무 확인 (모바일 캘린더)
|
||||
*/
|
||||
|
||||
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;
|
||||
var DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
var currentYear, currentMonth;
|
||||
var isProcessing = false;
|
||||
var selectedCell = null;
|
||||
|
||||
// ===== 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'));
|
||||
@@ -32,7 +25,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// ===== Month Nav =====
|
||||
function updateMonthLabel() {
|
||||
document.getElementById('monthLabel').textContent = currentYear + '년 ' + currentMonth + '월';
|
||||
}
|
||||
@@ -41,36 +33,38 @@ 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 tableWrap = document.getElementById('tableWrap');
|
||||
tableWrap.innerHTML = '<div class="mmc-skeleton"></div><div class="mmc-skeleton"></div><div class="mmc-skeleton"></div>';
|
||||
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('/attendance/vacation-balance/' + userId).catch(function() { return { success: true, data: [] }; })
|
||||
window.apiCall('/vacation-balances/worker/' + userId + '/year/' + currentYear).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>';
|
||||
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);
|
||||
renderAttendanceTable(data.daily_records || []);
|
||||
renderCalendar(data.daily_records || []);
|
||||
renderSummaryCards(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>';
|
||||
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,66 +76,119 @@ function renderUserInfo(user) {
|
||||
(user.job_type ? user.job_type + ' · ' : '') + (user.department_name || '');
|
||||
}
|
||||
|
||||
function renderAttendanceTable(records) {
|
||||
// 셀 텍스트 판정
|
||||
// 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 totalHours = 0;
|
||||
var html = '<table class="mmc-table"><tbody>';
|
||||
// 날짜별 맵
|
||||
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 calWrap = document.getElementById('tableWrap');
|
||||
var cellEl = allCells[day - 1];
|
||||
if (!cellEl) return;
|
||||
|
||||
var info = cellEl.querySelector('.cal-val').textContent;
|
||||
el.innerHTML = '<div class="cal-detail-inner">' +
|
||||
'<strong>' + currentMonth + '/' + day + ' (' + dow + ')</strong> — ' + info +
|
||||
'</div>';
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function renderSummaryCards(records) {
|
||||
var workDays = 0, overtimeHours = 0, vacDays = 0;
|
||||
|
||||
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 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;
|
||||
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 && (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 vacDays += 1; // fallback
|
||||
}
|
||||
|
||||
// 주말 근무
|
||||
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;
|
||||
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) {
|
||||
@@ -150,7 +197,7 @@ function renderVacationBalance(balances) {
|
||||
|
||||
if (Array.isArray(balances)) {
|
||||
balances.forEach(function(b) {
|
||||
total += parseFloat(b.total_days || b.remaining_days_total || 0);
|
||||
total += parseFloat(b.total_days || 0);
|
||||
used += parseFloat(b.used_days || 0);
|
||||
});
|
||||
}
|
||||
@@ -159,7 +206,7 @@ function renderVacationBalance(balances) {
|
||||
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">' + 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>';
|
||||
@@ -197,78 +244,47 @@ function renderConfirmStatus(conf) {
|
||||
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;
|
||||
}
|
||||
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');
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 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);
|
||||
t.textContent = msg; c.appendChild(t);
|
||||
setTimeout(function() { t.remove(); }, 3000);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeRejectModal();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeRejectModal(); });
|
||||
|
||||
Reference in New Issue
Block a user