Files
tk-factory-services/system1-factory/web/pages/admin/attendance-report.html
Hyungi Ahn 0de9d5bb48 feat(sso): 인앱 브라우저 SSO 토큰 릴레이 — 카톡 WebView 쿠키 미공유 해결
카카오톡 인앱 WebView는 서브도메인 간 쿠키를 공유하지 않아
tkds에서 로그인 후 tkfb로 리다이렉트 시 인증이 풀리는 문제.

- sso-relay.js: URL hash의 _sso= 토큰을 로컬 쿠키+localStorage로 설정
- gateway dashboard: 로그인 후 redirect URL에 #_sso=<token> 추가
- 전 서비스 HTML: core JS 직전에 sso-relay.js 로드 (81개 파일)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:44:02 +09:00

504 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>출퇴근-작업보고서 대조 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<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 class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<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>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
import '/js/api-config.js?v=2026031401
</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.user_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,
user_id: workerId || undefined
}
});
// 작업 보고서 로드
const reportsResponse = await axios.get('/daily-work-reports', {
params: {
start_date: startDate,
end_date: endDate,
user_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.user_id}`;
dateWorkerMap.set(key, {
date: record.attendance_date,
user_id: record.user_id,
worker_name: record.worker_name,
attendance: record,
reports: []
});
});
// 작업 보고서 맵핑
workReports.forEach(report => {
const key = `${report.report_date}_${report.user_id}`;
if (dateWorkerMap.has(key)) {
dateWorkerMap.get(key).reports.push(report);
} else {
dateWorkerMap.set(key, {
date: report.report_date,
user_id: report.user_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>
<script>initAuth();</script>
</body>
</html>