Files
tk-factory-services/system1-factory/web/pages/attendance/my-vacation-info.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

555 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>내 연차 정보 | 테크니컬코리아</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/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<style>
.page-wrapper {
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="has-sidebar">
<div id="navbar-container"></div>
<div id="sidebar-container"></div>
<main class="main-content">
<div class="page-wrapper">
<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>
</main>
<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 = 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() {
// 현재 사용자 정보 가져오기
const userStr = localStorage.getItem('sso_user');
if (userStr) {
try {
currentUser = JSON.parse(userStr);
} catch (e) {
console.error('사용자 정보 파싱 실패');
}
}
// 관리자 여부 확인
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 {
// 일반 사용자: 본인 worker_id 사용
if (currentUser?.worker_id) {
currentWorkerId = currentUser.worker_id;
document.getElementById('infoGrid').style.display = 'grid';
await loadAllData();
} else {
// worker_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.worker_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.worker_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}&worker_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>
</body>
</html>