feat(sprint-004): 월간 비교·확인·정산 프론트엔드 (Section B)
- monthly-comparison.html: 작업자 뷰 + 관리자 뷰 통합 페이지 - monthly-comparison.js: 일별 비교 카드(7상태), 확인/반려 워크플로우, 관리자 진행바+필터+엑셀, Mock 데이터 포함 - monthly-comparison.css: 모바일 우선 스타일 - tkfb-core.js: NAV_MENU에 월간 비교·확인 추가 - 권한: role 기반 mode 자동 결정, 일반 작업자 admin 접근 차단 - 상태 전이: pending→confirmed/rejected, rejected→confirmed 재확인 가능 - 엑셀: pending 0명일 때 활성화 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
305
system1-factory/web/css/monthly-comparison.css
Normal file
305
system1-factory/web/css/monthly-comparison.css
Normal file
@@ -0,0 +1,305 @@
|
||||
/* monthly-comparison.css — 월간 비교·확인·정산 */
|
||||
|
||||
/* Header */
|
||||
.mc-header {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
padding: 14px 16px;
|
||||
border-radius: 0 0 16px 16px;
|
||||
margin: -16px -16px 0;
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
z-index: 20;
|
||||
}
|
||||
.mc-header-row { display: flex; align-items: center; gap: 12px; }
|
||||
.mc-back-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 8px;
|
||||
border: none; color: white; cursor: pointer;
|
||||
}
|
||||
.mc-header h1 { font-size: 1.05rem; font-weight: 700; flex: 1; }
|
||||
.mc-view-toggle {
|
||||
width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 8px;
|
||||
border: none; color: white; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Month Navigation */
|
||||
.mc-month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
position: relative;
|
||||
}
|
||||
.mc-month-nav button {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #6b7280; background: #f3f4f6;
|
||||
border: none; cursor: pointer;
|
||||
}
|
||||
.mc-month-nav button:hover { background: #e5e7eb; }
|
||||
.mc-month-nav span { font-size: 1rem; font-weight: 700; color: #1f2937; }
|
||||
.mc-status-badge {
|
||||
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
|
||||
font-size: 0.7rem; font-weight: 600;
|
||||
padding: 3px 8px; border-radius: 12px;
|
||||
}
|
||||
.mc-status-badge.pending { background: #fef3c7; color: #92400e; }
|
||||
.mc-status-badge.confirmed { background: #dcfce7; color: #166534; }
|
||||
.mc-status-badge.rejected { background: #fef2f2; color: #991b1b; }
|
||||
|
||||
/* Summary Cards */
|
||||
.mc-summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.mc-summary-cards { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
.mc-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.mc-card-value { font-size: 1.25rem; font-weight: 800; color: #1f2937; }
|
||||
.mc-card-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; }
|
||||
|
||||
/* Mismatch Alert */
|
||||
.mc-mismatch-alert {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #fffbeb; border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.8rem; color: #92400e;
|
||||
}
|
||||
|
||||
/* Daily List */
|
||||
.mc-daily-list { padding-bottom: 100px; }
|
||||
.mc-daily-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.mc-daily-card.match { border-left-color: #10b981; }
|
||||
.mc-daily-card.mismatch { background: #fffbeb; border-left-color: #f59e0b; }
|
||||
.mc-daily-card.report_only { background: #eff6ff; border-left-color: #3b82f6; }
|
||||
.mc-daily-card.attend_only { background: #f5f3ff; border-left-color: #8b5cf6; }
|
||||
.mc-daily-card.vacation { background: #f0fdf4; border-left-color: #34d399; }
|
||||
.mc-daily-card.holiday { background: #f9fafb; border-left-color: #9ca3af; }
|
||||
.mc-daily-card.none { background: #fef2f2; border-left-color: #ef4444; }
|
||||
|
||||
.mc-daily-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mc-daily-date { font-size: 0.85rem; font-weight: 600; color: #1f2937; }
|
||||
.mc-daily-status { font-size: 0.7rem; font-weight: 600; display: flex; align-items: center; gap: 4px; }
|
||||
.mc-daily-row { font-size: 0.8rem; color: #374151; margin: 2px 0; }
|
||||
.mc-daily-row span { color: #6b7280; }
|
||||
.mc-daily-diff {
|
||||
font-size: 0.75rem; font-weight: 600; color: #f59e0b;
|
||||
margin-top: 4px;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
/* Bottom Actions */
|
||||
.mc-bottom-actions {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
display: flex; gap: 8px;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
z-index: 30;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mc-confirm-btn {
|
||||
flex: 1; padding: 12px;
|
||||
background: #10b981; color: white;
|
||||
font-size: 0.85rem; font-weight: 700;
|
||||
border: none; border-radius: 10px; cursor: pointer;
|
||||
}
|
||||
.mc-confirm-btn:hover { background: #059669; }
|
||||
.mc-reject-btn {
|
||||
flex: 1; padding: 12px;
|
||||
background: white; color: #ef4444;
|
||||
font-size: 0.85rem; font-weight: 700;
|
||||
border: 2px solid #fecaca; border-radius: 10px; cursor: pointer;
|
||||
}
|
||||
.mc-reject-btn:hover { background: #fef2f2; }
|
||||
|
||||
.mc-confirmed-status {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem; color: #059669; font-weight: 600;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
/* Admin View */
|
||||
.mc-admin-summary {
|
||||
background: white; border-radius: 10px;
|
||||
padding: 16px; margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.mc-progress-bar {
|
||||
height: 8px; background: #e5e7eb; border-radius: 4px;
|
||||
overflow: hidden; margin-bottom: 8px;
|
||||
}
|
||||
.mc-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f59e0b, #10b981);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.mc-progress-text { font-size: 0.8rem; font-weight: 600; color: #1f2937; margin-bottom: 4px; }
|
||||
.mc-status-counts { font-size: 0.75rem; color: #6b7280; display: flex; gap: 12px; }
|
||||
|
||||
/* Filter Tabs */
|
||||
.mc-filter-tabs {
|
||||
display: flex; gap: 4px;
|
||||
padding: 4px; background: #f3f4f6;
|
||||
border-radius: 10px; margin-bottom: 12px;
|
||||
}
|
||||
.mc-tab {
|
||||
flex: 1; padding: 8px 4px;
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
color: #6b7280; background: transparent;
|
||||
border: none; border-radius: 8px; cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.mc-tab.active { background: white; color: #2563eb; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
|
||||
/* Worker List (admin) */
|
||||
.mc-worker-list { padding-bottom: 100px; }
|
||||
.mc-worker-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.mc-worker-card:active { background: #f9fafb; }
|
||||
.mc-worker-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
|
||||
.mc-worker-dept { font-size: 0.7rem; color: #9ca3af; }
|
||||
.mc-worker-stats { font-size: 0.75rem; color: #6b7280; margin: 4px 0; }
|
||||
.mc-worker-status {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.mc-worker-status-badge {
|
||||
font-size: 0.65rem; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 10px;
|
||||
}
|
||||
.mc-worker-status-badge.confirmed { background: #dcfce7; color: #166534; }
|
||||
.mc-worker-status-badge.pending { background: #fef3c7; color: #92400e; }
|
||||
.mc-worker-status-badge.rejected { background: #fef2f2; color: #991b1b; }
|
||||
.mc-worker-reject-reason {
|
||||
font-size: 0.7rem; color: #991b1b;
|
||||
margin-top: 4px; padding-left: 8px;
|
||||
border-left: 2px solid #fecaca;
|
||||
}
|
||||
.mc-worker-mismatch {
|
||||
font-size: 0.65rem; font-weight: 600;
|
||||
color: #f59e0b; background: #fffbeb;
|
||||
padding: 1px 6px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Bottom Export */
|
||||
.mc-bottom-export {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: white;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
z-index: 30;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.mc-export-btn {
|
||||
width: 100%; padding: 12px;
|
||||
background: #059669; color: white;
|
||||
font-size: 0.85rem; font-weight: 700;
|
||||
border: none; border-radius: 10px; cursor: pointer;
|
||||
}
|
||||
.mc-export-btn:disabled { background: #d1d5db; cursor: not-allowed; }
|
||||
.mc-export-note { font-size: 0.7rem; color: #9ca3af; margin-top: 4px; }
|
||||
|
||||
/* Modal */
|
||||
.mc-modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 50;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.mc-modal {
|
||||
background: white; border-radius: 12px;
|
||||
width: 100%; max-width: 400px; overflow: hidden;
|
||||
}
|
||||
.mc-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-weight: 700; font-size: 0.9rem;
|
||||
}
|
||||
.mc-modal-header button { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1.1rem; }
|
||||
.mc-modal-body { padding: 16px; }
|
||||
.mc-modal-desc { font-size: 0.85rem; color: #374151; margin-bottom: 8px; }
|
||||
.mc-textarea {
|
||||
width: 100%; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
padding: 10px; font-size: 0.85rem; resize: none;
|
||||
}
|
||||
.mc-modal-note { font-size: 0.75rem; color: #6b7280; margin-top: 8px; }
|
||||
.mc-modal-footer {
|
||||
display: flex; gap: 8px; padding: 12px 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.mc-modal-cancel {
|
||||
flex: 1; padding: 10px; border: 1px solid #e5e7eb;
|
||||
border-radius: 8px; background: white; cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.mc-modal-submit {
|
||||
flex: 1; padding: 10px; background: #ef4444; color: white;
|
||||
border: none; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Empty / No Permission */
|
||||
.mc-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Skeleton (reuse) */
|
||||
.ds-skeleton {
|
||||
height: 56px;
|
||||
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: ds-shimmer 1.5s infinite;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
.ds-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; }
|
||||
.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; }
|
||||
|
||||
@media (max-width: 480px) { body { max-width: 480px; margin: 0 auto; } }
|
||||
523
system1-factory/web/js/monthly-comparison.js
Normal file
523
system1-factory/web/js/monthly-comparison.js
Normal file
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* monthly-comparison.js — 월간 비교·확인·정산
|
||||
* Sprint 004 Section B
|
||||
*/
|
||||
|
||||
// ===== Mock =====
|
||||
const MOCK_ENABLED = true;
|
||||
|
||||
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 = window.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
// mode 결정: URL > role 기반 자동
|
||||
if (urlMode === 'admin') {
|
||||
if (ADMIN_ROLES.includes(user.role)) {
|
||||
currentMode = 'admin';
|
||||
} else {
|
||||
currentMode = 'my';
|
||||
showToast('관리자 전용 기능입니다', 'error');
|
||||
}
|
||||
} else if (currentUserId && ADMIN_ROLES.includes(user.role)) {
|
||||
currentMode = 'detail';
|
||||
} else if (!urlMode && ADMIN_ROLES.includes(user.role)) {
|
||||
currentMode = 'admin';
|
||||
} else {
|
||||
currentMode = 'my';
|
||||
}
|
||||
|
||||
// 관리자 뷰 전환 버튼 (관리자만)
|
||||
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;
|
||||
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})` : '';
|
||||
attendLine = `<div class="mc-daily-row">근태관리: <strong>${r.attendance.total_work_hours}h</strong> <span>(${escHtml(r.attendance.attendance_type)}${vacInfo})</span></div>`;
|
||||
} else if (r.status !== 'holiday') {
|
||||
attendLine = '<div class="mc-daily-row" style="color:#9ca3af">근태관리: 미입력</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) { actions.classList.remove('hidden'); statusEl.classList.add('hidden'); return; }
|
||||
|
||||
badge.textContent = { pending: '미확인', confirmed: '확인완료', rejected: '반려' }[conf.status] || '';
|
||||
badge.className = `mc-status-badge ${conf.status}`;
|
||||
|
||||
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.pending || 0} 대기</span>` +
|
||||
`<span>❌ ${s.rejected || 0} 반려</span>`;
|
||||
}
|
||||
|
||||
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 statusBadge = `<span class="mc-worker-status-badge ${w.status}">${
|
||||
{ confirmed: '확인완료', pending: '미확인', rejected: '반려' }[w.status] || ''
|
||||
}</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)} ${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 === 'pending').length;
|
||||
|
||||
if (pendingCount === 0) {
|
||||
btn.disabled = false;
|
||||
note.textContent = '모든 작업자가 확인을 완료했습니다';
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length;
|
||||
note.textContent = `${pendingCount}명 미확인${rejectedCount > 0 ? `, ${rejectedCount}명 반려` : ''} — 전원 확인 후 다운로드 가능합니다`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Actions =====
|
||||
async function confirmMonth() {
|
||||
if (!confirm(`${currentYear}년 ${currentMonth}월 근무 내역을 확인하시겠습니까?`)) return;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
function openRejectModal() {
|
||||
document.getElementById('rejectReason').value = '';
|
||||
document.getElementById('rejectModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeRejectModal() {
|
||||
document.getElementById('rejectModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
const reason = document.getElementById('rejectReason').value.trim();
|
||||
if (!reason) {
|
||||
showToast('반려 사유를 입력해주세요', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadExcel() {
|
||||
try {
|
||||
if (MOCK_ENABLED) {
|
||||
showToast('Mock 모드에서는 다운로드를 지원하지 않습니다', 'info');
|
||||
return;
|
||||
}
|
||||
const token = localStorage.getItem('sso_token') || '';
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 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);
|
||||
}
|
||||
|
||||
// ESC로 모달 닫기
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeRejectModal();
|
||||
});
|
||||
164
system1-factory/web/pages/attendance/monthly-comparison.html
Normal file
164
system1-factory/web/pages/attendance/monthly-comparison.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>월간 근무 비교 - TK 공장관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config = { corePlugins: { preflight: false } }</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/tkfb.css?v=2026033001">
|
||||
<link rel="stylesheet" href="/css/monthly-comparison.css?v=2026033001">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<header class="bg-orange-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" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
|
||||
<i class="fas fa-industry text-xl text-orange-200"></i>
|
||||
<h1 class="text-lg font-semibold">TK 공장관리</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
|
||||
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
|
||||
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
|
||||
|
||||
<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-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mc-header">
|
||||
<div class="mc-header-row">
|
||||
<button type="button" onclick="history.back()" class="mc-back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1 id="pageTitle">월간 근무 비교</h1>
|
||||
<button id="viewToggleBtn" class="mc-view-toggle hidden" onclick="toggleViewMode()">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 월 네비게이션 -->
|
||||
<div class="mc-month-nav">
|
||||
<button type="button" onclick="changeMonth(-1)"><i class="fas fa-chevron-left"></i></button>
|
||||
<span id="monthLabel">2026년 3월</span>
|
||||
<button type="button" onclick="changeMonth(1)"><i class="fas fa-chevron-right"></i></button>
|
||||
<div class="mc-status-badge" id="statusBadge"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 작업자 뷰 ═══ -->
|
||||
<div id="workerView">
|
||||
<div class="mc-summary-cards">
|
||||
<div class="mc-card"><div class="mc-card-value" id="totalDays">-</div><div class="mc-card-label">총근무일</div></div>
|
||||
<div class="mc-card"><div class="mc-card-value" id="totalHours">-</div><div class="mc-card-label">총시간</div></div>
|
||||
<div class="mc-card"><div class="mc-card-value" id="overtimeHours">-</div><div class="mc-card-label">연장근로</div></div>
|
||||
<div class="mc-card"><div class="mc-card-value" id="vacationDays">-</div><div class="mc-card-label">휴가</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mc-mismatch-alert hidden" id="mismatchAlert">
|
||||
<i class="fas fa-exclamation-triangle text-amber-500"></i>
|
||||
<span id="mismatchText"></span>
|
||||
</div>
|
||||
|
||||
<div class="mc-daily-list" id="dailyList">
|
||||
<div class="ds-skeleton"></div>
|
||||
<div class="ds-skeleton"></div>
|
||||
<div class="ds-skeleton"></div>
|
||||
</div>
|
||||
|
||||
<div class="mc-bottom-actions" id="bottomActions">
|
||||
<button type="button" class="mc-confirm-btn" id="confirmBtn" onclick="confirmMonth()">
|
||||
<i class="fas fa-check-circle mr-2"></i>확인 완료
|
||||
</button>
|
||||
<button type="button" class="mc-reject-btn" id="rejectBtn" onclick="openRejectModal()">
|
||||
<i class="fas fa-times-circle mr-2"></i>문제 있음
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mc-confirmed-status hidden" id="confirmedStatus">
|
||||
<i class="fas fa-check-circle text-green-500"></i>
|
||||
<span id="confirmedText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 관리자 뷰 ═══ -->
|
||||
<div id="adminView" class="hidden">
|
||||
<div class="mc-admin-summary" id="adminSummary">
|
||||
<div class="mc-progress-bar"><div class="mc-progress-fill" id="progressFill"></div></div>
|
||||
<div class="mc-progress-text" id="progressText"></div>
|
||||
<div class="mc-status-counts" id="statusCounts"></div>
|
||||
</div>
|
||||
|
||||
<div class="mc-filter-tabs">
|
||||
<button class="mc-tab active" data-filter="all" onclick="filterWorkers('all')">전체</button>
|
||||
<button class="mc-tab" data-filter="confirmed" onclick="filterWorkers('confirmed')">확인</button>
|
||||
<button class="mc-tab" data-filter="pending" onclick="filterWorkers('pending')">대기</button>
|
||||
<button class="mc-tab" data-filter="rejected" onclick="filterWorkers('rejected')">반려</button>
|
||||
</div>
|
||||
|
||||
<div class="mc-worker-list" id="adminWorkerList">
|
||||
<div class="ds-skeleton"></div>
|
||||
</div>
|
||||
|
||||
<div class="mc-bottom-export" id="bottomExport">
|
||||
<button type="button" class="mc-export-btn" id="exportBtn" onclick="downloadExcel()" disabled>
|
||||
<i class="fas fa-file-excel mr-2"></i>엑셀 다운로드
|
||||
</button>
|
||||
<div class="mc-export-note" id="exportNote"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div class="mc-empty hidden" id="emptyState">
|
||||
<i class="fas fa-calendar-xmark text-3xl text-gray-300"></i>
|
||||
<p>해당 월의 데이터가 없습니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 권한 없음 -->
|
||||
<div class="ds-empty hidden" id="noPermission">
|
||||
<i class="fas fa-lock text-3xl text-gray-300"></i>
|
||||
<p>접근 권한이 없습니다</p>
|
||||
<a href="/pages/dashboard-new.html" class="ds-link">대시보드로 이동</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려 모달 -->
|
||||
<div class="mc-modal-overlay hidden" id="rejectModal">
|
||||
<div class="mc-modal">
|
||||
<div class="mc-modal-header">
|
||||
<span><i class="fas fa-times-circle text-red-500 mr-2"></i>문제 있음 (반려)</span>
|
||||
<button type="button" onclick="closeRejectModal()"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="mc-modal-body">
|
||||
<p class="mc-modal-desc">반려 사유를 입력해주세요:</p>
|
||||
<textarea id="rejectReason" class="mc-textarea" rows="3" placeholder="예: 3/2 근무시간이 실제와 다릅니다"></textarea>
|
||||
<p class="mc-modal-note">
|
||||
<i class="fas fa-info-circle text-blue-400 mr-1"></i>
|
||||
반려 시 생산지원팀에 알림이 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mc-modal-footer">
|
||||
<button type="button" class="mc-modal-cancel" onclick="closeRejectModal()">취소</button>
|
||||
<button type="button" class="mc-modal-submit" id="rejectSubmitBtn" onclick="submitReject()">반려 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
<script src="/static/js/tkfb-core.js?v=2026033001"></script>
|
||||
<script src="/js/api-base.js?v=2026031701"></script>
|
||||
<script src="/js/monthly-comparison.js?v=2026033001"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -137,6 +137,7 @@ const NAV_MENU = [
|
||||
{ href: '/pages/attendance/vacation-management.html', icon: 'fa-cog', label: '휴가 관리', key: 'attendance.vacation_management', admin: true },
|
||||
{ href: '/pages/attendance/vacation-allocation.html', icon: 'fa-plus-circle', label: '휴가 발생 입력', key: 'attendance.vacation_allocation', admin: true },
|
||||
{ href: '/pages/attendance/annual-overview.html', icon: 'fa-chart-pie', label: '연간 휴가 현황', key: 'attendance.annual_overview', admin: true },
|
||||
{ href: '/pages/attendance/monthly-comparison.html', icon: 'fa-scale-balanced', label: '월간 비교·확인', key: 'attendance.monthly_comparison' },
|
||||
]},
|
||||
{ cat: '시스템 관리', admin: true, items: [
|
||||
{ href: `${_tkuserBase}/?tab=users`, icon: 'fa-users-cog', label: '사용자 관리', key: 'admin.user_management', external: true },
|
||||
|
||||
Reference in New Issue
Block a user