- 공통 유틸리티 추출 (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>
555 lines
17 KiB
HTML
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>
|