Files
TK-FB-Project/web-ui/pages/attendance/monthly.html
Hyungi Ahn 7c38c555f5 fix: 출근체크/근무현황 페이지 버그 수정
- workers API 기본 limit 10 → 100 변경 (작업자 누락 문제 해결)
- 작업자 필터 조건 수정 (status='active' + employment_status 체크)
- 근태 기록 저장 시 컬럼명 불일치 수정 (attendance_type_id)
- 근무현황 페이지에 저장 상태 표시 추가 (✓저장됨)
- 디버그 로그 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:58:30 +09:00

488 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>월별 출퇴근 현황 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<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>
.calendar-container {
margin-top: 2rem;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: #e5e7eb;
border: 1px solid #e5e7eb;
}
.calendar-header {
background-color: #f3f4f6;
padding: 1rem;
text-align: center;
font-weight: 600;
color: #374151;
}
.calendar-day {
background-color: white;
padding: 0.5rem;
min-height: 100px;
position: relative;
}
.calendar-day.today {
background-color: #fef3c7;
}
.calendar-day.other-month {
background-color: #f9fafb;
color: #9ca3af;
}
.day-number {
font-weight: 600;
margin-bottom: 0.5rem;
}
.day-info {
font-size: 0.75rem;
margin-top: 0.25rem;
}
.attendance-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-normal {
background-color: #d1fae5;
color: #065f46;
}
.badge-overtime {
background-color: #fef3c7;
color: #92400e;
}
.badge-annual {
background-color: #dbeafe;
color: #1e40af;
}
.badge-half {
background-color: #e0e7ff;
color: #3730a3;
}
.badge-quarter {
background-color: #f3e8ff;
color: #5b21b6;
}
.badge-early {
background-color: #fce7f3;
color: #9f1239;
}
.worker-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.summary-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-item {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.summary-label {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.summary-value {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
</style>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">월별 출퇴근 현황</h1>
<p class="page-description">이번 달 출퇴근 현황을 조회합니다</p>
</div>
<div class="page-actions">
<input type="month" id="selectedMonth" class="form-control" style="width: auto;">
<button class="btn btn-primary" onclick="loadMonthlyData()">
새로고침
</button>
</div>
</div>
<!-- 작업자 선택 -->
<div class="content-section">
<div class="card">
<div class="card-body">
<div class="worker-selector">
<label for="workerSelect" style="font-weight: 600;">작업자:</label>
<select id="workerSelect" class="form-control" style="width: 300px;" onchange="loadMonthlyData()">
<option value="">작업자를 선택하세요</option>
</select>
</div>
</div>
</div>
</div>
<!-- 월별 요약 통계 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 요약</h2>
</div>
<div class="card-body">
<div class="summary-card" id="summarySection">
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
<!-- 달력 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title" id="calendarTitle">출퇴근 달력</h2>
</div>
<div class="card-body">
<div class="calendar-container">
<div class="calendar-grid">
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div id="calendarDays">
<!-- 달력 날짜가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script>
// 전역 변수
let workers = [];
let attendanceRecords = [];
let currentUser = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 현재 년월 설정
const now = new Date();
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
document.getElementById('selectedMonth').value = yearMonth;
// 현재 사용자 정보 가져오기
try {
const userInfo = JSON.parse(localStorage.getItem('user'));
currentUser = userInfo;
await loadWorkers();
// 관리자가 아니면 자동으로 자신 선택
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
const workerSelect = document.getElementById('workerSelect');
if (currentUser.worker_id) {
workerSelect.value = currentUser.worker_id;
}
}
await loadMonthlyData();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers?limit=100');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
const select = document.getElementById('workerSelect');
select.innerHTML = '<option value="">작업자를 선택하세요</option>';
workers.forEach(worker => {
const option = document.createElement('option');
option.value = worker.worker_id;
option.textContent = worker.worker_name;
select.appendChild(option);
});
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadMonthlyData() {
const selectedMonth = document.getElementById('selectedMonth').value;
const workerId = document.getElementById('workerSelect').value;
if (!selectedMonth) {
alert('월을 선택해주세요.');
return;
}
if (!workerId) {
alert('작업자를 선택해주세요.');
return;
}
try {
// 선택한 월의 첫날과 마지막 날 계산
const [year, month] = selectedMonth.split('-');
const startDate = `${year}-${month}-01`;
const endDate = new Date(year, month, 0).getDate();
const endDateStr = `${year}-${month}-${String(endDate).padStart(2, '0')}`;
// 출퇴근 기록 로드
const response = await axios.get(`/attendance/records`, {
params: {
worker_id: workerId,
start_date: startDate,
end_date: endDateStr
}
});
if (response.data.success) {
attendanceRecords = response.data.data || [];
renderCalendar();
renderSummary();
}
} catch (error) {
console.error('월별 데이터 로드 오류:', error);
if (error.response?.status === 404) {
attendanceRecords = [];
renderCalendar();
renderSummary();
}
}
}
function renderCalendar() {
const selectedMonth = document.getElementById('selectedMonth').value;
const [year, month] = selectedMonth.split('-');
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
const today = new Date();
// 달력 제목 업데이트
document.getElementById('calendarTitle').textContent = `${year}${month}월 출퇴근 달력`;
// 달력 그리드 생성
const calendarDays = document.getElementById('calendarDays');
calendarDays.innerHTML = '';
// 이전 달의 빈 칸
for (let i = 0; i < firstDay.getDay(); i++) {
const emptyDay = document.createElement('div');
emptyDay.className = 'calendar-day other-month';
calendarDays.appendChild(emptyDay);
}
// 현재 달의 날짜
for (let day = 1; day <= lastDay.getDate(); day++) {
const dayElement = document.createElement('div');
const currentDate = new Date(year, month - 1, day);
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
dayElement.className = 'calendar-day';
// 오늘 날짜 표시
if (currentDate.toDateString() === today.toDateString()) {
dayElement.classList.add('today');
}
// 날짜 번호
const dayNumber = document.createElement('div');
dayNumber.className = 'day-number';
dayNumber.textContent = day;
dayElement.appendChild(dayNumber);
// 출퇴근 기록 찾기
const record = attendanceRecords.find(r => r.attendance_date === dateStr);
if (record) {
const infoDiv = document.createElement('div');
infoDiv.className = 'day-info';
// 근무시간
const hoursSpan = document.createElement('div');
hoursSpan.textContent = `${record.total_hours}시간`;
infoDiv.appendChild(hoursSpan);
// 야근 표시
if (record.is_overtime) {
const overtimeBadge = document.createElement('span');
overtimeBadge.className = 'attendance-badge badge-overtime';
overtimeBadge.textContent = '야근';
infoDiv.appendChild(overtimeBadge);
}
// 근태 구분
if (record.attendance_type && record.attendance_type !== 'normal') {
const typeBadge = document.createElement('span');
typeBadge.className = 'attendance-badge';
switch (record.attendance_type) {
case 'annual_leave':
typeBadge.classList.add('badge-annual');
typeBadge.textContent = '연차';
break;
case 'half_leave':
typeBadge.classList.add('badge-half');
typeBadge.textContent = '반차';
break;
case 'quarter_leave':
typeBadge.classList.add('badge-quarter');
typeBadge.textContent = '반반차';
break;
case 'early_leave':
typeBadge.classList.add('badge-early');
typeBadge.textContent = '조퇴';
break;
default:
typeBadge.classList.add('badge-normal');
typeBadge.textContent = '정상';
}
infoDiv.appendChild(typeBadge);
}
dayElement.appendChild(infoDiv);
}
calendarDays.appendChild(dayElement);
}
}
function renderSummary() {
const summarySection = document.getElementById('summarySection');
// 통계 계산
const totalDays = attendanceRecords.length;
const totalHours = attendanceRecords.reduce((sum, r) => sum + (r.total_hours || 0), 0);
const overtimeDays = attendanceRecords.filter(r => r.is_overtime).length;
const annualLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'annual_leave').length;
const halfLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'half_leave').length;
const quarterLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'quarter_leave').length;
summarySection.innerHTML = `
<div class="summary-item">
<div class="summary-label">총 근무일수</div>
<div class="summary-value">${totalDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">총 근무시간</div>
<div class="summary-value">${totalHours.toFixed(1)}시간</div>
</div>
<div class="summary-item">
<div class="summary-label">야근일수</div>
<div class="summary-value">${overtimeDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">연차 사용</div>
<div class="summary-value">${annualLeaveDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">반차 사용</div>
<div class="summary-value">${halfLeaveDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">반반차 사용</div>
<div class="summary-value">${quarterLeaveDays}일</div>
</div>
`;
}
</script>
</body>
</html>