refactor: 코드 관리 페이지 삭제 및 프론트엔드 모듈화

- codes.html, code-management.js 삭제 (tasks.html에서 동일 기능 제공)
- 사이드바에서 코드 관리 링크 제거
- daily-work-report, tbm, workplace-management JS 모듈 분리
- common/security.js 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-05 06:42:12 +09:00
parent 36f110c90a
commit 170adcc149
25 changed files with 6202 additions and 1606 deletions

View File

@@ -9,236 +9,165 @@
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.page-wrapper {
padding: 1.5rem;
max-width: 1400px;
}
.summary-cards {
.page-header {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.summary-card {
flex: 1;
min-width: 100px;
padding: 1rem;
background: white;
border-radius: 0.5rem;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.summary-card.normal { border-left: 4px solid #10b981; }
.summary-card.annual { border-left: 4px solid #3b82f6; }
.summary-card.half { border-left: 4px solid #22c55e; }
.summary-card.quarter { border-left: 4px solid #eab308; }
.summary-card.early { border-left: 4px solid #ef4444; }
.summary-card.overtime { border-left: 4px solid #f97316; }
.summary-value { font-size: 1.5rem; font-weight: 700; }
.summary-label { font-size: 0.75rem; color: #6b7280; }
.status-table {
width: 100%;
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.status-table th {
background: #f8fafc;
padding: 0.75rem 1rem;
text-align: left;
.page-title {
font-size: 1.25rem;
font-weight: 600;
font-size: 0.875rem;
border-bottom: 2px solid #e5e7eb;
}
.status-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
vertical-align: middle;
}
.status-table tr:hover { background: #f8fafc; }
.status-table tr.absent { background: #fef2f2; }
.worker-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.worker-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.worker-dot.present { background: #10b981; }
.worker-dot.absent { background: #ef4444; }
.type-select {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
min-width: 110px;
}
.overtime-group {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.overtime-input {
width: 60px;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
text-align: center;
font-size: 0.875rem;
margin: 0;
}
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
align-items: center;
}
.controls input[type="date"] {
padding: 0.5rem;
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.8rem;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-outline { background: white; border: 1px solid #d1d5db; }
.btn-success { background: #10b981; color: white; }
/* 요약 */
.summary-row {
display: flex;
gap: 1rem;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.summary-row span { display: flex; align-items: center; gap: 0.25rem; }
.summary-row .dot {
width: 8px; height: 8px; border-radius: 50%;
}
.dot-normal { background: #10b981; }
.dot-annual { background: #3b82f6; }
.dot-half { background: #22c55e; }
.dot-quarter { background: #eab308; }
.dot-early { background: #ef4444; }
.dot-overtime { background: #f97316; }
/* 테이블 */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
background: white;
border: 1px solid #e5e7eb;
}
.data-table th, .data-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.data-table th {
background: #f9fafb;
font-weight: 500;
color: #374151;
font-size: 0.8rem;
}
.data-table tr:hover {
background: #f9fafb;
}
.data-table tr.saved {
background: #f0fdf4;
}
.data-table tr.absent {
background: #fef2f2;
}
.worker-name {
font-weight: 500;
}
.saved-tag {
font-size: 0.65rem;
color: #10b981;
background: #dcfce7;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
}
.type-select {
padding: 0.25rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.8rem;
min-width: 100px;
}
.overtime-input {
width: 50px;
padding: 0.25rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
text-align: center;
font-size: 0.8rem;
}
.hours-cell {
text-align: center;
min-width: 60px;
}
.status-present { color: #10b981; }
.status-absent { color: #ef4444; }
/* 저장 영역 */
.save-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
}
.save-status {
font-size: 0.8rem;
color: #6b7280;
}
.save-status.saved { color: #10b981; }
.save-status.unsaved { color: #f59e0b; }
.btn-save {
display: block;
margin: 1.5rem auto 0;
padding: 0.75rem 2rem;
font-size: 1rem;
padding: 0.5rem 1.5rem;
font-size: 0.875rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
}
.btn-save:hover { background: #2563eb; }
.btn-save:disabled { background: #9ca3af; cursor: not-allowed; }
.btn-save.saved { background: #10b981; }
.btn-save.saving { background: #6b7280; }
.no-checkin-warning {
.warning-box {
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
text-align: center;
}
/* 저장 상태 섹션 */
.save-section {
text-align: center;
margin-top: 1.5rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 1rem;
}
.status-badge.saved {
background: #dcfce7;
color: #166534;
}
.status-badge.unsaved {
background: #fef3c7;
color: #92400e;
}
/* 토스트 알림 */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 0.5rem;
color: white;
font-weight: 500;
z-index: 9999;
animation: slideIn 0.3s ease, fadeOut 0.3s ease 2.7s;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.toast.success { background: #10b981; }
.toast.error { background: #ef4444; }
.toast.info { background: #3b82f6; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* 저장 성공 오버레이 */
.save-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(16, 185, 129, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9998;
animation: fadeIn 0.3s ease;
}
.save-overlay .checkmark {
width: 80px;
height: 80px;
border-radius: 50%;
background: white;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
animation: scaleIn 0.4s ease;
}
.save-overlay .checkmark svg {
width: 40px;
height: 40px;
stroke: #10b981;
stroke-width: 3;
}
.save-overlay .message {
color: white;
font-size: 1.5rem;
font-weight: 700;
}
.save-overlay .sub-message {
color: rgba(255,255,255,0.9);
font-size: 1rem;
margin-top: 0.5rem;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0); }
to { transform: scale(1); }
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
margin-bottom: 0.75rem;
font-size: 0.8rem;
}
.warning-box a { color: #92400e; font-weight: 500; }
</style>
</head>
<body class="has-sidebar">
@@ -247,72 +176,52 @@
<main class="main-content">
<div class="page-wrapper">
<h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;">근무 현황</h1>
<p style="color: #64748b; margin-bottom: 1.5rem;">휴가/조퇴 및 연장근무를 입력합니다</p>
<div class="controls">
<input type="date" id="selectedDate">
<button class="btn btn-primary" onclick="loadWorkStatus()">새로고침</button>
<div class="page-header">
<h1 class="page-title">근무 현황</h1>
<div class="controls">
<input type="date" id="selectedDate">
<button class="btn btn-primary" onclick="loadWorkStatus()">조회</button>
<button class="btn btn-outline" onclick="setAllNormal()">전체 정시근무</button>
</div>
</div>
<div id="noCheckinWarning" class="no-checkin-warning" style="display:none;">
<div id="noCheckinWarning" class="warning-box" style="display:none;">
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
</div>
<div class="summary-cards">
<div class="summary-card normal">
<div class="summary-value" id="normalCount">0</div>
<div class="summary-label">정시근무</div>
</div>
<div class="summary-card annual">
<div class="summary-value" id="annualCount">0</div>
<div class="summary-label">연차</div>
</div>
<div class="summary-card half">
<div class="summary-value" id="halfCount">0</div>
<div class="summary-label">반차</div>
</div>
<div class="summary-card quarter">
<div class="summary-value" id="quarterCount">0</div>
<div class="summary-label">반반차</div>
</div>
<div class="summary-card early">
<div class="summary-value" id="earlyCount">0</div>
<div class="summary-label">조퇴</div>
</div>
<div class="summary-card overtime">
<div class="summary-value" id="overtimeCount">0</div>
<div class="summary-label">연장근로</div>
</div>
<div class="summary-row">
<span><span class="dot dot-normal"></span> 정시 <strong id="normalCount">0</strong></span>
<span><span class="dot dot-annual"></span> 연차 <strong id="annualCount">0</strong></span>
<span><span class="dot dot-half"></span> 반차 <strong id="halfCount">0</strong></span>
<span><span class="dot dot-quarter"></span> 반반차 <strong id="quarterCount">0</strong></span>
<span><span class="dot dot-early"></span> 조퇴 <strong id="earlyCount">0</strong></span>
<span><span class="dot dot-overtime"></span> 연장 <strong id="overtimeCount">0</strong></span>
</div>
<table class="status-table">
<table class="data-table">
<thead>
<tr>
<th style="width: 130px;">작업자</th>
<th style="width: 80px;">출근</th>
<th style="width: 130px;">근태 구분</th>
<th style="width: 100px;">무시간</th>
<th style="width: 150px;">연장근로</th>
<th style="width:30px">#</th>
<th>이름</th>
<th>출근</th>
<th>태구분</th>
<th class="hours-cell">기본</th>
<th class="hours-cell">연장</th>
<th class="hours-cell">합계</th>
</tr>
</thead>
<tbody id="statusTableBody">
<tbody id="workerTableBody">
</tbody>
</table>
<div class="save-section">
<div id="saveStatus"></div>
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">근무 현황 저장</button>
<div class="save-bar">
<span id="saveStatus" class="save-status"></span>
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">저장</button>
</div>
</div>
</main>
<!-- 토스트 컨테이너 -->
<div id="toastContainer"></div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
</script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
@@ -333,7 +242,6 @@
let isAlreadySaved = false;
let isSaving = false;
// 근태 구분 옵션
const attendanceTypes = [
{ value: 'normal', label: '정시근무', hours: 8 },
{ value: 'annual', label: '연차', hours: 0 },
@@ -374,9 +282,7 @@
workers = (workersRes.data.data || []).filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
const records = recordsRes.data.data || [];
// 출근 체크 데이터가 있는지 확인
hasCheckinData = records.length > 0;
// 이미 저장된 근무 현황이 있는지 확인 (attendance_type_id가 설정된 경우)
isAlreadySaved = records.some(r => r.attendance_type_id || r.total_work_hours > 0);
document.getElementById('noCheckinWarning').style.display = hasCheckinData ? 'none' : 'block';
@@ -385,26 +291,23 @@
const record = records.find(r => r.worker_id === w.worker_id);
if (record) {
// 기존 데이터 기반으로 설정
let type = 'normal';
let overtimeHours = 0;
// is_present가 0이면 결근 → 연차로 기본 설정
if (record.is_present === 0) {
type = 'annual';
} else {
// 기존 저장된 타입이 있으면 사용
if (record.attendance_type_code) {
const codeMap = {
'NORMAL': 'normal',
'REGULAR': 'normal',
'VACATION': 'annual',
'HALF_LEAVE': 'half',
'QUARTER_LEAVE': 'quarter',
'EARLY_LEAVE': 'early'
'PARTIAL': 'early',
'OVERTIME': 'overtime'
};
type = codeMap[record.attendance_type_code] || 'normal';
}
// 연장근로 시간이 있으면 연장근로 타입으로
if (record.total_work_hours > 8) {
type = 'overtime';
overtimeHours = record.total_work_hours - 8;
@@ -419,7 +322,6 @@
isSaved: record.attendance_type_id != null || record.total_work_hours > 0
};
} else {
// 데이터 없으면 기본값 (출근, 정시근무)
workStatus[w.worker_id] = {
isPresent: true,
type: 'normal',
@@ -435,33 +337,35 @@
updateSaveStatus();
} catch (e) {
console.error(e);
showToast('데이터 로드 실패', 'error');
alert('데이터 로드 실패');
}
}
function render() {
const tbody = document.getElementById('statusTableBody');
const tbody = document.getElementById('workerTableBody');
if (workers.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;color:#6b7280;">작업자가 없습니다</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:2rem;">작업자가 없습니다</td></tr>';
return;
}
tbody.innerHTML = workers.map(w => {
tbody.innerHTML = workers.map((w, idx) => {
const s = workStatus[w.worker_id];
const isAbsent = !s.isPresent;
const showOvertimeInput = s.type === 'overtime';
const baseHours = s.hours;
const totalHours = s.type === 'overtime' ? baseHours + s.overtimeHours : baseHours;
const rowClass = s.isSaved ? 'saved' : (!s.isPresent ? 'absent' : '');
return `
<tr class="${isAbsent ? 'absent' : ''}" style="${s.isSaved ? 'background:#f0fdf4;' : ''}">
<tr class="${rowClass}">
<td>${idx + 1}</td>
<td>
<div class="worker-cell">
<span class="worker-dot ${s.isPresent ? 'present' : 'absent'}"></span>
<span>${w.worker_name}</span>
${s.isSaved ? '<span style="margin-left:0.5rem;font-size:0.7rem;color:#10b981;">✓저장됨</span>' : ''}
</div>
<span class="worker-name">${w.worker_name}</span>
${s.isSaved ? '<span class="saved-tag">저장됨</span>' : ''}
</td>
<td class="${s.isPresent ? 'status-present' : 'status-absent'}">
${s.isPresent ? '출근' : '결근'}
</td>
<td>${s.isPresent ? '<span style="color:#10b981">출근</span>' : '<span style="color:#ef4444">결근</span>'}</td>
<td>
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
${attendanceTypes.map(t => `
@@ -469,16 +373,14 @@
`).join('')}
</select>
</td>
<td>${s.type === 'overtime' ? (s.hours + s.overtimeHours) : s.hours}시간</td>
<td>
<td class="hours-cell">${baseHours}h</td>
<td class="hours-cell">
${showOvertimeInput ? `
<div class="overtime-group">
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
onchange="updateOvertime(${w.worker_id}, this.value)">
<span style="color:#6b7280;font-size:0.875rem;">시간</span>
</div>
` : '<span style="color:#9ca3af;">-</span>'}
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
onchange="updateOvertime(${w.worker_id}, this.value)">
` : '-'}
</td>
<td class="hours-cell"><strong>${totalHours}h</strong></td>
</tr>
`;
}).join('');
@@ -489,7 +391,6 @@
workStatus[workerId].type = value;
workStatus[workerId].hours = type ? type.hours : 8;
// 연장근로 선택 시 기본 2시간
if (value === 'overtime') {
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
} else {
@@ -506,6 +407,16 @@
updateSummary();
}
function setAllNormal() {
workers.forEach(w => {
workStatus[w.worker_id].type = 'normal';
workStatus[w.worker_id].hours = 8;
workStatus[w.worker_id].overtimeHours = 0;
});
render();
updateSummary();
}
function updateSummary() {
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0;
@@ -528,78 +439,44 @@
document.getElementById('overtimeCount').textContent = overtime;
}
// 토스트 알림 표시
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// 저장 성공 오버레이 표시
function showSaveOverlay(count) {
const overlay = document.createElement('div');
overlay.className = 'save-overlay';
overlay.id = 'saveOverlay';
overlay.innerHTML = `
<div class="checkmark">
<svg viewBox="0 0 24 24" fill="none">
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="message">저장 완료!</div>
<div class="sub-message">${count}명의 근무 현황이 저장되었습니다</div>
`;
document.body.appendChild(overlay);
setTimeout(() => {
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s ease';
setTimeout(() => overlay.remove(), 300);
}, 1500);
}
// 저장 상태 업데이트
function updateSaveStatus() {
const statusEl = document.getElementById('saveStatus');
const saveBtn = document.getElementById('saveBtn');
if (isAlreadySaved) {
statusEl.innerHTML = '<span class="status-badge saved">✓ 이 날짜의 근무 현황이 저장되어 있습니다</span>';
saveBtn.textContent = '수정하여 다시 저장';
saveBtn.classList.add('saved');
statusEl.innerHTML = '이 날짜의 근무 현황이 저장되어 있습니다';
statusEl.className = 'save-status saved';
saveBtn.textContent = '수정 저장';
} else {
statusEl.innerHTML = '<span class="status-badge unsaved">⚠ 아직 저장되지 않았습니다</span>';
saveBtn.textContent = '근무 현황 저장';
saveBtn.classList.remove('saved');
statusEl.innerHTML = '아직 저장되지 않았습니다';
statusEl.className = 'save-status unsaved';
saveBtn.textContent = '저장';
}
}
async function saveWorkStatus() {
const date = document.getElementById('selectedDate').value;
if (!date) return showToast('날짜를 선택해주세요.', 'error');
if (!date) return alert('날짜를 선택해주세요.');
if (isSaving) return;
const saveBtn = document.getElementById('saveBtn');
// attendance_type_id 매핑 (DB의 work_attendance_types 테이블)
// work_attendance_types: 1=REGULAR, 2=OVERTIME, 3=PARTIAL, 4=VACATION
const typeIdMap = {
'normal': 1, // NORMAL
'annual': 5, // VACATION
'half': 5, // VACATION (반차)
'quarter': 5, // VACATION (반반차)
'early': 3, // EARLY_LEAVE
'overtime': 1 // NORMAL (연장근로는 정상출근 + 추가시간)
'normal': 1,
'annual': 4,
'half': 4,
'quarter': 4,
'early': 3,
'overtime': 2
};
// vacation_type_id 매핑 (필요한 경우)
// vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER
const vacationTypeIdMap = {
'annual': 1, // ANNUAL
'half': 2, // HALF_ANNUAL
'quarter': null, // 반반차는 별도 처리 필요할 수 있음
'annual': 1,
'half': 2,
'quarter': 3,
};
const recordsToSave = workers.map(w => {
@@ -617,10 +494,8 @@
};
});
// 저장 시작 - 버튼 상태 변경
isSaving = true;
saveBtn.disabled = true;
saveBtn.classList.add('saving');
saveBtn.textContent = '저장 중...';
try {
@@ -636,10 +511,8 @@
}
if (fail === 0) {
// 성공 - 오버레이 표시
showSaveOverlay(ok);
alert(`${ok}명 저장 완료`);
isAlreadySaved = true;
// 모든 작업자 저장됨 표시
workers.forEach(w => {
if (workStatus[w.worker_id]) {
workStatus[w.worker_id].isSaved = true;
@@ -648,17 +521,16 @@
render();
updateSaveStatus();
} else if (ok > 0) {
showToast(`${ok}명 성공, ${fail}명 실패`, 'error');
alert(`${ok}명 성공, ${fail}명 실패`);
} else {
showToast('저장에 실패했습니다', 'error');
alert('저장에 실패했습니다');
}
} catch (e) {
console.error(e);
showToast('저장 중 오류가 발생했습니다', 'error');
alert('저장 중 오류가 발생했습니다');
} finally {
isSaving = false;
saveBtn.disabled = false;
saveBtn.classList.remove('saving');
updateSaveStatus();
}
}