feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현

- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성
  - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동
  - common/ → attendance/: 근태/휴가 관련 페이지 이동
  - admin/ 정리: safety-* 파일들을 safety/로 이동

- 사이드바 네비게이션 메뉴 구현
  - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리
  - 접기/펼치기 기능 및 상태 저장
  - 관리자 전용 메뉴 자동 표시/숨김

- 날씨 API 연동 (기상청 단기예보)
  - TBM 및 navbar에 현재 날씨 표시
  - weatherService.js 추가

- 안전 체크리스트 확장
  - 기본/날씨별/작업별 체크 유형 추가
  - checklist-manage.html 페이지 추가

- 이슈 신고 시스템 구현
  - workIssueController, workIssueModel, workIssueRoutes 추가

- DB 마이그레이션 파일 추가 (실행 대기)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-02 14:27:22 +09:00
parent b6485e3140
commit 74d3a78aa3
116 changed files with 23117 additions and 294 deletions

View File

@@ -0,0 +1,143 @@
<!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/annual-vacation-overview.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 스크립트 -->
<script type="module" src="/js/api-config.js"></script>
<script type="module" src="/js/auth-check.js" defer></script>
<script type="module" src="/js/load-navbar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
</head>
<body>
<!-- 메인 컨테이너 -->
<div class="page-container">
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="content-wrapper">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1 class="page-title">📊 연간 연차 현황</h1>
<p class="page-description">모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
</div>
<!-- 필터 섹션 -->
<section class="filter-section">
<div class="card">
<div class="card-body">
<div class="filter-controls">
<div class="form-group">
<label for="yearSelect">조회 연도</label>
<select id="yearSelect" class="form-select">
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<button id="refreshBtn" class="btn btn-primary">
조회
</button>
</div>
</div>
</div>
</section>
<!-- 탭 네비게이션 -->
<section class="tabs-section">
<div class="tabs-nav">
<button class="tab-btn active" data-tab="annualUsage">연간 사용 기록</button>
<button class="tab-btn" data-tab="monthlyDetails">월별 상세 기록</button>
</div>
</section>
<!-- 탭 1: 연간 사용 기록 -->
<section id="annualUsageTab" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 휴가 사용 현황</h2>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="annualUsageChart"></canvas>
</div>
</div>
</div>
</section>
<!-- 탭 2: 월별 상세 기록 -->
<section id="monthlyDetailsTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 상세 기록</h2>
<div class="month-controls">
<select id="monthSelect" class="form-select">
<option value="1">1월</option>
<option value="2">2월</option>
<option value="3">3월</option>
<option value="4">4월</option>
<option value="5">5월</option>
<option value="6">6월</option>
<option value="7">7월</option>
<option value="8">8월</option>
<option value="9">9월</option>
<option value="10">10월</option>
<option value="11">11월</option>
<option value="12">12월</option>
</select>
<button id="exportExcelBtn" class="btn btn-sm btn-secondary">
📥 엑셀 다운로드
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자명</th>
<th>휴가 유형</th>
<th>시작일</th>
<th>종료일</th>
<th>사용 일수</th>
<th>사유</th>
<th>상태</th>
</tr>
</thead>
<tbody id="monthlyTableBody">
<tr>
<td colspan="7" class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
</body>
</html>

View File

@@ -0,0 +1,395 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</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">
<span class="title-icon">📅</span>
일일 출퇴근 입력
</h1>
<p class="page-description">오늘 출근한 작업자들의 출퇴근 기록을 입력합니다</p>
</div>
<div class="page-actions">
<input type="date" id="selectedDate" class="form-control" style="width: auto;">
<button class="btn btn-primary" onclick="loadAttendanceRecords()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<!-- 출퇴근 입력 폼 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">작업자 출퇴근 기록</h2>
<p class="text-muted">근태 구분을 선택하면 근무시간이 자동 입력됩니다. 연장근로는 별도로 입력 가능합니다. (조퇴: 2시간, 반반차: 6시간, 반차: 4시간, 정시: 8시간, 주말근무: 0시간)</p>
</div>
<div class="card-body">
<div id="attendanceList" class="data-table-container">
<!-- 출퇴근 기록 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
<div style="text-align: center; margin-top: 2rem;">
<button class="btn btn-primary" onclick="saveAllAttendance()" style="padding: 1rem 3rem; font-size: 1.1rem;">
<span>💾 전체 저장</span>
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 attendanceRecords = [];
// 근태 구분 옵션 (근무시간 자동 설정, 연장근로는 별도 입력)
const attendanceTypes = [
{ value: 'on_time', label: '정시', hours: 8 },
{ value: 'half_leave', label: '반차', hours: 4 },
{ value: 'quarter_leave', label: '반반차', hours: 6 },
{ value: 'early_leave', label: '조퇴', hours: 2 },
{ value: 'weekend_work', label: '주말근무', hours: 0 },
{ value: 'annual_leave', label: '연차', hours: 0 },
{ value: 'custom', label: '특이사항', hours: 0 }
];
// 페이지 로드 시 초기화
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().toISOString().split('T')[0];
document.getElementById('selectedDate').value = today;
try {
await loadWorkers();
await loadAttendanceRecords();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadAttendanceRecords() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
alert('날짜를 선택해주세요.');
return;
}
try {
// 출퇴근 기록과 체크인 목록(휴가 정보 포함)을 동시에 가져오기
const [recordsResponse, checkinResponse] = await Promise.all([
axios.get(`/attendance/records?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } })),
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } }))
]);
const existingRecords = recordsResponse.data.success ? recordsResponse.data.data : [];
const checkinList = checkinResponse.data.success ? checkinResponse.data.data : [];
// 체크인 목록을 기준으로 출퇴근 기록 생성 (연차 정보 포함)
attendanceRecords = checkinList.map(worker => {
const existingRecord = existingRecords.find(r => r.worker_id === worker.worker_id);
const isOnVacation = worker.vacation_status === 'approved';
// 기존 기록이 있으면 사용, 없으면 초기화
if (existingRecord) {
return existingRecord;
} else {
return {
worker_id: worker.worker_id,
worker_name: worker.worker_name,
attendance_date: selectedDate,
total_hours: isOnVacation ? 0 : 8,
overtime_hours: 0,
attendance_type: isOnVacation ? 'annual_leave' : 'on_time',
is_custom: false,
is_new: true,
is_on_vacation: isOnVacation,
vacation_type_name: worker.vacation_type_name
};
}
});
renderAttendanceList();
} catch (error) {
console.error('출퇴근 기록 로드 오류:', error);
alert('출퇴근 기록 조회 중 오류가 발생했습니다.');
}
}
function initializeAttendanceRecords() {
const selectedDate = document.getElementById('selectedDate').value;
attendanceRecords = workers.map(worker => ({
worker_id: worker.worker_id,
worker_name: worker.worker_name,
attendance_date: selectedDate,
total_hours: 8,
overtime_hours: 0,
attendance_type: 'on_time',
is_custom: false,
is_new: true
}));
renderAttendanceList();
}
function renderAttendanceList() {
const container = document.getElementById('attendanceList');
if (attendanceRecords.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>등록된 작업자가 없거나 출퇴근 기록이 없습니다.</p>
<button class="btn btn-primary" onclick="initializeAttendanceRecords()">
작업자 목록으로 초기화
</button>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table" style="font-size: 0.95rem;">
<thead style="background-color: #f8f9fa;">
<tr>
<th style="width: 120px;">작업자</th>
<th style="width: 180px;">근태 구분</th>
<th style="width: 100px;">근무시간</th>
<th style="width: 120px;">연장근로</th>
<th style="width: 100px;">특이사항</th>
</tr>
</thead>
<tbody>
${attendanceRecords.map((record, index) => {
const isCustom = record.is_custom || record.attendance_type === 'custom';
const isHoursReadonly = !isCustom; // 특이사항이 아니면 근무시간은 읽기 전용
const isOnVacation = record.is_on_vacation || false;
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
return `
<tr style="border-bottom: 1px solid #e5e7eb; ${isOnVacation ? 'background-color: #f0f9ff;' : ''}">
<td style="padding: 0.75rem; font-weight: 600;">
${record.worker_name}
${isOnVacation ? `<span style="margin-left: 0.5rem; display: inline-block; padding: 0.125rem 0.5rem; background-color: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
</td>
<td style="padding: 0.75rem;">
<select class="form-control"
onchange="updateAttendanceType(${index}, this.value)"
style="width: 160px; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;">
${attendanceTypes.map(type => `
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>
${type.label}
</option>
`).join('')}
</select>
</td>
<td style="padding: 0.75rem;">
<input type="number" class="form-control"
id="hours_${index}"
value="${record.total_hours || 0}"
min="0" max="24" step="0.5"
${isHoursReadonly ? 'readonly' : ''}
onchange="updateTotalHours(${index}, this.value)"
style="width: 90px; padding: 0.5rem; border: 1px solid ${isHoursReadonly ? '#e5e7eb' : '#d1d5db'};
border-radius: 0.375rem; background-color: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
</td>
<td style="padding: 0.75rem;">
<input type="number" class="form-control"
id="overtime_${index}"
value="${record.overtime_hours || 0}"
min="0" max="12" step="0.5"
onchange="updateOvertimeHours(${index}, this.value)"
style="width: 90px; padding: 0.5rem; border: 1px solid #d1d5db;
border-radius: 0.375rem; background-color: white; text-align: center;">
</td>
<td style="padding: 0.75rem; text-align: center;">
${isCustom ?
'<span style="color: #dc2626; font-weight: 600;">✓</span>' :
'<span style="color: #9ca3af;">-</span>'}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
function updateTotalHours(index, value) {
attendanceRecords[index].total_hours = parseFloat(value) || 0;
}
function updateOvertimeHours(index, value) {
attendanceRecords[index].overtime_hours = parseFloat(value) || 0;
}
function updateAttendanceType(index, value) {
const record = attendanceRecords[index];
record.attendance_type = value;
// 근태 구분에 따라 자동으로 근무시간 설정
const attendanceType = attendanceTypes.find(t => t.value === value);
if (value === 'custom') {
// 특이사항 선택 시 수동 입력 가능
record.is_custom = true;
// 기존 값 유지, 수동 입력 가능
} else if (attendanceType) {
// 다른 근태 구분 선택 시 근무시간만 자동 설정
record.is_custom = false;
record.total_hours = attendanceType.hours;
// 연장근로는 유지 (별도 입력 가능)
}
// UI 다시 렌더링
renderAttendanceList();
}
async function saveAllAttendance() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
alert('날짜를 선택해주세요.');
return;
}
if (attendanceRecords.length === 0) {
alert('저장할 출퇴근 기록이 없습니다.');
return;
}
// 모든 기록을 API 형식에 맞게 변환
const recordsToSave = attendanceRecords.map(record => ({
worker_id: record.worker_id,
attendance_date: selectedDate,
total_hours: record.total_hours || 0,
overtime_hours: record.overtime_hours || 0,
attendance_type: record.attendance_type || 'on_time',
is_custom: record.is_custom || false
}));
try {
// 각 기록을 순차적으로 저장
let successCount = 0;
let errorCount = 0;
for (const data of recordsToSave) {
try {
const response = await axios.post('/attendance/records', data);
if (response.data.success) {
successCount++;
}
} catch (error) {
console.error(`작업자 ${data.worker_id} 저장 오류:`, error);
errorCount++;
}
}
if (errorCount === 0) {
alert(`${successCount}건의 출퇴근 기록이 모두 저장되었습니다.`);
await loadAttendanceRecords(); // 저장 후 새로고침
} else {
alert(`${successCount}건 저장 완료, ${errorCount}건 저장 실패\n자세한 내용은 콘솔을 확인해주세요.`);
}
} catch (error) {
console.error('전체 저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,490 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
<style>
.calendar-container {
margin-top: 2rem;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: #e5e7eb;
border: 1px solid #e5e7eb;
}
.calendar-header {
background-color: #f3f4f6;
padding: 1rem;
text-align: center;
font-weight: 600;
color: #374151;
}
.calendar-day {
background-color: white;
padding: 0.5rem;
min-height: 100px;
position: relative;
}
.calendar-day.today {
background-color: #fef3c7;
}
.calendar-day.other-month {
background-color: #f9fafb;
color: #9ca3af;
}
.day-number {
font-weight: 600;
margin-bottom: 0.5rem;
}
.day-info {
font-size: 0.75rem;
margin-top: 0.25rem;
}
.attendance-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-normal {
background-color: #d1fae5;
color: #065f46;
}
.badge-overtime {
background-color: #fef3c7;
color: #92400e;
}
.badge-annual {
background-color: #dbeafe;
color: #1e40af;
}
.badge-half {
background-color: #e0e7ff;
color: #3730a3;
}
.badge-quarter {
background-color: #f3e8ff;
color: #5b21b6;
}
.badge-early {
background-color: #fce7f3;
color: #9f1239;
}
.worker-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.summary-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-item {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.summary-label {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.summary-value {
font-size: 1.5rem;
font-weight: 600;
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">
<span class="title-icon">📆</span>
월별 출퇴근 현황
</h1>
<p class="page-description">이번 달 출퇴근 현황을 조회합니다</p>
</div>
<div class="page-actions">
<input type="month" id="selectedMonth" class="form-control" style="width: auto;">
<button class="btn btn-primary" onclick="loadMonthlyData()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<!-- 작업자 선택 -->
<div class="content-section">
<div class="card">
<div class="card-body">
<div class="worker-selector">
<label for="workerSelect" style="font-weight: 600;">작업자:</label>
<select id="workerSelect" class="form-control" style="width: 300px;" onchange="loadMonthlyData()">
<option value="">작업자를 선택하세요</option>
</select>
</div>
</div>
</div>
</div>
<!-- 월별 요약 통계 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 요약</h2>
</div>
<div class="card-body">
<div class="summary-card" id="summarySection">
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
<!-- 달력 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title" id="calendarTitle">출퇴근 달력</h2>
</div>
<div class="card-body">
<div class="calendar-container">
<div class="calendar-grid">
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div id="calendarDays">
<!-- 달력 날짜가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 attendanceRecords = [];
let currentUser = null;
// 페이지 로드 시 초기화
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 now = new Date();
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
document.getElementById('selectedMonth').value = yearMonth;
// 현재 사용자 정보 가져오기
try {
const userInfo = JSON.parse(localStorage.getItem('user'));
currentUser = userInfo;
await loadWorkers();
// 관리자가 아니면 자동으로 자신 선택
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
const workerSelect = document.getElementById('workerSelect');
if (currentUser.worker_id) {
workerSelect.value = currentUser.worker_id;
}
}
await loadMonthlyData();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
const select = document.getElementById('workerSelect');
select.innerHTML = '<option value="">작업자를 선택하세요</option>';
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 loadMonthlyData() {
const selectedMonth = document.getElementById('selectedMonth').value;
const workerId = document.getElementById('workerSelect').value;
if (!selectedMonth) {
alert('월을 선택해주세요.');
return;
}
if (!workerId) {
alert('작업자를 선택해주세요.');
return;
}
try {
// 선택한 월의 첫날과 마지막 날 계산
const [year, month] = selectedMonth.split('-');
const startDate = `${year}-${month}-01`;
const endDate = new Date(year, month, 0).getDate();
const endDateStr = `${year}-${month}-${String(endDate).padStart(2, '0')}`;
// 출퇴근 기록 로드
const response = await axios.get(`/attendance/records`, {
params: {
worker_id: workerId,
start_date: startDate,
end_date: endDateStr
}
});
if (response.data.success) {
attendanceRecords = response.data.data || [];
renderCalendar();
renderSummary();
}
} catch (error) {
console.error('월별 데이터 로드 오류:', error);
if (error.response?.status === 404) {
attendanceRecords = [];
renderCalendar();
renderSummary();
}
}
}
function renderCalendar() {
const selectedMonth = document.getElementById('selectedMonth').value;
const [year, month] = selectedMonth.split('-');
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
const today = new Date();
// 달력 제목 업데이트
document.getElementById('calendarTitle').textContent = `${year}${month}월 출퇴근 달력`;
// 달력 그리드 생성
const calendarDays = document.getElementById('calendarDays');
calendarDays.innerHTML = '';
// 이전 달의 빈 칸
for (let i = 0; i < firstDay.getDay(); i++) {
const emptyDay = document.createElement('div');
emptyDay.className = 'calendar-day other-month';
calendarDays.appendChild(emptyDay);
}
// 현재 달의 날짜
for (let day = 1; day <= lastDay.getDate(); day++) {
const dayElement = document.createElement('div');
const currentDate = new Date(year, month - 1, day);
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
dayElement.className = 'calendar-day';
// 오늘 날짜 표시
if (currentDate.toDateString() === today.toDateString()) {
dayElement.classList.add('today');
}
// 날짜 번호
const dayNumber = document.createElement('div');
dayNumber.className = 'day-number';
dayNumber.textContent = day;
dayElement.appendChild(dayNumber);
// 출퇴근 기록 찾기
const record = attendanceRecords.find(r => r.attendance_date === dateStr);
if (record) {
const infoDiv = document.createElement('div');
infoDiv.className = 'day-info';
// 근무시간
const hoursSpan = document.createElement('div');
hoursSpan.textContent = `${record.total_hours}시간`;
infoDiv.appendChild(hoursSpan);
// 야근 표시
if (record.is_overtime) {
const overtimeBadge = document.createElement('span');
overtimeBadge.className = 'attendance-badge badge-overtime';
overtimeBadge.textContent = '야근';
infoDiv.appendChild(overtimeBadge);
}
// 근태 구분
if (record.attendance_type && record.attendance_type !== 'normal') {
const typeBadge = document.createElement('span');
typeBadge.className = 'attendance-badge';
switch (record.attendance_type) {
case 'annual_leave':
typeBadge.classList.add('badge-annual');
typeBadge.textContent = '연차';
break;
case 'half_leave':
typeBadge.classList.add('badge-half');
typeBadge.textContent = '반차';
break;
case 'quarter_leave':
typeBadge.classList.add('badge-quarter');
typeBadge.textContent = '반반차';
break;
case 'early_leave':
typeBadge.classList.add('badge-early');
typeBadge.textContent = '조퇴';
break;
default:
typeBadge.classList.add('badge-normal');
typeBadge.textContent = '정상';
}
infoDiv.appendChild(typeBadge);
}
dayElement.appendChild(infoDiv);
}
calendarDays.appendChild(dayElement);
}
}
function renderSummary() {
const summarySection = document.getElementById('summarySection');
// 통계 계산
const totalDays = attendanceRecords.length;
const totalHours = attendanceRecords.reduce((sum, r) => sum + (r.total_hours || 0), 0);
const overtimeDays = attendanceRecords.filter(r => r.is_overtime).length;
const annualLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'annual_leave').length;
const halfLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'half_leave').length;
const quarterLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'quarter_leave').length;
summarySection.innerHTML = `
<div class="summary-item">
<div class="summary-label">총 근무일수</div>
<div class="summary-value">${totalDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">총 근무시간</div>
<div class="summary-value">${totalHours.toFixed(1)}시간</div>
</div>
<div class="summary-item">
<div class="summary-label">야근일수</div>
<div class="summary-value">${overtimeDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">연차 사용</div>
<div class="summary-value">${annualLeaveDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">반차 사용</div>
<div class="summary-value">${halfLeaveDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">반반차 사용</div>
<div class="summary-value">${quarterLeaveDays}일</div>
</div>
`;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,354 @@
<!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/vacation-allocation.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 스크립트 -->
<script type="module" src="/js/api-config.js"></script>
<script type="module" src="/js/auth-check.js" defer></script>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/vacation-allocation.js" defer></script>
</head>
<body>
<!-- 메인 컨테이너 -->
<div class="page-container">
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="content-wrapper">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1 class="page-title"> 휴가 발생 입력</h1>
<p class="page-description">작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
</div>
<!-- 탭 네비게이션 -->
<div class="tab-navigation">
<button class="tab-button active" data-tab="individual">개별 입력</button>
<button class="tab-button" data-tab="bulk">일괄 입력</button>
<button class="tab-button" data-tab="special">특별 휴가 관리</button>
</div>
<!-- 탭 1: 개별 입력 -->
<section id="tab-individual" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">개별 작업자 휴가 입력</h2>
</div>
<div class="card-body">
<!-- 입력 폼 -->
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="individualWorker">작업자 선택 <span class="required">*</span></label>
<select id="individualWorker" class="form-select" required>
<option value="">선택하세요</option>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="individualYear">연도 <span class="required">*</span></label>
<select id="individualYear" class="form-select" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="individualVacationType">휴가 유형 <span class="required">*</span></label>
<select id="individualVacationType" class="form-select" required>
<option value="">선택하세요</option>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
</div>
<!-- 자동 계산 섹션 -->
<div class="auto-calculate-section">
<div class="section-header">
<h3>자동 계산 (연차만 해당)</h3>
<button id="autoCalculateBtn" class="btn btn-secondary btn-sm">
🔄 입사일 기준 자동 계산
</button>
</div>
<div id="autoCalculateResult" class="alert alert-info" style="display: none;">
<!-- 계산 결과 표시 -->
</div>
</div>
<!-- 수동 입력 -->
<div class="form-row">
<div class="form-group">
<label for="individualTotalDays">총 부여 일수 <span class="required">*</span></label>
<input type="number" id="individualTotalDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="individualUsedDays">사용 일수</label>
<input type="number" id="individualUsedDays" class="form-input" min="0" step="0.5" value="0">
</div>
<div class="form-group full-width">
<label for="individualNotes">비고</label>
<input type="text" id="individualNotes" class="form-input" placeholder="예: 2026년 연차">
</div>
</div>
<div class="form-actions">
<button id="individualSubmitBtn" class="btn btn-primary">
저장
</button>
<button id="individualResetBtn" class="btn btn-secondary">
초기화
</button>
</div>
</div>
<!-- 기존 데이터 테이블 -->
<div class="existing-data-section">
<h3>기존 입력 내역</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>연도</th>
<th>휴가 유형</th>
<th>총 일수</th>
<th>사용 일수</th>
<th>잔여 일수</th>
<th>비고</th>
<th>작업</th>
</tr>
</thead>
<tbody id="individualTableBody">
<tr>
<td colspan="8" class="loading-state">
<p>작업자를 선택하세요</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- 탭 2: 일괄 입력 -->
<section id="tab-bulk" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">근속년수별 연차 일괄 생성</h2>
</div>
<div class="card-body">
<div class="alert alert-warning">
<strong>주의:</strong> 이 기능은 연차(ANNUAL) 휴가 유형만 생성합니다. 각 작업자의 입사일을 기준으로 근속년수를 계산하여 자동으로 연차를 부여합니다.
</div>
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="bulkYear">대상 연도 <span class="required">*</span></label>
<select id="bulkYear" class="form-select" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="bulkEmploymentStatus">재직 상태</label>
<select id="bulkEmploymentStatus" class="form-select">
<option value="employed">재직 중만</option>
<option value="all">전체</option>
</select>
</div>
</div>
<div class="form-actions">
<button id="bulkPreviewBtn" class="btn btn-secondary">
미리보기
</button>
<button id="bulkSubmitBtn" class="btn btn-primary" disabled>
일괄 생성
</button>
</div>
</div>
<!-- 미리보기 테이블 -->
<div id="bulkPreviewSection" class="preview-section" style="display: none;">
<h3>생성 예정 내역</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>입사일</th>
<th>근속년수</th>
<th>부여 연차</th>
<th>계산 근거</th>
<th>상태</th>
</tr>
</thead>
<tbody id="bulkPreviewTableBody">
<!-- JavaScript로 동적 생성 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- 탭 3: 특별 휴가 관리 -->
<section id="tab-special" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">특별 휴가 유형 관리</h2>
<button id="addSpecialTypeBtn" class="btn btn-primary btn-sm">
+ 새 휴가 유형 추가
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>유형명</th>
<th>코드</th>
<th>우선순위</th>
<th>특별 휴가</th>
<th>시스템 유형</th>
<th>설명</th>
<th>작업</th>
</tr>
</thead>
<tbody id="specialTypesTableBody">
<tr>
<td colspan="7" class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<!-- 모달: 휴가 유형 추가/수정 -->
<div id="vacationTypeModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">휴가 유형 추가</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="vacationTypeForm">
<input type="hidden" id="modalTypeId">
<div class="form-group">
<label for="modalTypeName">유형명 <span class="required">*</span></label>
<input type="text" id="modalTypeName" class="form-input" required>
</div>
<div class="form-group">
<label for="modalTypeCode">코드 <span class="required">*</span></label>
<input type="text" id="modalTypeCode" class="form-input" required>
<small>예: ANNUAL, SICK, MATERNITY (영문 대문자)</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="modalPriority">우선순위 <span class="required">*</span></label>
<input type="number" id="modalPriority" class="form-input" min="1" required>
<small>낮을수록 먼저 차감</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="modalIsSpecial">
특별 휴가
</label>
</div>
</div>
<div class="form-group">
<label for="modalDescription">설명</label>
<textarea id="modalDescription" class="form-input" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-secondary modal-close">취소</button>
</div>
</form>
</div>
</div>
</div>
<!-- 모달: 휴가 수정 -->
<div id="editBalanceModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>휴가 수정</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="editBalanceForm">
<input type="hidden" id="editBalanceId">
<div class="form-group">
<label for="editTotalDays">총 일수 <span class="required">*</span></label>
<input type="number" id="editTotalDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="editUsedDays">사용 일수 <span class="required">*</span></label>
<input type="number" id="editUsedDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="editNotes">비고</label>
<input type="text" id="editNotes" class="form-input">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-secondary modal-close">취소</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,267 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #111827;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</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">
<span class="title-icon"></span>
휴가 승인 관리
</h1>
<p class="page-description">휴가 신청을 승인하거나 거부합니다</p>
</div>
</div>
<!-- 탭 메뉴 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('pending')">승인 대기 목록</button>
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
</div>
<!-- 승인 대기 목록 탭 -->
<div id="pendingTab" class="tab-content active">
<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="pendingRequestsList" class="data-table-container">
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 전체 신청 내역 탭 -->
<div id="allTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">전체 신청 내역</h2>
<div class="page-actions">
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
<span style="margin: 0 0.5rem;">~</span>
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
<button class="btn btn-secondary" onclick="filterAllRequests()">
<span>🔍 조회</span>
</button>
<button class="btn btn-secondary" onclick="resetFilter()">
<span>🔄 전체</span>
</button>
</div>
</div>
<div class="card-body">
<div id="allRequestsList" class="data-table-container">
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 allRequestsData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/my-vacation.html';
return;
}
await loadPendingRequests();
await loadAllRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadPendingRequests();
loadAllRequests();
});
// 날짜 필터 초기화 (최근 3개월)
const today = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(today.getMonth() - 3);
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
function switchTab(tabName) {
// 모든 탭과 컨텐츠 비활성화
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택한 탭 활성화
if (tabName === 'pending') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('pendingTab').classList.add('active');
} else if (tabName === 'all') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('allTab').classList.add('active');
}
}
async function loadPendingRequests() {
try {
const response = await axios.get('/vacation-requests/pending');
if (response.data.success) {
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
}
} catch (error) {
console.error('승인 대기 목록 로드 오류:', error);
document.getElementById('pendingRequestsList').innerHTML = `
<div class="empty-state">
<p>승인 대기 중인 신청이 없습니다.</p>
</div>
`;
}
}
async function loadAllRequests() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
allRequestsData = response.data.data;
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
} catch (error) {
console.error('전체 신청 내역 로드 오류:', error);
document.getElementById('allRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
function filterAllRequests() {
const startDate = document.getElementById('filterStartDate').value;
const endDate = document.getElementById('filterEndDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
const filtered = allRequestsData.filter(req => {
return req.start_date >= startDate && req.start_date <= endDate;
});
renderVacationRequests(filtered, 'allRequestsList', false);
}
function resetFilter() {
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,294 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</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">
<span class="title-icon">📝</span>
휴가 직접 입력
</h1>
<p class="page-description">관리자 권한으로 작업자의 휴가 정보를 직접 입력합니다</p>
</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">
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="inputWorker">작업자 *</label>
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputVacationType">휴가 유형 *</label>
<select id="inputVacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputStartDate">시작일 *</label>
<input type="date" id="inputStartDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputEndDate">종료일 *</label>
<input type="date" id="inputEndDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputDaysUsed">사용 일수 *</label>
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label>작업자 휴가 잔여</label>
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<span class="text-muted">작업자를 선택하세요</span>
</div>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inputReason">사유</label>
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>💾 즉시 입력 (자동 승인)</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 최근 입력 내역 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">최근 입력 내역</h2>
<div class="page-actions">
<button class="btn btn-secondary" onclick="loadRecentInputs()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<div class="card-body">
<div id="recentInputsList" class="data-table-container">
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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>
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/my-vacation.html';
return;
}
await loadWorkers();
await loadVacationTypes();
await loadRecentInputs();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', loadRecentInputs);
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function updateVacationBalance() {
const workerId = document.getElementById('inputWorker').value;
const balanceDiv = document.getElementById('workerVacationBalance');
if (!workerId) {
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
return;
}
try {
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
if (response.data.success) {
const balance = response.data.data;
if (!balance || Object.keys(balance).length === 0) {
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="display: inline-block; margin-right: 1rem;">
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
</div>
`;
}).join('');
balanceDiv.innerHTML = balanceHTML;
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
}
}
async function submitVacationInput(event) {
event.preventDefault();
const data = {
worker_id: parseInt(document.getElementById('inputWorker').value),
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
start_date: document.getElementById('inputStartDate').value,
end_date: document.getElementById('inputEndDate').value,
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
reason: document.getElementById('inputReason').value || null,
auto_approve: true // 자동 승인 플래그
};
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
return;
}
try {
// 휴가 신청 생성
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
const requestId = response.data.data.request_id;
// 즉시 승인 처리
try {
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (approveResponse.data.success) {
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
document.getElementById('vacationInputForm').reset();
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
window.dispatchEvent(new Event('vacation-updated'));
}
} catch (approveError) {
console.error('자동 승인 오류:', approveError);
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 페이지에서 수동으로 승인해주세요.');
}
}
} catch (error) {
console.error('휴가 입력 오류:', error);
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
}
}
async function loadRecentInputs() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 최근 30일 이내 승인된 항목만 표시
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
const recentApproved = response.data.data.filter(req =>
req.status === 'approved' &&
req.created_at >= thirtyDaysAgoStr
).slice(0, 20); // 최근 20개만
renderVacationRequests(recentApproved, 'recentInputsList', false);
}
} catch (error) {
console.error('최근 입력 내역 로드 오류:', error);
document.getElementById('recentInputsList').innerHTML = `
<div class="empty-state">
<p>최근 입력 내역이 없습니다.</p>
</div>
`;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,461 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #111827;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</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">
<span class="title-icon">🏖️</span>
휴가 관리
</h1>
<p class="page-description">휴가 신청을 승인하고 작업자 휴가 정보를 관리합니다</p>
</div>
</div>
<!-- 탭 메뉴 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('approval')">승인 대기 목록</button>
<button class="tab" onclick="switchTab('input')">직접 입력</button>
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
</div>
<!-- 승인 대기 목록 탭 -->
<div id="approvalTab" class="tab-content active">
<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="pendingRequestsList" class="data-table-container">
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 직접 입력 탭 -->
<div id="inputTab" class="tab-content">
<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">
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="inputWorker">작업자 *</label>
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputVacationType">휴가 유형 *</label>
<select id="inputVacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputStartDate">시작일 *</label>
<input type="date" id="inputStartDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputEndDate">종료일 *</label>
<input type="date" id="inputEndDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputDaysUsed">사용 일수 *</label>
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label>작업자 휴가 잔여</label>
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<span class="text-muted">작업자를 선택하세요</span>
</div>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inputReason">사유</label>
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>💾 즉시 입력 (자동 승인)</span>
</button>
</div>
</form>
</div>
</div>
<!-- 최근 입력 내역 -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<h2 class="card-title">최근 입력 내역</h2>
<div class="page-actions">
<button class="btn btn-secondary" onclick="loadRecentInputs()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<div class="card-body">
<div id="recentInputsList" class="data-table-container">
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 전체 신청 내역 탭 -->
<div id="allTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">전체 신청 내역</h2>
<div class="page-actions">
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
<span style="margin: 0 0.5rem;">~</span>
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
<button class="btn btn-secondary" onclick="filterAllRequests()">
<span>🔍 조회</span>
</button>
<button class="btn btn-secondary" onclick="resetFilter()">
<span>🔄 전체</span>
</button>
</div>
</div>
<div class="card-body">
<div id="allRequestsList" class="data-table-container">
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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 allRequestsData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/vacation-request.html';
return;
}
await loadWorkers();
await loadVacationTypes();
await loadPendingRequests();
await loadAllRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadPendingRequests();
loadAllRequests();
});
// 날짜 필터 초기화 (최근 3개월)
const today = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(today.getMonth() - 3);
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
function switchTab(tabName) {
// 모든 탭과 컨텐츠 비활성화
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택한 탭 활성화
if (tabName === 'approval') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('approvalTab').classList.add('active');
} else if (tabName === 'input') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('inputTab').classList.add('active');
} else if (tabName === 'all') {
document.querySelector('.tab:nth-child(3)').classList.add('active');
document.getElementById('allTab').classList.add('active');
}
}
async function loadPendingRequests() {
try {
const response = await axios.get('/vacation-requests/pending');
if (response.data.success) {
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
}
} catch (error) {
console.error('승인 대기 목록 로드 오류:', error);
document.getElementById('pendingRequestsList').innerHTML = `
<div class="empty-state">
<p>승인 대기 중인 신청이 없습니다.</p>
</div>
`;
}
}
async function loadAllRequests() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
allRequestsData = response.data.data;
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
} catch (error) {
console.error('전체 신청 내역 로드 오류:', error);
document.getElementById('allRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
async function updateVacationBalance() {
const workerId = document.getElementById('inputWorker').value;
const balanceDiv = document.getElementById('workerVacationBalance');
if (!workerId) {
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
return;
}
try {
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
if (response.data.success) {
const balance = response.data.data;
if (!balance || Object.keys(balance).length === 0) {
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="display: inline-block; margin-right: 1rem;">
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
</div>
`;
}).join('');
balanceDiv.innerHTML = balanceHTML;
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
}
}
async function submitVacationInput(event) {
event.preventDefault();
const data = {
worker_id: parseInt(document.getElementById('inputWorker').value),
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
start_date: document.getElementById('inputStartDate').value,
end_date: document.getElementById('inputEndDate').value,
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
reason: document.getElementById('inputReason').value || null
};
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
return;
}
try {
// 휴가 신청 생성
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
const requestId = response.data.data.request_id;
// 즉시 승인 처리
try {
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (approveResponse.data.success) {
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
document.getElementById('vacationInputForm').reset();
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
window.dispatchEvent(new Event('vacation-updated'));
loadRecentInputs();
}
} catch (approveError) {
console.error('자동 승인 오류:', approveError);
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 탭에서 수동으로 승인해주세요.');
}
}
} catch (error) {
console.error('휴가 입력 오류:', error);
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
}
}
async function loadRecentInputs() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 최근 30일 이내 승인된 항목만 표시
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
const recentApproved = response.data.data.filter(req =>
req.status === 'approved' &&
req.created_at >= thirtyDaysAgoStr
).slice(0, 20); // 최근 20개만
renderVacationRequests(recentApproved, 'recentInputsList', false);
}
} catch (error) {
console.error('최근 입력 내역 로드 오류:', error);
document.getElementById('recentInputsList').innerHTML = `
<div class="empty-state">
<p>최근 입력 내역이 없습니다.</p>
</div>
`;
}
}
function filterAllRequests() {
const startDate = document.getElementById('filterStartDate').value;
const endDate = document.getElementById('filterEndDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
const filtered = allRequestsData.filter(req => {
return req.start_date >= startDate && req.start_date <= endDate;
});
renderVacationRequests(filtered, 'allRequestsList', false);
}
function resetFilter() {
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,272 @@
<!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/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</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">
<span class="title-icon">📝</span>
휴가 신청
</h1>
<p class="page-description">휴가를 신청하고 신청 내역을 확인합니다</p>
</div>
</div>
<!-- 휴가 잔여 현황 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 잔여 현황</h2>
</div>
<div class="card-body">
<div id="vacationBalance" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<!-- 휴가 잔여 정보가 여기에 표시됩니다 -->
</div>
</div>
</div>
</div>
<!-- 휴가 신청 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 신청</h2>
</div>
<div class="card-body">
<form id="vacationRequestForm" onsubmit="submitVacationRequest(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="vacationType">휴가 유형 *</label>
<select id="vacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="daysUsed">사용 일수 *</label>
<input type="number" id="daysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label for="startDate">시작일 *</label>
<input type="date" id="startDate" class="form-control" required>
</div>
<div class="form-group">
<label for="endDate">종료일 *</label>
<input type="date" id="endDate" class="form-control" required>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="reason">사유</label>
<textarea id="reason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>📝 신청하기</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 내 신청 내역 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">내 신청 내역</h2>
</div>
<div class="card-body">
<div id="myRequestsList" class="data-table-container">
<!-- 내 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></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('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('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>
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
if (!currentUser || !currentUser.worker_id) {
alert('작업자 정보가 없습니다. 관리자에게 문의하세요.');
return;
}
await loadVacationTypes();
await loadVacationBalance();
await loadMyRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadVacationBalance();
loadMyRequests();
});
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadVacationBalance() {
const currentUser = getCurrentUser();
try {
const response = await axios.get(`/attendance/vacation-balance/${currentUser.worker_id}`);
if (response.data.success) {
renderVacationBalance(response.data.data);
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
document.getElementById('vacationBalance').innerHTML = `
<p class="text-muted">휴가 잔여 정보를 불러올 수 없습니다.</p>
`;
}
}
function renderVacationBalance(balance) {
const container = document.getElementById('vacationBalance');
if (!balance || Object.keys(balance).length === 0) {
container.innerHTML = '<p class="text-muted">휴가 잔여 정보가 없습니다.</p>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="padding: 1.5rem; background-color: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem;">${key}</div>
<div style="font-size: 1.5rem; font-weight: 700; color: #111827;">
${info.remaining || 0}
</div>
<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">
사용: ${info.used || 0}일 / 전체: ${info.total || 0}
</div>
</div>
`;
}).join('');
container.innerHTML = balanceHTML;
}
async function submitVacationRequest(event) {
event.preventDefault();
const currentUser = getCurrentUser();
const data = {
worker_id: currentUser.worker_id,
vacation_type_id: parseInt(document.getElementById('vacationType').value),
start_date: document.getElementById('startDate').value,
end_date: document.getElementById('endDate').value,
days_used: parseFloat(document.getElementById('daysUsed').value),
reason: document.getElementById('reason').value || null
};
try {
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
alert('휴가 신청이 완료되었습니다.');
document.getElementById('vacationRequestForm').reset();
window.dispatchEvent(new Event('vacation-updated'));
}
} catch (error) {
console.error('휴가 신청 오류:', error);
alert(error.response?.data?.message || '휴가 신청 중 오류가 발생했습니다.');
}
}
async function loadMyRequests() {
const currentUser = getCurrentUser();
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 내 신청만 필터링
const myRequests = response.data.data.filter(req =>
req.requested_by === currentUser.user_id || req.worker_id === currentUser.worker_id
);
renderVacationRequests(myRequests, 'myRequestsList', true, 'delete');
}
} catch (error) {
console.error('내 신청 내역 로드 오류:', error);
document.getElementById('myRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
</script>
</body>
</html>