- 실시간 작업장 현황을 지도로 시각화 - 작업장 관리 페이지에서 정의한 구역 정보 활용 - TBM 작업자 및 방문자 현황 표시 주요 변경사항: - dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거) - workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현 - modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가 시각화 방식: - 인원 없음: 회색 테두리 + 작업장 이름 - 내부 작업자: 파란색 영역 + 인원 수 - 외부 방문자: 보라색 영역 + 인원 수 - 둘 다: 초록색 영역 + 총 인원 수 기술 구현: - Canvas API 기반 사각형 영역 렌더링 - map-regions API를 통한 데이터 일관성 보장 - 클릭 이벤트로 상세 정보 모달 표시 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
491 lines
16 KiB
HTML
491 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/auth-check.js?v=1" defer></script>
|
|
<script type="module" src="/js/api-config.js?v=3"></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">
|
|
<span class="title-icon">📆</span>
|
|
월별 출퇴근 현황
|
|
</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()">
|
|
<span>🔄 새로고침</span>
|
|
</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" src="/js/load-navbar.js?v=5"></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');
|
|
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>
|