카카오톡 인앱 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>
576 lines
19 KiB
HTML
576 lines
19 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">
|
|
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026033108">
|
|
<style>
|
|
.page-wrapper {
|
|
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
|
padding: 1.5rem;
|
|
max-width: 1000px;
|
|
}
|
|
.page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.page-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
|
.page-desc { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
|
|
|
|
/* 작업자 선택 (관리자용) */
|
|
.admin-controls {
|
|
display: none;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
padding: 0.75rem 1rem;
|
|
background: #fef3c7;
|
|
border: 1px solid #f59e0b;
|
|
border-radius: 0.5rem;
|
|
}
|
|
.admin-controls.visible { display: flex; }
|
|
.admin-controls label { font-weight: 500; color: #92400e; }
|
|
.admin-controls select {
|
|
padding: 0.4rem 0.5rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875rem;
|
|
min-width: 150px;
|
|
}
|
|
|
|
/* 카드 그리드 */
|
|
.info-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
/* 연차 카드 */
|
|
.vacation-card {
|
|
background: white;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 0.75rem;
|
|
padding: 1.25rem;
|
|
}
|
|
.vacation-card h3 {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
margin: 0 0 1rem 0;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 2px solid #3b82f6;
|
|
color: #1e40af;
|
|
}
|
|
.vacation-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid #f3f4f6;
|
|
}
|
|
.vacation-item:last-child { border-bottom: none; }
|
|
.vacation-item .label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: #374151;
|
|
font-size: 0.875rem;
|
|
}
|
|
.vacation-item .dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 2px;
|
|
}
|
|
.dot-carryover { background: #fbbf24; }
|
|
.dot-annual { background: #3b82f6; }
|
|
.dot-longservice { background: #a855f7; }
|
|
.dot-special { background: #ec4899; }
|
|
.vacation-item .days {
|
|
font-weight: 700;
|
|
font-size: 1rem;
|
|
}
|
|
.days.positive { color: #059669; }
|
|
.days.zero { color: #9ca3af; }
|
|
.days.negative { color: #dc2626; }
|
|
|
|
/* 총 합계 */
|
|
.vacation-total {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-top: 0.75rem;
|
|
padding-top: 0.75rem;
|
|
border-top: 2px solid #e5e7eb;
|
|
font-weight: 600;
|
|
}
|
|
.vacation-total .label { font-size: 0.9rem; color: #111827; }
|
|
.vacation-total .days { font-size: 1.25rem; }
|
|
|
|
/* 연장근로 카드 */
|
|
.overtime-card {
|
|
background: white;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 0.75rem;
|
|
padding: 1.25rem;
|
|
}
|
|
.overtime-card h3 {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
margin: 0 0 1rem 0;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 2px solid #f97316;
|
|
color: #c2410c;
|
|
}
|
|
.overtime-controls {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.overtime-controls select {
|
|
padding: 0.4rem 0.5rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.overtime-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.overtime-stat {
|
|
text-align: center;
|
|
padding: 1rem;
|
|
background: #fff7ed;
|
|
border-radius: 0.5rem;
|
|
}
|
|
.overtime-stat .value {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: #ea580c;
|
|
}
|
|
.overtime-stat .label {
|
|
font-size: 0.75rem;
|
|
color: #9a3412;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
/* 월별 상세 */
|
|
.overtime-detail {
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
.overtime-day {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.4rem 0;
|
|
border-bottom: 1px solid #f3f4f6;
|
|
font-size: 0.8rem;
|
|
}
|
|
.overtime-day:last-child { border-bottom: none; }
|
|
.overtime-day .date { color: #6b7280; }
|
|
.overtime-day .hours { font-weight: 600; color: #ea580c; }
|
|
|
|
/* 로딩/에러 */
|
|
.loading, .error, .no-data {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: #6b7280;
|
|
}
|
|
.error { color: #dc2626; }
|
|
|
|
/* 안내 메시지 */
|
|
.info-message {
|
|
padding: 1rem;
|
|
background: #eff6ff;
|
|
border: 1px solid #bfdbfe;
|
|
border-radius: 0.5rem;
|
|
color: #1e40af;
|
|
font-size: 0.875rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
</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>
|
|
<h1 class="page-title">내 연차 정보</h1>
|
|
<p class="page-desc" id="workerNameDisplay">연차 잔여 현황 및 월간 연장근로 시간을 확인합니다</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 관리자용 작업자 선택 -->
|
|
<div class="admin-controls" id="adminControls">
|
|
<label>작업자 선택:</label>
|
|
<select id="workerSelect" onchange="onWorkerChange()">
|
|
<option value="">-- 선택 --</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- 작업자 미연결 안내 -->
|
|
<div class="info-message" id="noWorkerMessage" style="display:none;">
|
|
현재 계정에 연결된 작업자 정보가 없습니다. 관리자에게 문의하세요.
|
|
</div>
|
|
|
|
<!-- 정보 그리드 -->
|
|
<div class="info-grid" id="infoGrid" style="display:none;">
|
|
<!-- 연차 잔여 현황 -->
|
|
<div class="vacation-card">
|
|
<h3 id="vacationCardTitle">연차 잔여 현황</h3>
|
|
<div id="vacationList">
|
|
<div class="loading">로딩 중...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 월간 연장근로 -->
|
|
<div class="overtime-card">
|
|
<h3>월간 연장근로 현황</h3>
|
|
<div class="overtime-controls">
|
|
<select id="yearSelect" onchange="loadOvertimeData()"></select>
|
|
<select id="monthSelect" onchange="loadOvertimeData()"></select>
|
|
</div>
|
|
<div id="overtimeContent">
|
|
<div class="loading">로딩 중...</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>
|
|
// axios 설정
|
|
(function() {
|
|
const check = setInterval(() => {
|
|
if (window.API_BASE_URL) {
|
|
clearInterval(check);
|
|
axios.defaults.baseURL = window.API_BASE_URL;
|
|
const token = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token'));
|
|
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
}, 50);
|
|
})();
|
|
|
|
// 전역 변수
|
|
let currentUser = null;
|
|
let currentWorkerId = null;
|
|
let isAdmin = false;
|
|
let workers = [];
|
|
|
|
// 초기화
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await waitForAxios();
|
|
await initPage();
|
|
});
|
|
|
|
function waitForAxios() {
|
|
return new Promise(resolve => {
|
|
const check = setInterval(() => {
|
|
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
|
|
}, 50);
|
|
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
|
});
|
|
}
|
|
|
|
async function initPage() {
|
|
// 현재 사용자 정보 가져오기 (쿠키 우선, localStorage 폴백)
|
|
currentUser = window.getSSOUser ? window.getSSOUser() : null;
|
|
if (!currentUser) {
|
|
const userStr = localStorage.getItem('sso_user');
|
|
if (userStr) { try { currentUser = JSON.parse(userStr); } catch(e) {} }
|
|
}
|
|
|
|
// 관리자 여부 확인
|
|
const userRole = (currentUser?.role || currentUser?.access_level || '').toLowerCase();
|
|
isAdmin = ['admin', 'system admin', 'system', 'system_admin'].includes(userRole);
|
|
|
|
// 연도/월 선택기 초기화
|
|
initDateSelectors();
|
|
|
|
if (isAdmin) {
|
|
// 관리자: 작업자 선택 UI 표시
|
|
document.getElementById('adminControls').classList.add('visible');
|
|
await loadWorkers();
|
|
} else {
|
|
// 일반 사용자: 본인 user_id 사용
|
|
if (currentUser?.user_id) {
|
|
currentWorkerId = currentUser.user_id;
|
|
document.getElementById('infoGrid').style.display = 'grid';
|
|
await loadAllData();
|
|
} else {
|
|
// user_id가 없는 경우
|
|
document.getElementById('noWorkerMessage').style.display = 'block';
|
|
}
|
|
}
|
|
}
|
|
|
|
function initDateSelectors() {
|
|
const now = new Date();
|
|
const yearSelect = document.getElementById('yearSelect');
|
|
const monthSelect = document.getElementById('monthSelect');
|
|
|
|
// 연도 (올해 ± 1년)
|
|
for (let y = now.getFullYear() - 1; y <= now.getFullYear() + 1; y++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = y;
|
|
opt.textContent = `${y}년`;
|
|
if (y === now.getFullYear()) opt.selected = true;
|
|
yearSelect.appendChild(opt);
|
|
}
|
|
|
|
// 월
|
|
for (let m = 1; m <= 12; m++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = m;
|
|
opt.textContent = `${m}월`;
|
|
if (m === now.getMonth() + 1) opt.selected = true;
|
|
monthSelect.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
async function loadWorkers() {
|
|
try {
|
|
const res = await axios.get('/workers?limit=100');
|
|
workers = (res.data.data || [])
|
|
.filter(w => w.status === 'active' && w.employment_status === 'employed')
|
|
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
|
|
|
|
const select = document.getElementById('workerSelect');
|
|
workers.forEach(w => {
|
|
const opt = document.createElement('option');
|
|
opt.value = w.user_id;
|
|
opt.textContent = w.worker_name;
|
|
select.appendChild(opt);
|
|
});
|
|
} catch (e) {
|
|
console.error('작업자 목록 로드 실패:', e);
|
|
}
|
|
}
|
|
|
|
async function onWorkerChange() {
|
|
const workerId = document.getElementById('workerSelect').value;
|
|
if (!workerId) {
|
|
document.getElementById('infoGrid').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
currentWorkerId = parseInt(workerId);
|
|
const worker = workers.find(w => w.user_id === currentWorkerId);
|
|
document.getElementById('workerNameDisplay').textContent =
|
|
`${worker?.worker_name || ''}님의 연차 잔여 현황 및 월간 연장근로 시간`;
|
|
document.getElementById('infoGrid').style.display = 'grid';
|
|
await loadAllData();
|
|
}
|
|
|
|
async function loadAllData() {
|
|
await Promise.all([
|
|
loadVacationData(),
|
|
loadOvertimeData()
|
|
]);
|
|
}
|
|
|
|
// ===== 연차 잔여 현황 =====
|
|
async function loadVacationData() {
|
|
const container = document.getElementById('vacationList');
|
|
container.innerHTML = '<div class="loading">로딩 중...</div>';
|
|
|
|
const year = new Date().getFullYear();
|
|
document.getElementById('vacationCardTitle').textContent = `연차 잔여 현황 (${year}년)`;
|
|
|
|
try {
|
|
const res = await axios.get(`/vacation-balances/worker/${currentWorkerId}/year/${year}`);
|
|
const balances = res.data.data || [];
|
|
|
|
if (balances.length === 0) {
|
|
container.innerHTML = '<div class="no-data">등록된 연차 정보가 없습니다</div>';
|
|
return;
|
|
}
|
|
|
|
// 유형별 정리
|
|
const typeOrder = ['CARRYOVER', 'ANNUAL', 'LONG_SERVICE'];
|
|
const typeNames = {
|
|
'CARRYOVER': '이월',
|
|
'ANNUAL': '정기연차',
|
|
'LONG_SERVICE': '장기근속'
|
|
};
|
|
const dotClasses = {
|
|
'CARRYOVER': 'dot-carryover',
|
|
'ANNUAL': 'dot-annual',
|
|
'LONG_SERVICE': 'dot-longservice'
|
|
};
|
|
|
|
let totalDays = 0;
|
|
let usedDays = 0;
|
|
let html = '';
|
|
|
|
// 정렬된 순서로 표시
|
|
const sortedBalances = balances.sort((a, b) => {
|
|
const aIdx = typeOrder.indexOf(a.type_code);
|
|
const bIdx = typeOrder.indexOf(b.type_code);
|
|
if (aIdx === -1 && bIdx === -1) return 0;
|
|
if (aIdx === -1) return 1;
|
|
if (bIdx === -1) return -1;
|
|
return aIdx - bIdx;
|
|
});
|
|
|
|
sortedBalances.forEach(b => {
|
|
const total = parseFloat(b.total_days) || 0;
|
|
const used = parseFloat(b.used_days) || 0;
|
|
const remaining = total - used;
|
|
totalDays += total;
|
|
usedDays += used;
|
|
|
|
const dotClass = dotClasses[b.type_code] || 'dot-special';
|
|
const typeName = typeNames[b.type_code] || b.type_name || b.type_code;
|
|
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
|
|
|
|
html += `
|
|
<div class="vacation-item">
|
|
<span class="label">
|
|
<span class="dot ${dotClass}"></span>
|
|
${typeName}
|
|
</span>
|
|
<span class="days ${remainingClass}">
|
|
${remaining.toFixed(1)}일
|
|
<small style="color:#9ca3af;font-weight:normal;">(${total.toFixed(1)} - ${used.toFixed(1)})</small>
|
|
</span>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
// 총 합계
|
|
const totalRemaining = totalDays - usedDays;
|
|
const totalClass = totalRemaining > 0 ? 'positive' : totalRemaining < 0 ? 'negative' : 'zero';
|
|
|
|
html += `
|
|
<div class="vacation-total">
|
|
<span class="label">총 잔여</span>
|
|
<span class="days ${totalClass}">${totalRemaining.toFixed(1)}일</span>
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
|
|
} catch (e) {
|
|
console.error('연차 데이터 로드 실패:', e);
|
|
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
|
|
}
|
|
}
|
|
|
|
// ===== 월간 연장근로 =====
|
|
async function loadOvertimeData() {
|
|
const container = document.getElementById('overtimeContent');
|
|
container.innerHTML = '<div class="loading">로딩 중...</div>';
|
|
|
|
const year = parseInt(document.getElementById('yearSelect').value);
|
|
const month = parseInt(document.getElementById('monthSelect').value);
|
|
|
|
// 해당 월의 시작일/종료일
|
|
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
|
const lastDay = new Date(year, month, 0).getDate();
|
|
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
|
|
|
|
try {
|
|
// 근태 기록에서 연장근로 데이터 조회
|
|
const res = await axios.get(`/attendance/records?start_date=${startDate}&end_date=${endDate}&user_id=${currentWorkerId}`);
|
|
const records = res.data.data || [];
|
|
|
|
// 8시간 초과분 계산
|
|
let totalOvertimeHours = 0;
|
|
const overtimeDays = [];
|
|
|
|
records.forEach(r => {
|
|
const hours = parseFloat(r.total_work_hours) || 0;
|
|
if (hours > 8) {
|
|
const overtime = hours - 8;
|
|
totalOvertimeHours += overtime;
|
|
overtimeDays.push({
|
|
date: r.record_date,
|
|
hours: overtime
|
|
});
|
|
}
|
|
});
|
|
|
|
// 총 근무일수
|
|
const workDays = records.filter(r => (parseFloat(r.total_work_hours) || 0) > 0).length;
|
|
|
|
// 렌더링
|
|
let html = `
|
|
<div class="overtime-summary">
|
|
<div class="overtime-stat">
|
|
<div class="value">${totalOvertimeHours.toFixed(1)}h</div>
|
|
<div class="label">총 연장근로</div>
|
|
</div>
|
|
<div class="overtime-stat">
|
|
<div class="value">${overtimeDays.length}일</div>
|
|
<div class="label">연장근로 일수</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (overtimeDays.length > 0) {
|
|
html += '<div class="overtime-detail">';
|
|
overtimeDays.sort((a, b) => a.date.localeCompare(b.date)).forEach(d => {
|
|
const dateObj = new Date(d.date);
|
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
|
const dayName = dayNames[dateObj.getDay()];
|
|
const displayDate = `${dateObj.getMonth() + 1}/${dateObj.getDate()} (${dayName})`;
|
|
|
|
html += `
|
|
<div class="overtime-day">
|
|
<span class="date">${displayDate}</span>
|
|
<span class="hours">+${d.hours.toFixed(1)}h</span>
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
} else {
|
|
html += '<div class="no-data" style="padding:1rem;">연장근로 기록이 없습니다</div>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
} catch (e) {
|
|
console.error('연장근로 데이터 로드 실패:', e);
|
|
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
|
|
}
|
|
}
|
|
</script>
|
|
<script>initAuth();</script>
|
|
<script src="/static/js/shared-bottom-nav.js?v=2026040103"></script>
|
|
</body>
|
|
</html>
|