Files
tk-factory-services/system1-factory/web/pages/admin/attendance-report.html
Hyungi Ahn 4388628788 refactor: TBM/작업보고 코드 통합 및 API 쿼리 버그 수정
- 공통 유틸리티 추출 (common/utils.js, common/base-state.js)
- TBM 모바일 인라인 JS/CSS 외부 파일로 분리 (tbm-mobile.js, tbm-mobile.css)
- 미사용 코드 삭제 (index.js, work-report-*.js 등 5개 파일)
- TBM/작업보고 state.js, utils.js를 공통 모듈 기반으로 전환
- 작업보고서 SSO 인증 호환 수정 (token/user 함수)
- tbmModel.js: incomplete-reports 쿼리에서 users→sso_users 조인 수정, leader_name 조인 추가
- docker-compose.yml: system1-web 볼륨 마운트 추가
- 모바일 인계(handover) 기능 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:51:24 +09:00

487 lines
15 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=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></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">출퇴근-작업보고서 대조</h1>
<p class="page-description">출퇴근 기록과 작업보고서를 비교 분석합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="loadComparisonData()">새로고침</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()">조회</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">
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('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_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?limit=100');
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>