- 검토완료 버튼: 하단 제거 → 헤더 토글 ("검토하기" ↔ "✓ 검토완료")
- 상세→목록 복귀: year/month URL 유지 (4월로 리셋 방지)
- 확인요청: 전원 admin_checked 시만 활성화 (정책 변경)
- Sprint 004 PLAN.md: 정책 변경 이력 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
713 lines
30 KiB
JavaScript
713 lines
30 KiB
JavaScript
/**
|
|
* monthly-comparison.js — 월간 비교·확인·정산
|
|
* Sprint 004 Section B
|
|
*/
|
|
|
|
// ===== Mock =====
|
|
const MOCK_ENABLED = false;
|
|
|
|
const MOCK_MY_RECORDS = {
|
|
success: true,
|
|
data: {
|
|
user: { user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' },
|
|
period: { year: 2026, month: 3 },
|
|
summary: {
|
|
total_work_days: 22, total_work_hours: 182.5,
|
|
total_overtime_hours: 6.5, vacation_days: 1,
|
|
mismatch_count: 3,
|
|
mismatch_details: { hours_diff: 2, missing_report: 1, missing_attendance: 0 }
|
|
},
|
|
confirmation: { status: 'pending', confirmed_at: null, reject_reason: null },
|
|
daily_records: [
|
|
{ date: '2026-03-01', day_of_week: '월', is_holiday: false,
|
|
work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] },
|
|
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
|
|
status: 'match', hours_diff: 0 },
|
|
{ date: '2026-03-02', day_of_week: '화', is_holiday: false,
|
|
work_report: { total_hours: 9.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 9.0 }] },
|
|
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
|
|
status: 'mismatch', hours_diff: 1.0 },
|
|
{ date: '2026-03-03', day_of_week: '수', is_holiday: false,
|
|
work_report: null,
|
|
attendance: { total_work_hours: 0, attendance_type: '휴가근로', vacation_type: '연차' },
|
|
status: 'vacation', hours_diff: 0 },
|
|
{ date: '2026-03-04', day_of_week: '목', is_holiday: false,
|
|
work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] },
|
|
attendance: null,
|
|
status: 'report_only', hours_diff: 0 },
|
|
{ date: '2026-03-05', day_of_week: '금', is_holiday: false,
|
|
work_report: { total_hours: 8.0, entries: [{ project_name: 'B동 보수', work_type: '배관', hours: 8.0 }] },
|
|
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
|
|
status: 'match', hours_diff: 0 },
|
|
{ date: '2026-03-06', day_of_week: '토', is_holiday: true,
|
|
work_report: null, attendance: null, status: 'holiday', hours_diff: 0 },
|
|
{ date: '2026-03-07', day_of_week: '일', is_holiday: true,
|
|
work_report: null, attendance: null, status: 'holiday', hours_diff: 0 },
|
|
]
|
|
}
|
|
};
|
|
|
|
const MOCK_ADMIN_STATUS = {
|
|
success: true,
|
|
data: {
|
|
period: { year: 2026, month: 3 },
|
|
summary: { total_workers: 25, confirmed: 15, pending: 8, rejected: 2 },
|
|
workers: [
|
|
{ user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀',
|
|
total_work_days: 22, total_work_hours: 182.5, total_overtime_hours: 6.5,
|
|
status: 'confirmed', confirmed_at: '2026-03-30T10:00:00', mismatch_count: 0 },
|
|
{ user_id: 11, worker_name: '이영희', job_type: '도장', department_name: '생산1팀',
|
|
total_work_days: 20, total_work_hours: 168.0, total_overtime_hours: 2.0,
|
|
status: 'pending', confirmed_at: null, mismatch_count: 0 },
|
|
{ user_id: 12, worker_name: '박민수', job_type: '배관', department_name: '생산2팀',
|
|
total_work_days: 22, total_work_hours: 190.0, total_overtime_hours: 14.0,
|
|
status: 'rejected', confirmed_at: null, reject_reason: '3/15 근무시간 오류', mismatch_count: 2 },
|
|
]
|
|
}
|
|
};
|
|
|
|
// ===== State =====
|
|
let currentYear, currentMonth;
|
|
let currentMode = 'my'; // 'my' | 'admin' | 'detail'
|
|
let currentUserId = null;
|
|
let comparisonData = null;
|
|
let adminData = null;
|
|
let currentFilter = 'all';
|
|
|
|
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
|
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
|
|
|
// ===== Init =====
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const now = new Date();
|
|
currentYear = now.getFullYear();
|
|
currentMonth = now.getMonth() + 1;
|
|
|
|
// URL 파라미터
|
|
const params = new URLSearchParams(location.search);
|
|
if (params.get('year')) currentYear = parseInt(params.get('year'));
|
|
if (params.get('month')) currentMonth = parseInt(params.get('month'));
|
|
if (params.get('user_id')) currentUserId = parseInt(params.get('user_id'));
|
|
const urlMode = params.get('mode');
|
|
|
|
setTimeout(() => {
|
|
const user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser;
|
|
if (!user) return;
|
|
|
|
// 비관리자 → 작업자 전용 확인 페이지로 리다이렉트
|
|
if (!ADMIN_ROLES.includes(user.role)) {
|
|
location.href = '/pages/attendance/my-monthly-confirm.html';
|
|
return;
|
|
}
|
|
|
|
// 관리자 mode 결정
|
|
if (currentUserId) {
|
|
currentMode = 'detail';
|
|
} else {
|
|
currentMode = 'admin';
|
|
}
|
|
|
|
// 관리자 뷰 전환 버튼 (관리자만)
|
|
if (ADMIN_ROLES.includes(user.role)) {
|
|
document.getElementById('viewToggleBtn').classList.remove('hidden');
|
|
}
|
|
|
|
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() {
|
|
if (currentMode === 'admin') {
|
|
await loadAdminStatus();
|
|
} else {
|
|
await loadMyRecords();
|
|
}
|
|
}
|
|
|
|
async function loadMyRecords() {
|
|
document.getElementById('workerView').classList.remove('hidden');
|
|
document.getElementById('adminView').classList.add('hidden');
|
|
document.getElementById('pageTitle').textContent = currentMode === 'detail' ? '작업자 근무 비교' : '월간 근무 비교';
|
|
|
|
const listEl = document.getElementById('dailyList');
|
|
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
|
|
|
|
try {
|
|
let res;
|
|
if (MOCK_ENABLED) {
|
|
res = JSON.parse(JSON.stringify(MOCK_MY_RECORDS));
|
|
} else {
|
|
const endpoint = currentMode === 'detail' && currentUserId
|
|
? `/monthly-comparison/records?year=${currentYear}&month=${currentMonth}&user_id=${currentUserId}`
|
|
: `/monthly-comparison/my-records?year=${currentYear}&month=${currentMonth}`;
|
|
res = await window.apiCall(endpoint);
|
|
}
|
|
|
|
if (!res || !res.success) {
|
|
listEl.innerHTML = '<div class="mc-empty"><p>데이터를 불러올 수 없습니다</p></div>';
|
|
return;
|
|
}
|
|
|
|
comparisonData = res.data;
|
|
|
|
// detail 모드: 작업자 이름 + 검토완료 버튼 (상단 헤더)
|
|
if (currentMode === 'detail' && comparisonData.user) {
|
|
var isChecked = comparisonData.confirmation && comparisonData.confirmation.admin_checked;
|
|
var checkBtnHtml = '<button type="button" id="headerCheckBtn" onclick="toggleAdminCheck()" style="' +
|
|
'padding:6px 12px;border-radius:8px;font-size:0.75rem;font-weight:600;border:none;cursor:pointer;margin-left:auto;' +
|
|
(isChecked ? 'background:#dcfce7;color:#166534;' : 'background:#f3f4f6;color:#6b7280;') +
|
|
'">' + (isChecked ? '✓ 검토완료' : '검토하기') + '</button>';
|
|
document.getElementById('pageTitle').innerHTML =
|
|
(comparisonData.user.worker_name || '') + ' 근무 비교' + checkBtnHtml;
|
|
}
|
|
|
|
renderSummaryCards(comparisonData.summary);
|
|
renderMismatchAlert(comparisonData.summary);
|
|
renderDailyList(comparisonData.daily_records || []);
|
|
renderConfirmationStatus(comparisonData.confirmation);
|
|
} catch (e) {
|
|
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
|
}
|
|
}
|
|
|
|
async function loadAdminStatus() {
|
|
document.getElementById('workerView').classList.add('hidden');
|
|
document.getElementById('adminView').classList.remove('hidden');
|
|
document.getElementById('pageTitle').textContent = '월간 근무 확인 현황';
|
|
|
|
const listEl = document.getElementById('adminWorkerList');
|
|
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
|
|
|
|
try {
|
|
let res;
|
|
if (MOCK_ENABLED) {
|
|
res = JSON.parse(JSON.stringify(MOCK_ADMIN_STATUS));
|
|
} else {
|
|
res = await window.apiCall(`/monthly-comparison/all-status?year=${currentYear}&month=${currentMonth}`);
|
|
}
|
|
|
|
if (!res || !res.success) {
|
|
listEl.innerHTML = '<div class="mc-empty"><p>데이터를 불러올 수 없습니다</p></div>';
|
|
return;
|
|
}
|
|
|
|
adminData = res.data;
|
|
renderAdminSummary(adminData.summary);
|
|
renderWorkerList(adminData.workers || []);
|
|
updateExportButton(adminData.summary, adminData.workers || []);
|
|
} catch (e) {
|
|
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
|
}
|
|
}
|
|
|
|
// ===== Render: Worker View =====
|
|
function renderSummaryCards(s) {
|
|
document.getElementById('totalDays').textContent = s.total_work_days || 0;
|
|
document.getElementById('totalHours').textContent = (s.total_work_hours || 0) + 'h';
|
|
document.getElementById('overtimeHours').textContent = (s.total_overtime_hours || 0) + 'h';
|
|
document.getElementById('vacationDays').textContent = (s.vacation_days || 0) + '일';
|
|
}
|
|
|
|
function renderMismatchAlert(s) {
|
|
const el = document.getElementById('mismatchAlert');
|
|
if (!s.mismatch_count || s.mismatch_count === 0) {
|
|
el.classList.add('hidden');
|
|
return;
|
|
}
|
|
el.classList.remove('hidden');
|
|
const details = s.mismatch_details || {};
|
|
const parts = [];
|
|
if (details.hours_diff) parts.push(`시간차이 ${details.hours_diff}건`);
|
|
if (details.missing_report) parts.push(`보고서만 ${details.missing_report}건`);
|
|
if (details.missing_attendance) parts.push(`근태만 ${details.missing_attendance}건`);
|
|
document.getElementById('mismatchText').textContent =
|
|
`${s.mismatch_count}건의 불일치가 있습니다` + (parts.length ? ` (${parts.join(' | ')})` : '');
|
|
}
|
|
|
|
function renderDailyList(records) {
|
|
const el = document.getElementById('dailyList');
|
|
if (!records.length) {
|
|
el.innerHTML = '<div class="mc-empty"><p>데이터가 없습니다</p></div>';
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = records.map(r => {
|
|
const dateStr = r.date.substring(5); // "03-01"
|
|
const dayStr = r.day_of_week || '';
|
|
const icon = getStatusIcon(r.status);
|
|
const label = getStatusLabel(r.status, r);
|
|
|
|
let reportLine = '';
|
|
let attendLine = '';
|
|
let diffLine = '';
|
|
|
|
if (r.work_report) {
|
|
const entries = (r.work_report.entries || []).map(e => `${e.project_name}-${e.work_type}`).join(', ');
|
|
reportLine = `<div class="mc-daily-row">작업보고: <strong>${r.work_report.total_hours}h</strong> <span>(${escHtml(entries)})</span></div>`;
|
|
} else if (r.status !== 'holiday') {
|
|
reportLine = '<div class="mc-daily-row" style="color:#9ca3af">작업보고: -</div>';
|
|
}
|
|
|
|
if (r.attendance) {
|
|
const vacInfo = r.attendance.vacation_type ? ` (${r.attendance.vacation_type})` : '';
|
|
const editBtn = currentMode === 'detail' ? `<button class="mc-edit-btn" onclick="editAttendance('${r.date}', ${r.attendance.total_work_hours}, ${r.attendance.vacation_type_id || 'null'})" title="근태 수정"><i class="fas fa-pen"></i></button>` : '';
|
|
attendLine = `<div class="mc-daily-row mc-attend-row" id="attend-${r.date}">근태관리: <strong>${r.attendance.total_work_hours}h</strong> <span>(${escHtml(r.attendance.attendance_type)}${vacInfo})</span>${editBtn}</div>`;
|
|
} else if (r.status !== 'holiday') {
|
|
const addBtn = currentMode === 'detail' ? `<button class="mc-edit-btn" onclick="editAttendance('${r.date}', 0, null)" title="근태 입력"><i class="fas fa-plus"></i></button>` : '';
|
|
attendLine = `<div class="mc-daily-row mc-attend-row" id="attend-${r.date}" style="color:#9ca3af">근태관리: 미입력${addBtn}</div>`;
|
|
}
|
|
|
|
if (r.status === 'mismatch' && r.hours_diff) {
|
|
const sign = r.hours_diff > 0 ? '+' : '';
|
|
diffLine = `<div class="mc-daily-diff"><i class="fas fa-thumbtack"></i> 차이: ${sign}${r.hours_diff}h</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="mc-daily-card ${r.status}">
|
|
<div class="mc-daily-header">
|
|
<div class="mc-daily-date">${dateStr}(${dayStr})</div>
|
|
<div class="mc-daily-status">${icon} ${label}</div>
|
|
</div>
|
|
${reportLine}${attendLine}${diffLine}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderConfirmationStatus(conf) {
|
|
const actions = document.getElementById('bottomActions');
|
|
const statusEl = document.getElementById('confirmedStatus');
|
|
const badge = document.getElementById('statusBadge');
|
|
|
|
if (!conf) {
|
|
// detail 모드(관리자가 타인의 기록 조회)에서는 버튼 숨김
|
|
if (currentMode === 'detail') {
|
|
actions.classList.add('hidden');
|
|
} else {
|
|
actions.classList.remove('hidden');
|
|
}
|
|
statusEl.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
badge.textContent = { pending: '미확인', confirmed: '확인완료', rejected: '반려' }[conf.status] || '';
|
|
badge.className = `mc-status-badge ${conf.status}`;
|
|
|
|
if (currentMode === 'detail') {
|
|
// 관리자 상세 뷰: 확인/반려 버튼 숨기고 상태만 표시
|
|
actions.classList.add('hidden');
|
|
if (conf.status !== 'pending') {
|
|
statusEl.classList.remove('hidden');
|
|
const statusLabel = { confirmed: '확인 완료', rejected: '반려' }[conf.status] || '';
|
|
const dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : '';
|
|
const reason = conf.reject_reason ? ` (사유: ${conf.reject_reason})` : '';
|
|
document.getElementById('confirmedText').textContent = `${dt} ${statusLabel}${reason}`;
|
|
} else {
|
|
statusEl.classList.add('hidden');
|
|
}
|
|
} else if (conf.status === 'confirmed') {
|
|
actions.classList.add('hidden');
|
|
statusEl.classList.remove('hidden');
|
|
const dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : '';
|
|
document.getElementById('confirmedText').textContent = `${dt} 확인 완료`;
|
|
} else if (conf.status === 'rejected') {
|
|
// 재확인 가능
|
|
actions.classList.remove('hidden');
|
|
statusEl.classList.add('hidden');
|
|
} else {
|
|
actions.classList.remove('hidden');
|
|
statusEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// ===== Render: Admin View =====
|
|
function renderAdminSummary(s) {
|
|
const total = s.total_workers || 1;
|
|
const pct = Math.round((s.confirmed || 0) / total * 100);
|
|
document.getElementById('progressFill').style.width = pct + '%';
|
|
document.getElementById('progressText').textContent = `확인 현황: ${s.confirmed || 0}/${total}명 완료`;
|
|
document.getElementById('statusCounts').innerHTML =
|
|
`<span>✅ ${s.confirmed || 0} 확인</span>` +
|
|
`<span>📩 ${s.review_sent || 0} 확인요청</span>` +
|
|
`<span>⏳ ${s.pending || 0} 미검토</span>` +
|
|
`<span>📝 ${s.change_request || 0} 수정요청</span>` +
|
|
`<span>❌ ${s.rejected || 0} 반려</span>`;
|
|
|
|
// 확인요청 일괄 발송 버튼 — 전원 검토완료 시만 활성화
|
|
var reviewBtn = document.getElementById('reviewSendBtn');
|
|
if (reviewBtn) {
|
|
var pendingCount = (s.pending || 0);
|
|
var uncheckedCount = (adminData?.workers || []).filter(function(w) { return !w.admin_checked && w.status === 'pending'; }).length;
|
|
if (pendingCount > 0 && uncheckedCount === 0) {
|
|
reviewBtn.classList.remove('hidden');
|
|
reviewBtn.disabled = false;
|
|
reviewBtn.textContent = `${pendingCount}명 확인요청 발송`;
|
|
reviewBtn.style.background = '#2563eb';
|
|
} else if (pendingCount > 0 && uncheckedCount > 0) {
|
|
reviewBtn.classList.remove('hidden');
|
|
reviewBtn.disabled = true;
|
|
reviewBtn.textContent = `${uncheckedCount}명 미검토 — 전원 검토 후 발송 가능`;
|
|
reviewBtn.style.background = '#9ca3af';
|
|
} else {
|
|
reviewBtn.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderWorkerList(workers) {
|
|
const el = document.getElementById('adminWorkerList');
|
|
let filtered = workers;
|
|
if (currentFilter !== 'all') {
|
|
filtered = workers.filter(w => w.status === currentFilter);
|
|
}
|
|
|
|
if (!filtered.length) {
|
|
el.innerHTML = '<div class="mc-empty"><p>해당 조건의 작업자가 없습니다</p></div>';
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = filtered.map(w => {
|
|
const statusLabels = { confirmed: '확인완료', pending: '미검토', review_sent: '확인요청', change_request: '수정요청', rejected: '반려' };
|
|
const statusBadge = `<span class="mc-worker-status-badge ${w.status}">${statusLabels[w.status] || ''}</span>`;
|
|
const checkedBadge = w.admin_checked ? ' <span style="color:#10b981;font-size:0.7rem;">✓검토</span>' : '';
|
|
const mismatchBadge = w.mismatch_count > 0
|
|
? `<span class="mc-worker-mismatch">⚠️ 불일치${w.mismatch_count}</span>` : '';
|
|
const rejectReason = w.status === 'rejected' && w.reject_reason
|
|
? `<div class="mc-worker-reject-reason">사유: ${escHtml(w.reject_reason)}</div>` : '';
|
|
const confirmedAt = w.confirmed_at ? `(${new Date(w.confirmed_at).toLocaleDateString('ko')})` : '';
|
|
|
|
return `
|
|
<div class="mc-worker-card" onclick="viewWorkerDetail(${w.user_id})">
|
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
<div>
|
|
<div class="mc-worker-name">${escHtml(w.worker_name)}${checkedBadge} ${mismatchBadge}</div>
|
|
<div class="mc-worker-dept">${escHtml(w.department_name)} · ${escHtml(w.job_type)}</div>
|
|
</div>
|
|
<i class="fas fa-chevron-right text-gray-300"></i>
|
|
</div>
|
|
<div class="mc-worker-stats">${w.total_work_days}일 | ${w.total_work_hours}h | 연장 ${w.total_overtime_hours}h</div>
|
|
<div class="mc-worker-status">
|
|
${statusBadge} <span style="font-size:0.7rem;color:#9ca3af">${confirmedAt}</span>
|
|
</div>
|
|
${rejectReason}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function filterWorkers(status) {
|
|
currentFilter = status;
|
|
document.querySelectorAll('.mc-tab').forEach(t => {
|
|
t.classList.toggle('active', t.dataset.filter === status);
|
|
});
|
|
if (adminData) renderWorkerList(adminData.workers || []);
|
|
}
|
|
|
|
function updateExportButton(summary, workers) {
|
|
const btn = document.getElementById('exportBtn');
|
|
const note = document.getElementById('exportNote');
|
|
const pendingCount = (workers || []).filter(w => !w.status || w.status === 'pending').length;
|
|
const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length;
|
|
const allConfirmed = pendingCount === 0 && rejectedCount === 0;
|
|
|
|
if (allConfirmed) {
|
|
btn.disabled = false;
|
|
note.textContent = '모든 작업자가 확인을 완료했습니다';
|
|
} else {
|
|
btn.disabled = true;
|
|
const parts = [];
|
|
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
|
|
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
|
|
note.textContent = `${parts.join(', ')} — 전원 확인 후 다운로드 가능합니다`;
|
|
}
|
|
}
|
|
|
|
// ===== Actions =====
|
|
let isProcessing = false;
|
|
|
|
async function confirmMonth() {
|
|
if (isProcessing) return;
|
|
if (!confirm(`${currentYear}년 ${currentMonth}월 근무 내역을 확인하시겠습니까?`)) return;
|
|
|
|
isProcessing = true;
|
|
try {
|
|
let res;
|
|
if (MOCK_ENABLED) {
|
|
await new Promise(r => setTimeout(r, 500));
|
|
res = { success: true, message: '확인이 완료되었습니다.' };
|
|
} else {
|
|
res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
|
|
year: currentYear, month: currentMonth, status: 'confirmed'
|
|
});
|
|
}
|
|
if (res && res.success) {
|
|
showToast(res.message || '확인 완료', 'success');
|
|
loadMyRecords();
|
|
} else {
|
|
showToast(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;
|
|
const reason = document.getElementById('rejectReason').value.trim();
|
|
if (!reason) {
|
|
showToast('반려 사유를 입력해주세요', 'error');
|
|
return;
|
|
}
|
|
|
|
isProcessing = true;
|
|
try {
|
|
let res;
|
|
if (MOCK_ENABLED) {
|
|
await new Promise(r => setTimeout(r, 500));
|
|
res = { success: true, message: '이의가 접수되었습니다. 지원팀에 알림이 전달됩니다.' };
|
|
} else {
|
|
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();
|
|
loadMyRecords();
|
|
} else {
|
|
showToast(res?.message || '처리 실패', 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast('네트워크 오류', 'error');
|
|
} finally {
|
|
isProcessing = false;
|
|
}
|
|
}
|
|
|
|
async function downloadExcel() {
|
|
try {
|
|
if (MOCK_ENABLED) {
|
|
showToast('Mock 모드에서는 다운로드를 지원하지 않습니다', 'info');
|
|
return;
|
|
}
|
|
const token = (window.getSSOToken && window.getSSOToken()) || '';
|
|
const response = await fetch(`${window.API_BASE_URL}/monthly-comparison/export?year=${currentYear}&month=${currentMonth}`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (!response.ok) throw new Error('다운로드 실패');
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `월간근무_${currentYear}년${currentMonth}월.xlsx`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
showToast('엑셀 다운로드 실패', 'error');
|
|
}
|
|
}
|
|
|
|
// ===== Admin Check (검토완료 토글) =====
|
|
async function toggleAdminCheck() {
|
|
if (!currentUserId || isProcessing) return;
|
|
var isCurrentlyChecked = comparisonData?.confirmation?.admin_checked;
|
|
var newChecked = !isCurrentlyChecked;
|
|
isProcessing = true;
|
|
try {
|
|
var res = await window.apiCall('/monthly-comparison/admin-check', 'POST', {
|
|
user_id: currentUserId, year: currentYear, month: currentMonth, checked: newChecked
|
|
});
|
|
if (res && res.success) {
|
|
// 상태 업데이트
|
|
if (comparisonData.confirmation) {
|
|
comparisonData.confirmation.admin_checked = newChecked ? 1 : 0;
|
|
}
|
|
var btn = document.getElementById('headerCheckBtn');
|
|
if (btn) {
|
|
btn.textContent = newChecked ? '✓ 검토완료' : '검토하기';
|
|
btn.style.background = newChecked ? '#dcfce7' : '#f3f4f6';
|
|
btn.style.color = newChecked ? '#166534' : '#6b7280';
|
|
}
|
|
showToast(newChecked ? '검토완료' : '검토 해제', 'success');
|
|
} else {
|
|
showToast(res?.message || '처리 실패', 'error');
|
|
}
|
|
} catch (e) { showToast('네트워크 오류', 'error'); }
|
|
finally { isProcessing = false; }
|
|
}
|
|
|
|
// 목록으로 복귀 (월 유지)
|
|
function goBackToList() {
|
|
location.href = '/pages/attendance/monthly-comparison.html?mode=admin&year=' + currentYear + '&month=' + currentMonth;
|
|
}
|
|
|
|
// ===== Review Send (확인요청 일괄 발송) =====
|
|
async function sendReviewAll() {
|
|
if (isProcessing) return;
|
|
if (!confirm(currentYear + '년 ' + currentMonth + '월 미검토 작업자 전체에게 확인요청을 발송하시겠습니까?')) return;
|
|
isProcessing = true;
|
|
try {
|
|
var res = await window.apiCall('/monthly-comparison/review-send', 'POST', {
|
|
year: currentYear, month: currentMonth
|
|
});
|
|
if (res && res.success) {
|
|
showToast(res.message || '확인요청 발송 완료', 'success');
|
|
loadAdminStatus();
|
|
} else {
|
|
showToast(res && res.message || '발송 실패', 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast('네트워크 오류', 'error');
|
|
} finally {
|
|
isProcessing = false;
|
|
}
|
|
}
|
|
|
|
// ===== View Toggle =====
|
|
function toggleViewMode() {
|
|
if (currentMode === 'admin') {
|
|
currentMode = 'my';
|
|
} else {
|
|
currentMode = 'admin';
|
|
}
|
|
currentFilter = 'all';
|
|
loadData();
|
|
}
|
|
|
|
function viewWorkerDetail(userId) {
|
|
location.href = `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${currentYear}&month=${currentMonth}`;
|
|
}
|
|
|
|
// ===== Helpers =====
|
|
function getStatusIcon(status) {
|
|
const icons = {
|
|
match: '<i class="fas fa-check-circle text-green-500"></i>',
|
|
mismatch: '<i class="fas fa-exclamation-triangle text-amber-500"></i>',
|
|
report_only: '<i class="fas fa-file-alt text-blue-500"></i>',
|
|
attend_only: '<i class="fas fa-clock text-purple-500"></i>',
|
|
vacation: '<i class="fas fa-umbrella-beach text-green-400"></i>',
|
|
holiday: '<i class="fas fa-calendar text-gray-400"></i>',
|
|
none: '<i class="fas fa-minus-circle text-red-400"></i>'
|
|
};
|
|
return icons[status] || '';
|
|
}
|
|
|
|
function getStatusLabel(status, record) {
|
|
const labels = {
|
|
match: '일치', mismatch: '불일치', report_only: '보고서만',
|
|
attend_only: '근태만', holiday: '주말', none: '미입력'
|
|
};
|
|
if (status === 'vacation') {
|
|
return record?.attendance?.vacation_type || '연차';
|
|
}
|
|
return labels[status] || '';
|
|
}
|
|
|
|
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; }
|
|
const c = document.getElementById('toastContainer');
|
|
const t = document.createElement('div');
|
|
t.className = `toast toast-${type || 'info'}`;
|
|
t.textContent = msg;
|
|
c.appendChild(t);
|
|
setTimeout(() => t.remove(), 3000);
|
|
}
|
|
|
|
// ===== Inline Attendance Edit (detail mode) =====
|
|
function getAttendanceTypeId(hours, vacTypeId) {
|
|
if (vacTypeId) return 4; // VACATION
|
|
if (hours >= 8) return 1; // REGULAR
|
|
if (hours > 0) return 3; // PARTIAL
|
|
return 0;
|
|
}
|
|
|
|
function editAttendance(date, currentHours, currentVacTypeId) {
|
|
const el = document.getElementById('attend-' + date);
|
|
if (!el) return;
|
|
const vacTypeId = currentVacTypeId === 'null' || currentVacTypeId === null ? '' : currentVacTypeId;
|
|
el.innerHTML = `
|
|
<div class="mc-edit-form">
|
|
<div class="mc-edit-row">
|
|
<label>시간</label>
|
|
<input type="number" id="editHours-${date}" value="${currentHours}" step="0.5" min="0" max="24" class="mc-edit-input">
|
|
<span>h</span>
|
|
</div>
|
|
<div class="mc-edit-row">
|
|
<label>휴가</label>
|
|
<select id="editVacType-${date}" class="mc-edit-select" onchange="onVacTypeChange('${date}')">
|
|
<option value="">없음</option>
|
|
<option value="1" ${vacTypeId == 1 ? 'selected' : ''}>연차</option>
|
|
<option value="2" ${vacTypeId == 2 ? 'selected' : ''}>반차</option>
|
|
<option value="3" ${vacTypeId == 3 ? 'selected' : ''}>반반차</option>
|
|
</select>
|
|
</div>
|
|
<div class="mc-edit-actions">
|
|
<button class="mc-edit-save" onclick="saveAttendance('${date}')"><i class="fas fa-check"></i> 저장</button>
|
|
<button class="mc-edit-cancel" onclick="loadData()">취소</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function onVacTypeChange(date) {
|
|
const vacType = document.getElementById('editVacType-' + date).value;
|
|
const hoursInput = document.getElementById('editHours-' + date);
|
|
if (vacType === '1') hoursInput.value = '0'; // 연차 → 0시간
|
|
else if (vacType === '2') hoursInput.value = '4'; // 반차 → 4시간
|
|
else if (vacType === '3') hoursInput.value = '6'; // 반반차 → 6시간
|
|
}
|
|
|
|
async function saveAttendance(date) {
|
|
const hours = parseFloat(document.getElementById('editHours-' + date).value) || 0;
|
|
const vacTypeVal = document.getElementById('editVacType-' + date).value;
|
|
const vacTypeId = vacTypeVal ? parseInt(vacTypeVal) : null;
|
|
const attTypeId = getAttendanceTypeId(hours, vacTypeId);
|
|
|
|
try {
|
|
await window.apiCall('/attendance/records', 'POST', {
|
|
record_date: date,
|
|
user_id: currentUserId,
|
|
total_work_hours: hours,
|
|
vacation_type_id: vacTypeId,
|
|
attendance_type_id: attTypeId
|
|
});
|
|
showToast('근태 수정 완료', 'success');
|
|
await loadData(); // 전체 새로고침
|
|
} catch (e) {
|
|
showToast('저장 실패: ' + (e.message || e), 'error');
|
|
}
|
|
}
|
|
|
|
// ESC로 모달 닫기
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') closeRejectModal();
|
|
});
|