- 실시간 작업장 현황을 지도로 시각화 - 작업장 관리 페이지에서 정의한 구역 정보 활용 - 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>
494 lines
16 KiB
HTML
494 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>
|
|
.comparison-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
.comparison-card {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #e5e7eb;
|
|
}
|
|
.comparison-title {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
margin-bottom: 1rem;
|
|
color: #111827;
|
|
}
|
|
.discrepancy-badge {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
.badge-match {
|
|
background-color: #d1fae5;
|
|
color: #065f46;
|
|
}
|
|
.badge-mismatch {
|
|
background-color: #fee2e2;
|
|
color: #991b1b;
|
|
}
|
|
.badge-missing-attendance {
|
|
background-color: #fef3c7;
|
|
color: #92400e;
|
|
}
|
|
.badge-missing-report {
|
|
background-color: #dbeafe;
|
|
color: #1e40af;
|
|
}
|
|
.filter-section {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.summary-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
.stat-card {
|
|
background: white;
|
|
padding: 1rem;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #e5e7eb;
|
|
text-align: center;
|
|
}
|
|
.stat-label {
|
|
font-size: 0.875rem;
|
|
color: #6b7280;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.stat-value {
|
|
font-size: 1.75rem;
|
|
font-weight: 600;
|
|
color: #111827;
|
|
}
|
|
.detail-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1rem;
|
|
padding: 0.5rem;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
.detail-label {
|
|
font-weight: 500;
|
|
color: #6b7280;
|
|
}
|
|
.detail-value {
|
|
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">
|
|
<button class="btn btn-primary" onclick="loadComparisonData()">
|
|
<span>🔄 새로고침</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 필터 섹션 -->
|
|
<div class="content-section">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="filter-section">
|
|
<div style="flex: 1; min-width: 200px;">
|
|
<label for="startDate">시작일</label>
|
|
<input type="date" id="startDate" class="form-control">
|
|
</div>
|
|
<div style="flex: 1; min-width: 200px;">
|
|
<label for="endDate">종료일</label>
|
|
<input type="date" id="endDate" class="form-control">
|
|
</div>
|
|
<div style="flex: 1; min-width: 200px;">
|
|
<label for="workerFilter">작업자</label>
|
|
<select id="workerFilter" class="form-control">
|
|
<option value="">전체</option>
|
|
</select>
|
|
</div>
|
|
<div style="flex: 1; min-width: 200px;">
|
|
<label for="statusFilter">상태</label>
|
|
<select id="statusFilter" class="form-control">
|
|
<option value="">전체</option>
|
|
<option value="match">일치</option>
|
|
<option value="mismatch">불일치</option>
|
|
<option value="missing-attendance">출퇴근 누락</option>
|
|
<option value="missing-report">보고서 누락</option>
|
|
</select>
|
|
</div>
|
|
<div style="align-self: flex-end;">
|
|
<button class="btn btn-primary" onclick="loadComparisonData()">
|
|
<span>🔍 조회</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 요약 통계 -->
|
|
<div class="content-section">
|
|
<div class="summary-stats" id="summaryStats">
|
|
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 대조 결과 -->
|
|
<div class="content-section">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">대조 결과</h2>
|
|
<p class="text-muted">출퇴근 기록과 작업보고서의 시간을 비교합니다</p>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="comparisonList" class="data-table-container">
|
|
<!-- 대조 결과가 여기에 동적으로 렌더링됩니다 -->
|
|
</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 comparisonData = [];
|
|
|
|
// 페이지 로드 시 초기화
|
|
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 today = new Date();
|
|
const weekAgo = new Date(today);
|
|
weekAgo.setDate(today.getDate() - 7);
|
|
|
|
document.getElementById('startDate').value = weekAgo.toISOString().split('T')[0];
|
|
document.getElementById('endDate').value = today.toISOString().split('T')[0];
|
|
|
|
try {
|
|
await loadWorkers();
|
|
await loadComparisonData();
|
|
} 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('workerFilter');
|
|
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 loadComparisonData() {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
const workerId = document.getElementById('workerFilter').value;
|
|
|
|
if (!startDate || !endDate) {
|
|
alert('시작일과 종료일을 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 출퇴근 기록 로드
|
|
const attendanceResponse = await axios.get('/attendance/records', {
|
|
params: {
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
worker_id: workerId || undefined
|
|
}
|
|
});
|
|
|
|
// 작업 보고서 로드
|
|
const reportsResponse = await axios.get('/daily-work-reports', {
|
|
params: {
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
worker_id: workerId || undefined
|
|
}
|
|
});
|
|
|
|
const attendanceRecords = attendanceResponse.data.success ? attendanceResponse.data.data : [];
|
|
const workReports = reportsResponse.data.success ? reportsResponse.data.data : [];
|
|
|
|
// 데이터 비교
|
|
comparisonData = compareData(attendanceRecords, workReports);
|
|
|
|
// 필터 적용
|
|
const statusFilter = document.getElementById('statusFilter').value;
|
|
if (statusFilter) {
|
|
comparisonData = comparisonData.filter(item => item.status === statusFilter);
|
|
}
|
|
|
|
renderSummary();
|
|
renderComparisonList();
|
|
} catch (error) {
|
|
console.error('데이터 로드 오류:', error);
|
|
document.getElementById('comparisonList').innerHTML = `
|
|
<div class="empty-state">
|
|
<p>데이터를 불러오는 중 오류가 발생했습니다.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function compareData(attendanceRecords, workReports) {
|
|
const results = [];
|
|
const dateWorkerMap = new Map();
|
|
|
|
// 출퇴근 기록 맵핑
|
|
attendanceRecords.forEach(record => {
|
|
const key = `${record.attendance_date}_${record.worker_id}`;
|
|
dateWorkerMap.set(key, {
|
|
date: record.attendance_date,
|
|
worker_id: record.worker_id,
|
|
worker_name: record.worker_name,
|
|
attendance: record,
|
|
reports: []
|
|
});
|
|
});
|
|
|
|
// 작업 보고서 맵핑
|
|
workReports.forEach(report => {
|
|
const key = `${report.report_date}_${report.worker_id}`;
|
|
if (dateWorkerMap.has(key)) {
|
|
dateWorkerMap.get(key).reports.push(report);
|
|
} else {
|
|
dateWorkerMap.set(key, {
|
|
date: report.report_date,
|
|
worker_id: report.worker_id,
|
|
worker_name: report.worker_name,
|
|
attendance: null,
|
|
reports: [report]
|
|
});
|
|
}
|
|
});
|
|
|
|
// 비교 분석
|
|
dateWorkerMap.forEach(item => {
|
|
const attendanceHours = item.attendance?.total_hours || 0;
|
|
const reportTotalHours = item.reports.reduce((sum, r) => sum + (r.total_hours || 0), 0);
|
|
|
|
let status = 'match';
|
|
let message = '일치';
|
|
|
|
if (!item.attendance && item.reports.length > 0) {
|
|
status = 'missing-attendance';
|
|
message = '출퇴근 기록 누락';
|
|
} else if (item.attendance && item.reports.length === 0) {
|
|
status = 'missing-report';
|
|
message = '작업보고서 누락';
|
|
} else if (Math.abs(attendanceHours - reportTotalHours) > 0.5) {
|
|
status = 'mismatch';
|
|
message = `시간 불일치 (차이: ${Math.abs(attendanceHours - reportTotalHours).toFixed(1)}시간)`;
|
|
}
|
|
|
|
results.push({
|
|
...item,
|
|
attendanceHours,
|
|
reportTotalHours,
|
|
status,
|
|
message
|
|
});
|
|
});
|
|
|
|
// 날짜 역순 정렬
|
|
return results.sort((a, b) => b.date.localeCompare(a.date));
|
|
}
|
|
|
|
function renderSummary() {
|
|
const summaryStats = document.getElementById('summaryStats');
|
|
|
|
const total = comparisonData.length;
|
|
const matches = comparisonData.filter(item => item.status === 'match').length;
|
|
const mismatches = comparisonData.filter(item => item.status === 'mismatch').length;
|
|
const missingAttendance = comparisonData.filter(item => item.status === 'missing-attendance').length;
|
|
const missingReport = comparisonData.filter(item => item.status === 'missing-report').length;
|
|
|
|
summaryStats.innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-label">전체</div>
|
|
<div class="stat-value">${total}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">일치</div>
|
|
<div class="stat-value" style="color: #059669;">${matches}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">불일치</div>
|
|
<div class="stat-value" style="color: #dc2626;">${mismatches}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">출퇴근 누락</div>
|
|
<div class="stat-value" style="color: #d97706;">${missingAttendance}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">보고서 누락</div>
|
|
<div class="stat-value" style="color: #2563eb;">${missingReport}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderComparisonList() {
|
|
const container = document.getElementById('comparisonList');
|
|
|
|
if (comparisonData.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<p>비교 결과가 없습니다.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const tableHTML = `
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>날짜</th>
|
|
<th>작업자</th>
|
|
<th>출퇴근 시간</th>
|
|
<th>보고서 시간</th>
|
|
<th>차이</th>
|
|
<th>상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${comparisonData.map(item => {
|
|
const diff = Math.abs(item.attendanceHours - item.reportTotalHours);
|
|
const badgeClass = `badge-${item.status}`;
|
|
|
|
return `
|
|
<tr>
|
|
<td>${item.date}</td>
|
|
<td><strong>${item.worker_name}</strong></td>
|
|
<td>${item.attendanceHours.toFixed(1)}시간</td>
|
|
<td>${item.reportTotalHours.toFixed(1)}시간</td>
|
|
<td>${diff.toFixed(1)}시간</td>
|
|
<td>
|
|
<span class="discrepancy-badge ${badgeClass}">
|
|
${item.message}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
container.innerHTML = tableHTML;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|