feat: 대시보드 작업장 현황 지도 구현
- 실시간 작업장 현황을 지도로 시각화 - 작업장 관리 페이지에서 정의한 구역 정보 활용 - TBM 작업자 및 방문자 현황 표시 주요 변경사항: - dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거) - workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현 - modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가 시각화 방식: - 인원 없음: 회색 테두리 + 작업장 이름 - 내부 작업자: 파란색 영역 + 인원 수 - 외부 방문자: 보라색 영역 + 인원 수 - 둘 다: 초록색 영역 + 총 인원 수 기술 구현: - Canvas API 기반 사각형 영역 렌더링 - map-regions API를 통한 데이터 일관성 보장 - 클릭 이벤트로 상세 정보 모달 표시 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -241,6 +241,11 @@ function renderUsersTable() {
|
||||
<button class="action-btn edit" onclick="editUser(${user.user_id})">
|
||||
수정
|
||||
</button>
|
||||
${user.role !== 'Admin' && user.role !== 'admin' ? `
|
||||
<button class="action-btn permissions" onclick="managePageAccess(${user.user_id})">
|
||||
권한
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="action-btn toggle" onclick="toggleUserStatus(${user.user_id})">
|
||||
${user.is_active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
@@ -344,17 +349,25 @@ function openAddUserModal() {
|
||||
function editUser(userId) {
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
|
||||
currentEditingUser = user;
|
||||
|
||||
|
||||
if (elements.modalTitle) {
|
||||
elements.modalTitle.textContent = '사용자 정보 수정';
|
||||
}
|
||||
|
||||
|
||||
// 역할 이름을 HTML select option value로 변환
|
||||
const roleToValueMap = {
|
||||
'Admin': 'admin',
|
||||
'System Admin': 'admin',
|
||||
'User': 'user',
|
||||
'Guest': 'user'
|
||||
};
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
if (elements.userNameInput) elements.userNameInput.value = user.name || '';
|
||||
if (elements.userIdInput) elements.userIdInput.value = user.username || '';
|
||||
if (elements.userRoleSelect) elements.userRoleSelect.value = user.role || '';
|
||||
if (elements.userRoleSelect) elements.userRoleSelect.value = roleToValueMap[user.role] || 'user';
|
||||
if (elements.userEmailInput) elements.userEmailInput.value = user.email || '';
|
||||
if (elements.userPhoneInput) elements.userPhoneInput.value = user.phone || '';
|
||||
|
||||
@@ -403,24 +416,26 @@ async function saveUser() {
|
||||
const formData = {
|
||||
name: elements.userNameInput?.value,
|
||||
username: elements.userIdInput?.value,
|
||||
role: elements.userRoleSelect?.value,
|
||||
role: elements.userRoleSelect?.value, // HTML select value는 이미 'admin' 또는 'user'
|
||||
email: elements.userEmailInput?.value,
|
||||
phone: elements.userPhoneInput?.value
|
||||
};
|
||||
|
||||
|
||||
console.log('저장할 데이터:', formData);
|
||||
|
||||
// 유효성 검사
|
||||
if (!formData.name || !formData.username || !formData.role) {
|
||||
showToast('필수 항목을 모두 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 비밀번호 처리
|
||||
if (!currentEditingUser && elements.userPasswordInput?.value) {
|
||||
formData.password = elements.userPasswordInput.value;
|
||||
} else if (currentEditingUser && elements.userPasswordInput?.value) {
|
||||
formData.password = elements.userPasswordInput.value;
|
||||
}
|
||||
|
||||
|
||||
let response;
|
||||
if (currentEditingUser) {
|
||||
// 수정
|
||||
@@ -429,17 +444,17 @@ async function saveUser() {
|
||||
// 생성
|
||||
response = await window.apiCall('/users', 'POST', formData);
|
||||
}
|
||||
|
||||
|
||||
if (response.success || response.user_id) {
|
||||
const action = currentEditingUser ? '수정' : '생성';
|
||||
showToast(`사용자가 성공적으로 ${action}되었습니다.`, 'success');
|
||||
|
||||
|
||||
closeUserModal();
|
||||
await loadUsers();
|
||||
} else {
|
||||
throw new Error(response.message || '사용자 저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('사용자 저장 오류:', error);
|
||||
showToast(`사용자 저장 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
@@ -532,3 +547,325 @@ window.deleteUser = deleteUser;
|
||||
window.toggleUserStatus = toggleUserStatus;
|
||||
window.closeUserModal = closeUserModal;
|
||||
window.closeDeleteModal = closeDeleteModal;
|
||||
|
||||
// ========== 페이지 권한 관리 ========== //
|
||||
let allPages = [];
|
||||
let userPageAccess = [];
|
||||
|
||||
// 모든 페이지 목록 로드
|
||||
async function loadAllPages() {
|
||||
try {
|
||||
const response = await apiCall('/pages');
|
||||
allPages = response.data || response || [];
|
||||
console.log('📄 페이지 목록 로드:', allPages.length, '개');
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 목록 로드 오류:', error);
|
||||
allPages = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자의 페이지 권한 로드
|
||||
async function loadUserPageAccess(userId) {
|
||||
try {
|
||||
const response = await apiCall(`/users/${userId}/page-access`);
|
||||
userPageAccess = response.data?.pageAccess || [];
|
||||
console.log(`👤 사용자 ${userId} 페이지 권한 로드:`, userPageAccess.length, '개');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 페이지 권한 로드 오류:', error);
|
||||
userPageAccess = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 권한 체크박스 렌더링
|
||||
function renderPageAccessList(userRole) {
|
||||
const pageAccessList = document.getElementById('pageAccessList');
|
||||
const pageAccessGroup = document.getElementById('pageAccessGroup');
|
||||
|
||||
if (!pageAccessList || !pageAccessGroup) return;
|
||||
|
||||
// Admin 사용자는 권한 설정 불필요
|
||||
if (userRole === 'admin') {
|
||||
pageAccessGroup.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
pageAccessGroup.style.display = 'block';
|
||||
|
||||
// 카테고리별로 페이지 그룹화
|
||||
const pagesByCategory = {
|
||||
'work': [],
|
||||
'admin': [],
|
||||
'common': [],
|
||||
'profile': []
|
||||
};
|
||||
|
||||
allPages.forEach(page => {
|
||||
const category = page.category || 'common';
|
||||
if (pagesByCategory[category]) {
|
||||
pagesByCategory[category].push(page);
|
||||
}
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
'common': '공통',
|
||||
'work': '작업',
|
||||
'admin': '관리',
|
||||
'profile': '프로필'
|
||||
};
|
||||
|
||||
// HTML 생성
|
||||
let html = '';
|
||||
|
||||
Object.keys(pagesByCategory).forEach(category => {
|
||||
const pages = pagesByCategory[category];
|
||||
if (pages.length === 0) return;
|
||||
|
||||
const catName = categoryNames[category] || category;
|
||||
html += '<div class="page-access-category">';
|
||||
html += '<div class="page-access-category-title">' + catName + '</div>';
|
||||
|
||||
pages.forEach(page => {
|
||||
// 프로필과 대시보드는 모든 사용자가 접근 가능하므로 체크박스 비활성화
|
||||
const isAlwaysAccessible = page.page_key === 'dashboard' || page.page_key.startsWith('profile.');
|
||||
const isChecked = userPageAccess.find(p => p.page_id === page.id && p.can_access === 1) || isAlwaysAccessible;
|
||||
|
||||
html += '<div class="page-access-item"><label>';
|
||||
html += '<input type="checkbox" class="page-access-checkbox" ';
|
||||
html += 'data-page-id="' + page.id + '" ';
|
||||
html += 'data-page-key="' + page.page_key + '" ';
|
||||
html += (isChecked ? 'checked ' : '');
|
||||
html += (isAlwaysAccessible ? 'disabled ' : '');
|
||||
html += '>';
|
||||
html += '<span class="page-name">' + page.page_name + '</span>';
|
||||
html += '</label></div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
pageAccessList.innerHTML = html;
|
||||
}
|
||||
|
||||
// 페이지 권한 저장
|
||||
async function savePageAccess(userId) {
|
||||
try {
|
||||
const checkboxes = document.querySelectorAll('.page-access-checkbox:not([disabled])');
|
||||
const pageAccessData = [];
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
pageAccessData.push({
|
||||
page_id: parseInt(checkbox.dataset.pageId),
|
||||
can_access: checkbox.checked ? 1 : 0
|
||||
});
|
||||
});
|
||||
|
||||
console.log('📤 페이지 권한 저장:', userId, pageAccessData);
|
||||
|
||||
await apiCall(`/users/${userId}/page-access`, 'PUT', {
|
||||
pageAccess: pageAccessData
|
||||
});
|
||||
|
||||
console.log('✅ 페이지 권한 저장 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 저장 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// editUser 함수를 수정하여 페이지 권한 로드 추가
|
||||
const originalEditUser = window.editUser;
|
||||
window.editUser = async function(userId) {
|
||||
// 페이지 목록이 없으면 로드
|
||||
if (allPages.length === 0) {
|
||||
await loadAllPages();
|
||||
}
|
||||
|
||||
// 원래 editUser 함수 실행
|
||||
if (originalEditUser) {
|
||||
originalEditUser(userId);
|
||||
}
|
||||
|
||||
// 사용자의 페이지 권한 로드
|
||||
await loadUserPageAccess(userId);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
// 페이지 권한 체크박스 렌더링
|
||||
const roleToValueMap = {
|
||||
'Admin': 'admin',
|
||||
'System Admin': 'admin',
|
||||
'User': 'user',
|
||||
'Guest': 'user'
|
||||
};
|
||||
const userRole = roleToValueMap[user.role] || 'user';
|
||||
renderPageAccessList(userRole);
|
||||
};
|
||||
|
||||
// saveUser 함수를 수정하여 페이지 권한 저장 추가
|
||||
const originalSaveUser = window.saveUser;
|
||||
window.saveUser = async function() {
|
||||
try {
|
||||
// 원래 saveUser 함수 실행
|
||||
if (originalSaveUser) {
|
||||
await originalSaveUser();
|
||||
}
|
||||
|
||||
// 사용자 편집 시에만 페이지 권한 저장
|
||||
if (currentEditingUser && currentEditingUser.user_id) {
|
||||
const userRole = document.getElementById('userRole')?.value;
|
||||
|
||||
// Admin이 아닌 경우에만 페이지 권한 저장
|
||||
if (userRole !== 'admin') {
|
||||
await savePageAccess(currentEditingUser.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 저장 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ========== 페이지 권한 관리 모달 ========== //
|
||||
let currentPageAccessUser = null;
|
||||
|
||||
// 페이지 권한 관리 모달 열기
|
||||
async function managePageAccess(userId) {
|
||||
try {
|
||||
// 페이지 목록이 없으면 로드
|
||||
if (allPages.length === 0) {
|
||||
await loadAllPages();
|
||||
}
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) {
|
||||
showToast('사용자를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentPageAccessUser = user;
|
||||
|
||||
// 사용자의 페이지 권한 로드
|
||||
await loadUserPageAccess(userId);
|
||||
|
||||
// 모달 정보 업데이트
|
||||
const userName = user.name || user.username;
|
||||
document.getElementById('pageAccessModalTitle').textContent = userName + ' - 페이지 권한 관리';
|
||||
document.getElementById('pageAccessUserName').textContent = userName;
|
||||
document.getElementById('pageAccessUserRole').textContent = getRoleName(user.role);
|
||||
document.getElementById('pageAccessUserAvatar').textContent = userName.charAt(0);
|
||||
|
||||
// 페이지 권한 체크박스 렌더링
|
||||
renderPageAccessModalList();
|
||||
|
||||
// 모달 표시
|
||||
document.getElementById('pageAccessModal').style.display = 'flex';
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 관리 모달 오류:', error);
|
||||
showToast('페이지 권한 관리를 열 수 없습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 권한 모달 닫기
|
||||
function closePageAccessModal() {
|
||||
document.getElementById('pageAccessModal').style.display = 'none';
|
||||
currentPageAccessUser = null;
|
||||
}
|
||||
|
||||
// 페이지 권한 체크박스 렌더링 (모달용)
|
||||
function renderPageAccessModalList() {
|
||||
const pageAccessList = document.getElementById('pageAccessModalList');
|
||||
if (!pageAccessList) return;
|
||||
|
||||
// 카테고리별로 페이지 그룹화
|
||||
const pagesByCategory = {
|
||||
'work': [],
|
||||
'admin': [],
|
||||
'common': [],
|
||||
'profile': []
|
||||
};
|
||||
|
||||
allPages.forEach(page => {
|
||||
const category = page.category || 'common';
|
||||
if (pagesByCategory[category]) {
|
||||
pagesByCategory[category].push(page);
|
||||
}
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
'common': '공통',
|
||||
'work': '작업',
|
||||
'admin': '관리',
|
||||
'profile': '프로필'
|
||||
};
|
||||
|
||||
// HTML 생성
|
||||
let html = '';
|
||||
|
||||
Object.keys(pagesByCategory).forEach(category => {
|
||||
const pages = pagesByCategory[category];
|
||||
if (pages.length === 0) return;
|
||||
|
||||
const catName = categoryNames[category] || category;
|
||||
html += '<div class="page-access-category">';
|
||||
html += '<div class="page-access-category-title">' + catName + '</div>';
|
||||
|
||||
pages.forEach(page => {
|
||||
// 프로필과 대시보드는 모든 사용자가 접근 가능
|
||||
const isAlwaysAccessible = page.page_key === 'dashboard' || page.page_key.startsWith('profile.');
|
||||
const isChecked = userPageAccess.find(p => p.page_id === page.id && p.can_access === 1) || isAlwaysAccessible;
|
||||
|
||||
html += '<div class="page-access-item"><label>';
|
||||
html += '<input type="checkbox" class="page-access-checkbox" ';
|
||||
html += 'data-page-id="' + page.id + '" ';
|
||||
html += 'data-page-key="' + page.page_key + '" ';
|
||||
html += (isChecked ? 'checked ' : '');
|
||||
html += (isAlwaysAccessible ? 'disabled ' : '');
|
||||
html += '>';
|
||||
html += '<span class="page-name">' + page.page_name + '</span>';
|
||||
html += '</label></div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
pageAccessList.innerHTML = html;
|
||||
}
|
||||
|
||||
// 페이지 권한 저장 (모달용)
|
||||
async function savePageAccessFromModal() {
|
||||
if (!currentPageAccessUser) {
|
||||
showToast('사용자 정보가 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await savePageAccess(currentPageAccessUser.user_id);
|
||||
showToast('페이지 권한이 저장되었습니다.', 'success');
|
||||
|
||||
// 캐시 삭제 (사용자가 다시 로그인하거나 페이지 새로고침 필요)
|
||||
localStorage.removeItem('userPageAccess');
|
||||
|
||||
closePageAccessModal();
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 저장 오류:', error);
|
||||
showToast('페이지 권한 저장에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 등록
|
||||
window.managePageAccess = managePageAccess;
|
||||
window.closePageAccessModal = closePageAccessModal;
|
||||
|
||||
// 저장 버튼 이벤트 리스너
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const saveBtn = document.getElementById('savePageAccessBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', savePageAccessFromModal);
|
||||
}
|
||||
});
|
||||
|
||||
412
web-ui/js/annual-vacation-overview.js
Normal file
412
web-ui/js/annual-vacation-overview.js
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* annual-vacation-overview.js
|
||||
* 연간 연차 현황 페이지 로직 (2-탭 구조)
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from './api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let annualUsageChart = null;
|
||||
let currentYear = new Date().getFullYear();
|
||||
let vacationRequests = [];
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 관리자 권한 체크
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id);
|
||||
|
||||
if (!isAdmin) {
|
||||
alert('관리자만 접근할 수 있습니다');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
initializeYearSelector();
|
||||
initializeMonthSelector();
|
||||
initializeEventListeners();
|
||||
await loadAnnualUsageData();
|
||||
});
|
||||
|
||||
/**
|
||||
* 연도 선택 초기화
|
||||
*/
|
||||
function initializeYearSelector() {
|
||||
const yearSelect = document.getElementById('yearSelect');
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// 최근 5년, 현재 연도, 다음 연도
|
||||
for (let year = currentYear - 5; year <= currentYear + 1; year++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}년`;
|
||||
if (year === currentYear) {
|
||||
option.selected = true;
|
||||
}
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 선택 초기화
|
||||
*/
|
||||
function initializeMonthSelector() {
|
||||
const monthSelect = document.getElementById('monthSelect');
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
|
||||
// 현재 월을 기본 선택
|
||||
monthSelect.value = currentMonth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 초기화
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// 탭 전환
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const tabName = e.target.dataset.tab;
|
||||
switchTab(tabName);
|
||||
});
|
||||
});
|
||||
|
||||
// 조회 버튼
|
||||
document.getElementById('refreshBtn').addEventListener('click', async () => {
|
||||
await loadAnnualUsageData();
|
||||
const activeTab = document.querySelector('.tab-btn.active').dataset.tab;
|
||||
if (activeTab === 'monthlyDetails') {
|
||||
await loadMonthlyDetails();
|
||||
}
|
||||
});
|
||||
|
||||
// 연도 변경 시 자동 조회
|
||||
document.getElementById('yearSelect').addEventListener('change', async () => {
|
||||
await loadAnnualUsageData();
|
||||
const activeTab = document.querySelector('.tab-btn.active').dataset.tab;
|
||||
if (activeTab === 'monthlyDetails') {
|
||||
await loadMonthlyDetails();
|
||||
}
|
||||
});
|
||||
|
||||
// 월 선택 변경 시
|
||||
document.getElementById('monthSelect').addEventListener('change', loadMonthlyDetails);
|
||||
|
||||
// 엑셀 다운로드
|
||||
document.getElementById('exportExcelBtn').addEventListener('click', exportToExcel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// 탭 버튼 활성화
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.tab === tabName) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 콘텐츠 활성화
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
if (tabName === 'annualUsage') {
|
||||
document.getElementById('annualUsageTab').classList.add('active');
|
||||
} else if (tabName === 'monthlyDetails') {
|
||||
document.getElementById('monthlyDetailsTab').classList.add('active');
|
||||
loadMonthlyDetails();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 사용 데이터 로드 (탭 1)
|
||||
*/
|
||||
async function loadAnnualUsageData() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 해당 연도의 모든 승인된 휴가 신청 조회
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/vacation-requests?start_date=${year}-01-01&end_date=${year}-12-31&status=approved`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('휴가 데이터를 불러오는데 실패했습니다');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
vacationRequests = result.data || [];
|
||||
|
||||
// 월별로 집계
|
||||
const monthlyData = aggregateMonthlyUsage(vacationRequests);
|
||||
|
||||
// 잔여 일수 계산 (올해 총 부여 - 사용)
|
||||
const remainingDays = await calculateRemainingDays(year);
|
||||
|
||||
updateAnnualUsageChart(monthlyData, remainingDays);
|
||||
} catch (error) {
|
||||
console.error('연간 사용 데이터 로드 오류:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 사용 일수 집계
|
||||
*/
|
||||
function aggregateMonthlyUsage(requests) {
|
||||
const monthlyUsage = Array(12).fill(0); // 1월~12월
|
||||
|
||||
requests.forEach(req => {
|
||||
const startDate = new Date(req.start_date);
|
||||
const endDate = new Date(req.end_date);
|
||||
const daysUsed = req.days_used || 0;
|
||||
|
||||
// 간단한 집계: 시작일의 월에 모든 일수를 할당
|
||||
// (더 정교한 계산이 필요하면 일자별로 쪼개야 함)
|
||||
const month = startDate.getMonth(); // 0-11
|
||||
monthlyUsage[month] += daysUsed;
|
||||
});
|
||||
|
||||
return monthlyUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여 일수 계산
|
||||
*/
|
||||
async function calculateRemainingDays(year) {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 전체 작업자의 휴가 잔액 조회
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/year/${year}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const balances = result.data || [];
|
||||
|
||||
// 전체 잔여 일수 합계
|
||||
const totalRemaining = balances.reduce((sum, item) => sum + (item.remaining_days || 0), 0);
|
||||
return totalRemaining;
|
||||
} catch (error) {
|
||||
console.error('잔여 일수 계산 오류:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 사용 차트 업데이트
|
||||
*/
|
||||
function updateAnnualUsageChart(monthlyData, remainingDays) {
|
||||
const ctx = document.getElementById('annualUsageChart');
|
||||
|
||||
// 기존 차트 삭제
|
||||
if (annualUsageChart) {
|
||||
annualUsageChart.destroy();
|
||||
}
|
||||
|
||||
const labels = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월', '잔여'];
|
||||
const data = [...monthlyData, remainingDays];
|
||||
|
||||
annualUsageChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '일수',
|
||||
data: data,
|
||||
backgroundColor: data.map((_, idx) =>
|
||||
idx === 12 ? 'rgba(16, 185, 129, 0.8)' : 'rgba(59, 130, 246, 0.8)'
|
||||
),
|
||||
borderColor: data.map((_, idx) =>
|
||||
idx === 12 ? 'rgba(16, 185, 129, 1)' : 'rgba(59, 130, 246, 1)'
|
||||
),
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.parsed.y}일`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 상세 기록 로드 (탭 2)
|
||||
*/
|
||||
async function loadMonthlyDetails() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 해당 월의 모든 휴가 신청 조회 (승인된 것만)
|
||||
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}`;
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/vacation-requests?start_date=${startDate}&end_date=${endDate}&status=approved`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('월별 데이터를 불러오는데 실패했습니다');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const monthlyRequests = result.data || [];
|
||||
|
||||
updateMonthlyTable(monthlyRequests);
|
||||
} catch (error) {
|
||||
console.error('월별 상세 기록 로드 오류:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 테이블 업데이트
|
||||
*/
|
||||
function updateMonthlyTable(requests) {
|
||||
const tbody = document.getElementById('monthlyTableBody');
|
||||
|
||||
if (requests.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<p>데이터가 없습니다</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = requests.map(req => {
|
||||
const statusText = req.status === 'approved' ? '승인' : req.status === 'pending' ? '대기' : '거부';
|
||||
const statusClass = req.status === 'approved' ? 'success' : req.status === 'pending' ? 'warning' : 'danger';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${req.worker_name}</td>
|
||||
<td>${req.vacation_type_name}</td>
|
||||
<td>${req.start_date}</td>
|
||||
<td>${req.end_date}</td>
|
||||
<td>${req.days_used}일</td>
|
||||
<td>${req.reason || '-'}</td>
|
||||
<td><span class="badge badge-${statusClass}">${statusText}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드
|
||||
*/
|
||||
function exportToExcel() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
const tbody = document.getElementById('monthlyTableBody');
|
||||
|
||||
// 테이블에 데이터가 없으면 중단
|
||||
if (!tbody.querySelector('tr:not(.loading-state)')) {
|
||||
showToast('다운로드할 데이터가 없습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSV 형식으로 데이터 생성
|
||||
const headers = ['작업자명', '휴가유형', '시작일', '종료일', '사용일수', '사유', '상태'];
|
||||
const rows = Array.from(tbody.querySelectorAll('tr:not(.loading-state)')).map(tr => {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
return Array.from(cells).map(cell => {
|
||||
// badge 클래스가 있으면 텍스트만 추출
|
||||
const badge = cell.querySelector('.badge');
|
||||
return badge ? badge.textContent : cell.textContent;
|
||||
});
|
||||
});
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n');
|
||||
|
||||
// BOM 추가 (한글 깨짐 방지)
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `월별_연차_상세_${year}_${month}월.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showToast('엑셀 파일이 다운로드되었습니다', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
container.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
@@ -243,4 +243,7 @@ setInterval(() => {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
redirectToLogin();
|
||||
}
|
||||
}, config.app.tokenRefreshInterval); // 5분마다 확인
|
||||
}, config.app.tokenRefreshInterval); // 5분마다 확인
|
||||
|
||||
// ES6 모듈 export
|
||||
export { API_URL as API_BASE_URL };
|
||||
@@ -14,10 +14,105 @@ function getUser() {
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userPageAccess'); // 페이지 권한 캐시도 삭제
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지의 page_key를 URL 경로로부터 추출
|
||||
* 예: /pages/work/tbm.html -> work.tbm
|
||||
* /pages/admin/accounts.html -> admin.accounts
|
||||
* /pages/dashboard.html -> dashboard
|
||||
*/
|
||||
function getCurrentPageKey() {
|
||||
const path = window.location.pathname;
|
||||
|
||||
// /pages/로 시작하는지 확인
|
||||
if (!path.startsWith('/pages/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// /pages/ 이후 경로 추출
|
||||
const pagePath = path.substring(7); // '/pages/' 제거
|
||||
|
||||
// .html 제거
|
||||
const withoutExt = pagePath.replace('.html', '');
|
||||
|
||||
// 슬래시를 점으로 변환
|
||||
const pageKey = withoutExt.replace(/\//g, '.');
|
||||
|
||||
return pageKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 페이지 접근 권한 확인 (캐시 활용)
|
||||
*/
|
||||
async function checkPageAccess(pageKey) {
|
||||
const currentUser = getUser();
|
||||
|
||||
// Admin은 모든 페이지 접근 가능
|
||||
if (currentUser.role === 'Admin' || currentUser.role === 'System Admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 프로필 페이지는 모든 사용자 접근 가능
|
||||
if (pageKey && pageKey.startsWith('profile.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 대시보드는 모든 사용자 접근 가능
|
||||
if (pageKey === 'dashboard') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 캐시된 권한 확인
|
||||
const cached = localStorage.getItem('userPageAccess');
|
||||
let accessiblePages = null;
|
||||
|
||||
if (cached) {
|
||||
const cacheData = JSON.parse(cached);
|
||||
// 캐시가 5분 이내인 경우 사용
|
||||
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
||||
accessiblePages = cacheData.pages;
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시가 없으면 API 호출
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('페이지 권한 조회 실패:', response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
accessiblePages = data.data.pageAccess || [];
|
||||
|
||||
// 캐시 저장
|
||||
localStorage.setItem('userPageAccess', JSON.stringify({
|
||||
pages: accessiblePages,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
|
||||
// 해당 페이지에 대한 접근 권한 확인
|
||||
const pageAccess = accessiblePages.find(p => p.page_key === pageKey);
|
||||
return pageAccess && pageAccess.can_access === 1;
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 체크 오류:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
|
||||
(function() {
|
||||
(async function() {
|
||||
if (!isLoggedIn()) {
|
||||
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData(); // 만약을 위해 한번 더 정리
|
||||
@@ -26,7 +121,7 @@ function clearAuthData() {
|
||||
}
|
||||
|
||||
const currentUser = getUser();
|
||||
|
||||
|
||||
// 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우)
|
||||
if (!currentUser || !currentUser.username) {
|
||||
console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
|
||||
@@ -38,6 +133,25 @@ function clearAuthData() {
|
||||
const userRole = currentUser.role || currentUser.access_level || '사용자';
|
||||
console.log(`✅ ${currentUser.username}(${userRole})님 인증 성공.`);
|
||||
|
||||
// 페이지 접근 권한 체크 (Admin은 건너뛰기)
|
||||
if (currentUser.role !== 'Admin' && currentUser.role !== 'System Admin') {
|
||||
const pageKey = getCurrentPageKey();
|
||||
|
||||
if (pageKey) {
|
||||
console.log(`🔍 페이지 권한 체크: ${pageKey}`);
|
||||
const hasAccess = await checkPageAccess(pageKey);
|
||||
|
||||
if (!hasAccess) {
|
||||
console.error(`🚫 페이지 접근 권한이 없습니다: ${pageKey}`);
|
||||
alert('이 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ 페이지 접근 권한 확인됨: ${pageKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
|
||||
// 전역 변수 할당(window.currentUser) 제거.
|
||||
})();
|
||||
@@ -80,7 +80,18 @@ async function loadIncompleteTbms() {
|
||||
throw new Error(response.message || '미완료 TBM 조회 실패');
|
||||
}
|
||||
|
||||
incompleteTbms = response.data || [];
|
||||
let data = response.data || [];
|
||||
|
||||
// 사용자 권한 확인 및 필터링
|
||||
const user = getUser();
|
||||
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
|
||||
// 일반 사용자: 자신이 생성한 세션만 표시
|
||||
const userId = user.user_id;
|
||||
data = data.filter(tbm => tbm.created_by === userId);
|
||||
}
|
||||
// 관리자는 모든 데이터 표시
|
||||
|
||||
incompleteTbms = data;
|
||||
renderTbmWorkList();
|
||||
} catch (error) {
|
||||
console.error('미완료 TBM 로드 오류:', error);
|
||||
@@ -88,6 +99,14 @@ async function loadIncompleteTbms() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보 가져오기 (auth-check.js와 동일한 로직)
|
||||
*/
|
||||
function getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
return user ? JSON.parse(user) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TBM 작업 목록 렌더링 (세션별 그룹화)
|
||||
*/
|
||||
@@ -120,11 +139,42 @@ function renderTbmWorkList() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 수동 입력 섹션 먼저 추가 (맨 위)
|
||||
html += `
|
||||
<div class="tbm-session-group manual-input-section">
|
||||
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
||||
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
|
||||
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
|
||||
</div>
|
||||
<div class="tbm-table-container">
|
||||
<table class="tbm-work-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>날짜</th>
|
||||
<th>프로젝트</th>
|
||||
<th>공정</th>
|
||||
<th>작업</th>
|
||||
<th>작업장소</th>
|
||||
<th>작업시간<br>(시간)</th>
|
||||
<th>부적합<br>(시간)</th>
|
||||
<th>부적합 원인</th>
|
||||
<th>제출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manualWorkTableBody">
|
||||
<!-- 수동 입력 행들이 여기에 추가됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 각 TBM 세션별로 테이블 생성
|
||||
Object.keys(groupedTbms).forEach(key => {
|
||||
const group = groupedTbms[key];
|
||||
html += `
|
||||
<div class="tbm-session-group">
|
||||
<div class="tbm-session-group" data-session-key="${key}">
|
||||
<div class="tbm-session-header">
|
||||
<span class="tbm-session-badge">TBM 세션</span>
|
||||
<span class="tbm-session-date">${formatDate(group.session_date)}</span>
|
||||
@@ -150,7 +200,7 @@ function renderTbmWorkList() {
|
||||
${group.items.map(tbm => {
|
||||
const index = tbm.originalIndex;
|
||||
return `
|
||||
<tr data-index="${index}" data-type="tbm">
|
||||
<tr data-index="${index}" data-type="tbm" data-session-key="${key}">
|
||||
<td>
|
||||
<div class="worker-cell">
|
||||
<strong>${tbm.worker_name || '작업자'}</strong>
|
||||
@@ -202,41 +252,17 @@ function renderTbmWorkList() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="batch-submit-container">
|
||||
<button type="button"
|
||||
class="btn-batch-submit"
|
||||
onclick="batchSubmitTbmSession('${key}')">
|
||||
📤 이 세션 일괄제출 (${group.items.length}건)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// 수동 입력 섹션 추가
|
||||
html += `
|
||||
<div class="tbm-session-group" style="margin-top: 2rem;">
|
||||
<div class="tbm-session-header" style="background-color: #fef3c7;">
|
||||
<span class="tbm-session-badge" style="background-color: #f59e0b;">수동 입력</span>
|
||||
<span class="tbm-session-info">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
|
||||
</div>
|
||||
<div class="tbm-table-container">
|
||||
<table class="tbm-work-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>날짜</th>
|
||||
<th>프로젝트</th>
|
||||
<th>공정</th>
|
||||
<th>작업</th>
|
||||
<th>작업장소</th>
|
||||
<th>작업시간<br>(시간)</th>
|
||||
<th>부적합<br>(시간)</th>
|
||||
<th>부적합 원인</th>
|
||||
<th>제출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manualWorkTableBody">
|
||||
<!-- 수동 입력 행들이 여기에 추가됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
@@ -286,13 +312,20 @@ window.submitTbmWorkReport = async function(index) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜를 YYYY-MM-DD 형식으로 변환
|
||||
const reportDate = tbm.session_date instanceof Date
|
||||
? tbm.session_date.toISOString().split('T')[0]
|
||||
: (typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
|
||||
? tbm.session_date.split('T')[0]
|
||||
: tbm.session_date);
|
||||
|
||||
const reportData = {
|
||||
tbm_assignment_id: tbm.assignment_id,
|
||||
tbm_session_id: tbm.session_id,
|
||||
worker_id: tbm.worker_id,
|
||||
project_id: tbm.project_id,
|
||||
work_type_id: tbm.work_type_id,
|
||||
report_date: tbm.session_date,
|
||||
report_date: reportDate,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
total_hours: totalHours,
|
||||
@@ -301,6 +334,9 @@ window.submitTbmWorkReport = async function(index) {
|
||||
work_status_id: errorHours > 0 ? 2 : 1
|
||||
};
|
||||
|
||||
console.log('🔍 TBM 제출 데이터:', JSON.stringify(reportData, null, 2));
|
||||
console.log('🔍 tbm 객체:', tbm);
|
||||
|
||||
try {
|
||||
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
|
||||
|
||||
@@ -325,6 +361,155 @@ window.submitTbmWorkReport = async function(index) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* TBM 세션 일괄제출
|
||||
*/
|
||||
window.batchSubmitTbmSession = async function(sessionKey) {
|
||||
// 해당 세션의 모든 항목 가져오기
|
||||
const sessionRows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"]`);
|
||||
|
||||
if (sessionRows.length === 0) {
|
||||
showMessage('제출할 항목이 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1단계: 모든 항목 검증
|
||||
const validationErrors = [];
|
||||
const itemsToSubmit = [];
|
||||
|
||||
sessionRows.forEach((row, rowIndex) => {
|
||||
const index = parseInt(row.getAttribute('data-index'));
|
||||
const tbm = incompleteTbms[index];
|
||||
|
||||
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`)?.value);
|
||||
const errorHours = parseFloat(document.getElementById(`errorHours_${index}`)?.value) || 0;
|
||||
const errorTypeId = document.getElementById(`errorType_${index}`)?.value;
|
||||
|
||||
// 검증
|
||||
if (!totalHours || totalHours <= 0) {
|
||||
validationErrors.push(`${tbm.worker_name}: 작업시간 미입력`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorHours > totalHours) {
|
||||
validationErrors.push(`${tbm.worker_name}: 부적합 시간이 총 작업시간 초과`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorHours > 0 && !errorTypeId) {
|
||||
validationErrors.push(`${tbm.worker_name}: 부적합 원인 미선택`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 검증 통과한 항목 저장
|
||||
const reportDate = tbm.session_date instanceof Date
|
||||
? tbm.session_date.toISOString().split('T')[0]
|
||||
: (typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
|
||||
? tbm.session_date.split('T')[0]
|
||||
: tbm.session_date);
|
||||
|
||||
itemsToSubmit.push({
|
||||
index,
|
||||
tbm,
|
||||
data: {
|
||||
tbm_assignment_id: tbm.assignment_id,
|
||||
tbm_session_id: tbm.session_id,
|
||||
worker_id: tbm.worker_id,
|
||||
project_id: tbm.project_id,
|
||||
work_type_id: tbm.work_type_id,
|
||||
report_date: reportDate,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
total_hours: totalHours,
|
||||
error_hours: errorHours,
|
||||
error_type_id: errorTypeId || null,
|
||||
work_status_id: errorHours > 0 ? 2 : 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 검증 실패가 하나라도 있으면 전체 중단
|
||||
if (validationErrors.length > 0) {
|
||||
showSaveResultModal(
|
||||
'error',
|
||||
'일괄제출 검증 실패',
|
||||
'모든 항목이 유효해야 제출할 수 있습니다.',
|
||||
validationErrors
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2단계: 모든 항목 제출
|
||||
const submitBtn = event.target;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '제출 중...';
|
||||
|
||||
const results = {
|
||||
success: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
try {
|
||||
for (const item of itemsToSubmit) {
|
||||
try {
|
||||
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', item.data);
|
||||
|
||||
if (response.success) {
|
||||
results.success.push(item.tbm.worker_name);
|
||||
} else {
|
||||
results.failed.push(`${item.tbm.worker_name}: ${response.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed.push(`${item.tbm.worker_name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 표시
|
||||
const totalCount = itemsToSubmit.length;
|
||||
const successCount = results.success.length;
|
||||
const failedCount = results.failed.length;
|
||||
|
||||
if (failedCount === 0) {
|
||||
// 모두 성공
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'일괄제출 완료',
|
||||
`${totalCount}건의 작업보고서가 모두 성공적으로 제출되었습니다.`,
|
||||
results.success.map(name => `✓ ${name}`)
|
||||
);
|
||||
} else if (successCount === 0) {
|
||||
// 모두 실패
|
||||
showSaveResultModal(
|
||||
'error',
|
||||
'일괄제출 실패',
|
||||
`${totalCount}건의 작업보고서가 모두 실패했습니다.`,
|
||||
results.failed.map(msg => `✗ ${msg}`)
|
||||
);
|
||||
} else {
|
||||
// 일부 성공, 일부 실패
|
||||
const details = [
|
||||
...results.success.map(name => `✓ ${name} - 성공`),
|
||||
...results.failed.map(msg => `✗ ${msg}`)
|
||||
];
|
||||
showSaveResultModal(
|
||||
'warning',
|
||||
'일괄제출 부분 완료',
|
||||
`성공: ${successCount}건 / 실패: ${failedCount}건`,
|
||||
details
|
||||
);
|
||||
}
|
||||
|
||||
// 목록 새로고침
|
||||
await loadIncompleteTbms();
|
||||
} catch (error) {
|
||||
console.error('일괄제출 오류:', error);
|
||||
showSaveResultModal('error', '일괄제출 오류', error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = `📤 이 세션 일괄제출 (${sessionRows.length}건)`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 수동 작업 추가
|
||||
*/
|
||||
@@ -1075,15 +1260,23 @@ function showSaveResultModal(type, title, message, details = null) {
|
||||
`;
|
||||
|
||||
// 상세 정보가 있으면 추가
|
||||
if (details && details.length > 0) {
|
||||
content += `
|
||||
<div class="result-details">
|
||||
<h4>상세 정보:</h4>
|
||||
<ul>
|
||||
${details.map(detail => `<li>${detail}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
if (details) {
|
||||
if (Array.isArray(details) && details.length > 0) {
|
||||
content += `
|
||||
<div class="result-details">
|
||||
<h4>상세 정보:</h4>
|
||||
<ul>
|
||||
${details.map(detail => `<li>${detail}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
} else if (typeof details === 'string') {
|
||||
content += `
|
||||
<div class="result-details">
|
||||
<p>${details}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
titleElement.textContent = '저장 결과';
|
||||
@@ -1114,6 +1307,9 @@ function closeSaveResultModal() {
|
||||
document.removeEventListener('keydown', closeSaveResultModal);
|
||||
}
|
||||
|
||||
// 전역에서 접근 가능하도록 window에 할당
|
||||
window.closeSaveResultModal = closeSaveResultModal;
|
||||
|
||||
// 단계 이동
|
||||
function goToStep(stepNumber) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
|
||||
@@ -8,9 +8,31 @@ let currentEquipment = null;
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// axios 설정이 완료될 때까지 대기
|
||||
await waitForAxiosConfig();
|
||||
await loadInitialData();
|
||||
});
|
||||
|
||||
// axios 설정 대기 함수
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
// 최대 5초 대기
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
if (!axios.defaults.baseURL) {
|
||||
console.error('⚠️ Axios 설정 시간 초과');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 데이터 로드
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
@@ -28,7 +50,7 @@ async function loadInitialData() {
|
||||
// 설비 목록 로드
|
||||
async function loadEquipments() {
|
||||
try {
|
||||
const response = await axios.get('/api/equipments');
|
||||
const response = await axios.get('/equipments');
|
||||
if (response.data.success) {
|
||||
equipments = response.data.data;
|
||||
renderEquipmentList();
|
||||
@@ -42,7 +64,7 @@ async function loadEquipments() {
|
||||
// 작업장 목록 로드
|
||||
async function loadWorkplaces() {
|
||||
try {
|
||||
const response = await axios.get('/api/workplaces');
|
||||
const response = await axios.get('/workplaces');
|
||||
if (response.data.success) {
|
||||
workplaces = response.data.data;
|
||||
populateWorkplaceFilters();
|
||||
@@ -56,7 +78,7 @@ async function loadWorkplaces() {
|
||||
// 설비 유형 목록 로드
|
||||
async function loadEquipmentTypes() {
|
||||
try {
|
||||
const response = await axios.get('/api/equipments/types');
|
||||
const response = await axios.get('/equipments/types');
|
||||
if (response.data.success) {
|
||||
equipmentTypes = response.data.data;
|
||||
populateTypeFilter();
|
||||
@@ -220,7 +242,7 @@ function openEquipmentModal(equipmentId = null) {
|
||||
// 설비 데이터 로드 (수정용)
|
||||
async function loadEquipmentData(equipmentId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/equipments/${equipmentId}`);
|
||||
const response = await axios.get(`/equipments/${equipmentId}`);
|
||||
if (response.data.success) {
|
||||
const equipment = response.data.data;
|
||||
|
||||
@@ -281,10 +303,10 @@ async function saveEquipment() {
|
||||
let response;
|
||||
if (equipmentId) {
|
||||
// 수정
|
||||
response = await axios.put(`/api/equipments/${equipmentId}`, equipmentData);
|
||||
response = await axios.put(`/equipments/${equipmentId}`, equipmentData);
|
||||
} else {
|
||||
// 추가
|
||||
response = await axios.post('/api/equipments', equipmentData);
|
||||
response = await axios.post('/equipments', equipmentData);
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
@@ -318,7 +340,7 @@ async function deleteEquipment(equipmentId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.delete(`/api/equipments/${equipmentId}`);
|
||||
const response = await axios.delete(`/equipments/${equipmentId}`);
|
||||
if (response.data.success) {
|
||||
alert('설비가 삭제되었습니다.');
|
||||
await loadEquipments();
|
||||
|
||||
@@ -17,37 +17,95 @@ const ROLE_NAMES = {
|
||||
* 네비게이션 바 DOM을 사용자 정보와 역할에 맞게 수정하는 프로세서입니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
*/
|
||||
function processNavbarDom(doc) {
|
||||
async function processNavbarDom(doc) {
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return;
|
||||
|
||||
// 1. 역할 기반 메뉴 필터링
|
||||
filterMenuByRole(doc, currentUser.role);
|
||||
// 1. 역할 및 페이지 권한 기반 메뉴 필터링
|
||||
await filterMenuByPageAccess(doc, currentUser);
|
||||
|
||||
// 2. 사용자 정보 채우기
|
||||
populateUserInfo(doc, currentUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 메뉴 항목을 필터링합니다.
|
||||
* 사용자의 페이지 접근 권한에 따라 메뉴 항목을 필터링합니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {string} userRole - 현재 사용자의 역할
|
||||
* @param {object} currentUser - 현재 사용자 객체
|
||||
*/
|
||||
function filterMenuByRole(doc, userRole) {
|
||||
// 대소문자 구분 없이 처리
|
||||
const userRoleLower = (userRole || '').toLowerCase();
|
||||
async function filterMenuByPageAccess(doc, currentUser) {
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
|
||||
const selectors = [
|
||||
{ role: 'admin', selector: '.admin-only' },
|
||||
{ role: 'system', selector: '.system-only' },
|
||||
{ role: 'leader', selector: '.leader-only' },
|
||||
];
|
||||
// Admin은 모든 메뉴 표시
|
||||
if (userRole === 'admin' || userRole === 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
selectors.forEach(({ role, selector }) => {
|
||||
if (userRoleLower !== role && userRoleLower !== 'system') {
|
||||
doc.querySelectorAll(selector).forEach(el => el.remove());
|
||||
try {
|
||||
// 사용자의 페이지 접근 권한 조회
|
||||
const cached = localStorage.getItem('userPageAccess');
|
||||
let accessiblePages = null;
|
||||
|
||||
if (cached) {
|
||||
const cacheData = JSON.parse(cached);
|
||||
// 캐시가 5분 이내인 경우 사용
|
||||
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
||||
accessiblePages = cacheData.pages;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 캐시가 없으면 API 호출
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('페이지 권한 조회 실패:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
accessiblePages = data.data.pageAccess || [];
|
||||
|
||||
// 캐시 저장
|
||||
localStorage.setItem('userPageAccess', JSON.stringify({
|
||||
pages: accessiblePages,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
|
||||
// 접근 가능한 페이지 키 목록
|
||||
const accessiblePageKeys = accessiblePages
|
||||
.filter(p => p.can_access === 1)
|
||||
.map(p => p.page_key);
|
||||
|
||||
// 메뉴 항목에 data-page-key 속성이 있으면 해당 권한 체크
|
||||
const menuItems = doc.querySelectorAll('[data-page-key]');
|
||||
menuItems.forEach(item => {
|
||||
const pageKey = item.getAttribute('data-page-key');
|
||||
|
||||
// 대시보드와 프로필 페이지는 모든 사용자 접근 가능
|
||||
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한이 없으면 메뉴 항목 제거
|
||||
if (!accessiblePageKeys.includes(pageKey)) {
|
||||
item.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Admin 전용 메뉴는 무조건 제거
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.remove());
|
||||
|
||||
} catch (error) {
|
||||
console.error('메뉴 필터링 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,18 +34,18 @@ const elements = {
|
||||
userName: document.getElementById('userName'),
|
||||
userRole: document.getElementById('userRole'),
|
||||
userInitial: document.getElementById('userInitial'),
|
||||
selectedDate: document.getElementById('selectedDate'),
|
||||
refreshBtn: document.getElementById('refreshBtn'),
|
||||
selectedDate: document.getElementById('selectedDate'), // 작업장 현황으로 교체되어 없을 수 있음
|
||||
refreshBtn: document.getElementById('refreshBtn'), // 작업장 현황으로 교체되어 없을 수 있음
|
||||
logoutBtn: document.getElementById('logoutBtn'),
|
||||
|
||||
|
||||
// 요약 카드
|
||||
todayWorkers: document.getElementById('todayWorkers'),
|
||||
totalHours: document.getElementById('totalHours'),
|
||||
activeProjects: document.getElementById('activeProjects'),
|
||||
errorCount: document.getElementById('errorCount'),
|
||||
|
||||
|
||||
// 컨테이너
|
||||
workStatusContainer: document.getElementById('workStatusContainer'),
|
||||
workStatusContainer: document.getElementById('workStatusContainer'), // 작업장 현황으로 교체되어 없을 수 있음
|
||||
workersContainer: document.getElementById('workersContainer'),
|
||||
toastContainer: document.getElementById('toastContainer')
|
||||
};
|
||||
@@ -84,15 +84,19 @@ async function initializeDashboard() {
|
||||
// 시간 업데이트 시작
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// 날짜 설정
|
||||
elements.selectedDate.value = selectedDate;
|
||||
|
||||
|
||||
// 날짜 설정 (요소가 있을 때만)
|
||||
if (elements.selectedDate) {
|
||||
elements.selectedDate.value = selectedDate;
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 데이터 로드
|
||||
await loadDashboardData();
|
||||
|
||||
// 데이터 로드 (작업 현황 컨테이너가 있을 때만)
|
||||
if (elements.workStatusContainer) {
|
||||
await loadDashboardData();
|
||||
}
|
||||
|
||||
// 관리자 권한 확인
|
||||
checkAdminAccess();
|
||||
@@ -154,18 +158,22 @@ function updateCurrentTime() {
|
||||
|
||||
// ========== 이벤트 리스너 ========== //
|
||||
function setupEventListeners() {
|
||||
// 날짜 변경
|
||||
elements.selectedDate.addEventListener('change', (e) => {
|
||||
selectedDate = e.target.value;
|
||||
loadDashboardData();
|
||||
});
|
||||
|
||||
// 새로고침 버튼
|
||||
elements.refreshBtn.addEventListener('click', () => {
|
||||
loadDashboardData();
|
||||
showToast('데이터를 새로고침했습니다.', 'success');
|
||||
});
|
||||
|
||||
// 날짜 변경 (요소가 있을 때만)
|
||||
if (elements.selectedDate) {
|
||||
elements.selectedDate.addEventListener('change', (e) => {
|
||||
selectedDate = e.target.value;
|
||||
loadDashboardData();
|
||||
});
|
||||
}
|
||||
|
||||
// 새로고침 버튼 (요소가 있을 때만)
|
||||
if (elements.refreshBtn) {
|
||||
elements.refreshBtn.addEventListener('click', () => {
|
||||
loadDashboardData();
|
||||
showToast('데이터를 새로고침했습니다.', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// 로그아웃 버튼 (navbar 컴포넌트가 이미 처리하므로 버튼이 있을 때만)
|
||||
if (elements.logoutBtn) {
|
||||
elements.logoutBtn.addEventListener('click', () => {
|
||||
@@ -747,19 +755,31 @@ async function checkTbmPageAccess() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🛠️ TBM 페이지 권한 확인 중...');
|
||||
const tbmQuickAction = document.getElementById('tbmQuickAction');
|
||||
if (!tbmQuickAction) {
|
||||
console.log('⚠️ TBM 빠른 작업 버튼 요소를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자의 페이지 접근 권한 조회
|
||||
console.log('🛠️ TBM 페이지 권한 확인 중...', { role: currentUser.role, access_level: currentUser.access_level });
|
||||
|
||||
// Admin은 모든 페이지 접근 가능
|
||||
if (currentUser.role === 'Admin' || currentUser.role === 'System Admin' || currentUser.access_level === 'admin' || currentUser.access_level === 'system') {
|
||||
tbmQuickAction.style.display = 'block';
|
||||
console.log('✅ Admin 사용자 - TBM 빠른 작업 버튼 표시');
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 사용자는 페이지 접근 권한 조회
|
||||
const response = await window.apiCall(`/users/${currentUser.user_id}/page-access`);
|
||||
|
||||
if (response && response.success) {
|
||||
const pageAccess = response.data?.pageAccess || [];
|
||||
|
||||
// 'tbm' 페이지 접근 권한 확인
|
||||
const tbmPage = pageAccess.find(p => p.page_key === 'tbm');
|
||||
const tbmQuickAction = document.getElementById('tbmQuickAction');
|
||||
// 'work.tbm' 페이지 접근 권한 확인 (마이그레이션에서 work.tbm으로 등록함)
|
||||
const tbmPage = pageAccess.find(p => p.page_key === 'work.tbm');
|
||||
|
||||
if (tbmPage && tbmPage.can_access && tbmQuickAction) {
|
||||
if (tbmPage && tbmPage.can_access) {
|
||||
tbmQuickAction.style.display = 'block';
|
||||
console.log('✅ TBM 페이지 접근 권한 있음 - 빠른 작업 버튼 표시');
|
||||
} else {
|
||||
|
||||
447
web-ui/js/safety-management.js
Normal file
447
web-ui/js/safety-management.js
Normal file
@@ -0,0 +1,447 @@
|
||||
// 안전관리 대시보드 JavaScript
|
||||
|
||||
let currentStatus = 'pending';
|
||||
let requests = [];
|
||||
let currentRejectRequestId = null;
|
||||
|
||||
// ==================== Toast 알림 ====================
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const iconMap = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${iconMap[type] || 'ℹ️'}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toastContainer';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
if (!document.getElementById('toastStyles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'toastStyles';
|
||||
style.textContent = `
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toast-success { border-left: 4px solid #10b981; }
|
||||
.toast-error { border-left: 4px solid #ef4444; }
|
||||
.toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.toast-info { border-left: 4px solid #3b82f6; }
|
||||
.toast-icon { font-size: 20px; }
|
||||
.toast-message { font-size: 14px; color: #374151; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadRequests();
|
||||
updateStats();
|
||||
});
|
||||
|
||||
// ==================== 데이터 로드 ====================
|
||||
|
||||
/**
|
||||
* 출입 신청 목록 로드
|
||||
*/
|
||||
async function loadRequests() {
|
||||
try {
|
||||
const filters = currentStatus === 'all' ? {} : { status: currentStatus };
|
||||
const queryString = new URLSearchParams(filters).toString();
|
||||
|
||||
const response = await window.apiCall(`/workplace-visits/requests?${queryString}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
requests = response.data || [];
|
||||
renderRequestTable();
|
||||
updateStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('출입 신청 목록 로드 오류:', error);
|
||||
showToast('출입 신청 목록을 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 업데이트
|
||||
*/
|
||||
async function updateStats() {
|
||||
try {
|
||||
const response = await window.apiCall('/workplace-visits/requests', 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const allRequests = response.data || [];
|
||||
|
||||
const stats = {
|
||||
pending: allRequests.filter(r => r.status === 'pending').length,
|
||||
approved: allRequests.filter(r => r.status === 'approved').length,
|
||||
training_completed: allRequests.filter(r => r.status === 'training_completed').length,
|
||||
rejected: allRequests.filter(r => r.status === 'rejected').length
|
||||
};
|
||||
|
||||
document.getElementById('statPending').textContent = stats.pending;
|
||||
document.getElementById('statApproved').textContent = stats.approved;
|
||||
document.getElementById('statTrainingCompleted').textContent = stats.training_completed;
|
||||
document.getElementById('statRejected').textContent = stats.rejected;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('통계 업데이트 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 렌더링
|
||||
*/
|
||||
function renderRequestTable() {
|
||||
const container = document.getElementById('requestTableContainer');
|
||||
|
||||
if (requests.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">📭</div>
|
||||
<h3>출입 신청이 없습니다</h3>
|
||||
<p>현재 ${getStatusText(currentStatus)} 상태의 신청이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<table class="request-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일</th>
|
||||
<th>신청자</th>
|
||||
<th>방문자</th>
|
||||
<th>인원</th>
|
||||
<th>방문 작업장</th>
|
||||
<th>방문 일시</th>
|
||||
<th>목적</th>
|
||||
<th>상태</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
requests.forEach(req => {
|
||||
const statusText = {
|
||||
'pending': '승인 대기',
|
||||
'approved': '승인됨',
|
||||
'rejected': '반려됨',
|
||||
'training_completed': '교육 완료'
|
||||
}[req.status] || req.status;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>${new Date(req.created_at).toLocaleDateString()}</td>
|
||||
<td>${req.requester_full_name || req.requester_name}</td>
|
||||
<td>${req.visitor_company}</td>
|
||||
<td>${req.visitor_count}명</td>
|
||||
<td>${req.category_name} - ${req.workplace_name}</td>
|
||||
<td>${req.visit_date} ${req.visit_time}</td>
|
||||
<td>${req.purpose_name}</td>
|
||||
<td><span class="status-badge ${req.status}">${statusText}</span></td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-sm btn-secondary" onclick="viewDetail(${req.request_id})">상세</button>
|
||||
${req.status === 'pending' ? `
|
||||
<button class="btn btn-sm btn-primary" onclick="approveRequest(${req.request_id})">승인</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="openRejectModal(${req.request_id})">반려</button>
|
||||
` : ''}
|
||||
${req.status === 'approved' ? `
|
||||
<button class="btn btn-sm btn-primary" onclick="startTraining(${req.request_id})">교육 진행</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 텍스트 변환
|
||||
*/
|
||||
function getStatusText(status) {
|
||||
const map = {
|
||||
'pending': '승인 대기',
|
||||
'approved': '승인 완료',
|
||||
'rejected': '반려',
|
||||
'training_completed': '교육 완료',
|
||||
'all': '전체'
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
// ==================== 탭 전환 ====================
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
async function switchTab(status) {
|
||||
currentStatus = status;
|
||||
|
||||
// 탭 활성화 상태 변경
|
||||
document.querySelectorAll('.status-tab').forEach(tab => {
|
||||
if (tab.dataset.status === status) {
|
||||
tab.classList.add('active');
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
await loadRequests();
|
||||
}
|
||||
|
||||
// ==================== 상세보기 ====================
|
||||
|
||||
/**
|
||||
* 상세보기 모달 열기
|
||||
*/
|
||||
async function viewDetail(requestId) {
|
||||
try {
|
||||
const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const req = response.data;
|
||||
const statusText = {
|
||||
'pending': '승인 대기',
|
||||
'approved': '승인됨',
|
||||
'rejected': '반려됨',
|
||||
'training_completed': '교육 완료'
|
||||
}[req.status] || req.status;
|
||||
|
||||
let html = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-label">신청 번호</div>
|
||||
<div class="detail-value">#${req.request_id}</div>
|
||||
|
||||
<div class="detail-label">신청일</div>
|
||||
<div class="detail-value">${new Date(req.created_at).toLocaleString()}</div>
|
||||
|
||||
<div class="detail-label">신청자</div>
|
||||
<div class="detail-value">${req.requester_full_name || req.requester_name}</div>
|
||||
|
||||
<div class="detail-label">방문자 소속</div>
|
||||
<div class="detail-value">${req.visitor_company}</div>
|
||||
|
||||
<div class="detail-label">방문 인원</div>
|
||||
<div class="detail-value">${req.visitor_count}명</div>
|
||||
|
||||
<div class="detail-label">방문 구역</div>
|
||||
<div class="detail-value">${req.category_name}</div>
|
||||
|
||||
<div class="detail-label">방문 작업장</div>
|
||||
<div class="detail-value">${req.workplace_name}</div>
|
||||
|
||||
<div class="detail-label">방문 날짜</div>
|
||||
<div class="detail-value">${req.visit_date}</div>
|
||||
|
||||
<div class="detail-label">방문 시간</div>
|
||||
<div class="detail-value">${req.visit_time}</div>
|
||||
|
||||
<div class="detail-label">방문 목적</div>
|
||||
<div class="detail-value">${req.purpose_name}</div>
|
||||
|
||||
<div class="detail-label">상태</div>
|
||||
<div class="detail-value"><span class="status-badge ${req.status}">${statusText}</span></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (req.notes) {
|
||||
html += `
|
||||
<div style="margin-top: 16px; padding: 12px; background: var(--gray-50); border-radius: var(--radius-md);">
|
||||
<strong>비고:</strong><br>
|
||||
${req.notes}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (req.rejection_reason) {
|
||||
html += `
|
||||
<div style="margin-top: 16px; padding: 12px; background: var(--red-50); border-radius: var(--radius-md); color: var(--red-700);">
|
||||
<strong>반려 사유:</strong><br>
|
||||
${req.rejection_reason}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (req.approved_by) {
|
||||
html += `
|
||||
<div style="margin-top: 16px; padding: 12px; background: var(--blue-50); border-radius: var(--radius-md);">
|
||||
<strong>처리 정보:</strong><br>
|
||||
처리자: ${req.approver_name || 'Unknown'}<br>
|
||||
처리 시간: ${new Date(req.approved_at).toLocaleString()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
document.getElementById('detailModal').style.display = 'flex';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상세 정보 로드 오류:', error);
|
||||
showToast('상세 정보를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세보기 모달 닫기
|
||||
*/
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== 승인/반려 ====================
|
||||
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
async function approveRequest(requestId) {
|
||||
if (!confirm('이 출입 신청을 승인하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/workplace-visits/requests/${requestId}/approve`, 'PUT');
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('출입 신청이 승인되었습니다.', 'success');
|
||||
await loadRequests();
|
||||
updateStats();
|
||||
} else {
|
||||
throw new Error(response?.message || '승인 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 처리 오류:', error);
|
||||
showToast(error.message || '승인 처리 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 모달 열기
|
||||
*/
|
||||
function openRejectModal(requestId) {
|
||||
currentRejectRequestId = requestId;
|
||||
document.getElementById('rejectionReason').value = '';
|
||||
document.getElementById('rejectModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 모달 닫기
|
||||
*/
|
||||
function closeRejectModal() {
|
||||
currentRejectRequestId = null;
|
||||
document.getElementById('rejectModal').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 확정
|
||||
*/
|
||||
async function confirmReject() {
|
||||
const reason = document.getElementById('rejectionReason').value.trim();
|
||||
|
||||
if (!reason) {
|
||||
showToast('반려 사유를 입력해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(
|
||||
`/workplace-visits/requests/${currentRejectRequestId}/reject`,
|
||||
'PUT',
|
||||
{ rejection_reason: reason }
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('출입 신청이 반려되었습니다.', 'success');
|
||||
closeRejectModal();
|
||||
await loadRequests();
|
||||
updateStats();
|
||||
} else {
|
||||
throw new Error(response?.message || '반려 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('반려 처리 오류:', error);
|
||||
showToast(error.message || '반려 처리 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 안전교육 진행 ====================
|
||||
|
||||
/**
|
||||
* 안전교육 진행 페이지로 이동
|
||||
*/
|
||||
function startTraining(requestId) {
|
||||
window.location.href = `/pages/admin/safety-training-conduct.html?request_id=${requestId}`;
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.showToast = showToast;
|
||||
window.switchTab = switchTab;
|
||||
window.viewDetail = viewDetail;
|
||||
window.closeDetailModal = closeDetailModal;
|
||||
window.approveRequest = approveRequest;
|
||||
window.openRejectModal = openRejectModal;
|
||||
window.closeRejectModal = closeRejectModal;
|
||||
window.confirmReject = confirmReject;
|
||||
window.startTraining = startTraining;
|
||||
553
web-ui/js/safety-training-conduct.js
Normal file
553
web-ui/js/safety-training-conduct.js
Normal file
@@ -0,0 +1,553 @@
|
||||
// 안전교육 진행 페이지 JavaScript
|
||||
|
||||
let requestId = null;
|
||||
let requestData = null;
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let isDrawing = false;
|
||||
let hasSignature = false;
|
||||
let savedSignatures = []; // 저장된 서명 목록
|
||||
|
||||
// ==================== Toast 알림 ====================
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const iconMap = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${iconMap[type] || 'ℹ️'}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toastContainer';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
if (!document.getElementById('toastStyles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'toastStyles';
|
||||
style.textContent = `
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toast-success { border-left: 4px solid #10b981; }
|
||||
.toast-error { border-left: 4px solid #ef4444; }
|
||||
.toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.toast-info { border-left: 4px solid #3b82f6; }
|
||||
.toast-icon { font-size: 20px; }
|
||||
.toast-message { font-size: 14px; color: #374151; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// URL 파라미터에서 request_id 가져오기
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
requestId = urlParams.get('request_id');
|
||||
|
||||
if (!requestId) {
|
||||
showToast('출입 신청 ID가 없습니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 서명 캔버스 초기화
|
||||
initSignatureCanvas();
|
||||
|
||||
// 현재 날짜 표시
|
||||
const today = new Date().toLocaleDateString('ko-KR');
|
||||
document.getElementById('signatureDate').textContent = today;
|
||||
|
||||
// 출입 신청 정보 로드
|
||||
await loadRequestInfo();
|
||||
});
|
||||
|
||||
// ==================== 출입 신청 정보 로드 ====================
|
||||
|
||||
/**
|
||||
* 출입 신청 정보 로드
|
||||
*/
|
||||
async function loadRequestInfo() {
|
||||
try {
|
||||
const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
requestData = response.data;
|
||||
|
||||
// 상태 확인 - 승인됨 상태만 진행 가능
|
||||
if (requestData.status !== 'approved') {
|
||||
showToast('이미 처리되었거나 승인되지 않은 신청입니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
renderRequestInfo();
|
||||
} else {
|
||||
throw new Error(response?.message || '정보를 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('출입 신청 정보 로드 오류:', error);
|
||||
showToast('출입 신청 정보를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 출입 신청 정보 렌더링
|
||||
*/
|
||||
function renderRequestInfo() {
|
||||
const container = document.getElementById('requestInfo');
|
||||
|
||||
// 날짜 포맷 변환
|
||||
const visitDate = new Date(requestData.visit_date);
|
||||
const formattedDate = visitDate.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
});
|
||||
|
||||
const html = `
|
||||
<div class="info-item">
|
||||
<div class="info-label">신청 번호</div>
|
||||
<div class="info-value">#${requestData.request_id}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">신청자</div>
|
||||
<div class="info-value">${requestData.requester_full_name || requestData.requester_name}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문자 소속</div>
|
||||
<div class="info-value">${requestData.visitor_company}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문 인원</div>
|
||||
<div class="info-value">${requestData.visitor_count}명</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문 작업장</div>
|
||||
<div class="info-value">${requestData.category_name} - ${requestData.workplace_name}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문 일시</div>
|
||||
<div class="info-value">${formattedDate} ${requestData.visit_time}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문 목적</div>
|
||||
<div class="info-value">${requestData.purpose_name}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ==================== 서명 캔버스 ====================
|
||||
|
||||
/**
|
||||
* 서명 캔버스 초기화
|
||||
*/
|
||||
function initSignatureCanvas() {
|
||||
canvas = document.getElementById('signatureCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// 캔버스 크기 설정
|
||||
const container = canvas.parentElement;
|
||||
canvas.width = container.clientWidth - 4; // border 제외
|
||||
canvas.height = 300;
|
||||
|
||||
// 그리기 설정
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// 마우스 이벤트
|
||||
canvas.addEventListener('mousedown', startDrawing);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDrawing);
|
||||
canvas.addEventListener('mouseout', stopDrawing);
|
||||
|
||||
// 터치 이벤트 (모바일, Apple Pencil)
|
||||
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
canvas.addEventListener('touchend', stopDrawing);
|
||||
canvas.addEventListener('touchcancel', stopDrawing);
|
||||
|
||||
// Pointer Events (Apple Pencil 최적화)
|
||||
if (window.PointerEvent) {
|
||||
canvas.addEventListener('pointerdown', handlePointerDown);
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerup', stopDrawing);
|
||||
canvas.addEventListener('pointercancel', stopDrawing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 시작 (마우스)
|
||||
*/
|
||||
function startDrawing(e) {
|
||||
isDrawing = true;
|
||||
hasSignature = true;
|
||||
document.getElementById('signaturePlaceholder').style.display = 'none';
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 (마우스)
|
||||
*/
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 중지
|
||||
*/
|
||||
function stopDrawing() {
|
||||
isDrawing = false;
|
||||
ctx.beginPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 터치 시작 처리
|
||||
*/
|
||||
function handleTouchStart(e) {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
hasSignature = true;
|
||||
document.getElementById('signaturePlaceholder').style.display = 'none';
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(touch.clientX - rect.left, touch.clientY - rect.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* 터치 이동 처리
|
||||
*/
|
||||
function handleTouchMove(e) {
|
||||
if (!isDrawing) return;
|
||||
e.preventDefault();
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.lineTo(touch.clientX - rect.left, touch.clientY - rect.top);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer 시작 처리 (Apple Pencil)
|
||||
*/
|
||||
function handlePointerDown(e) {
|
||||
isDrawing = true;
|
||||
hasSignature = true;
|
||||
document.getElementById('signaturePlaceholder').style.display = 'none';
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer 이동 처리 (Apple Pencil)
|
||||
*/
|
||||
function handlePointerMove(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 지우기
|
||||
*/
|
||||
function clearSignature() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
hasSignature = false;
|
||||
document.getElementById('signaturePlaceholder').style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 Base64로 변환
|
||||
*/
|
||||
function getSignatureBase64() {
|
||||
if (!hasSignature) {
|
||||
return null;
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 서명 저장
|
||||
*/
|
||||
function saveSignature() {
|
||||
if (!hasSignature) {
|
||||
showToast('서명이 없습니다. 이름과 서명을 작성해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const signatureImage = getSignatureBase64();
|
||||
const now = new Date();
|
||||
|
||||
savedSignatures.push({
|
||||
id: Date.now(),
|
||||
image: signatureImage,
|
||||
timestamp: now.toLocaleString('ko-KR')
|
||||
});
|
||||
|
||||
// 서명 카운트 업데이트
|
||||
document.getElementById('signatureCount').textContent = savedSignatures.length;
|
||||
|
||||
// 캔버스 초기화
|
||||
clearSignature();
|
||||
|
||||
// 저장된 서명 목록 렌더링
|
||||
renderSavedSignatures();
|
||||
|
||||
// 교육 완료 버튼 활성화
|
||||
updateCompleteButton();
|
||||
|
||||
showToast('서명이 저장되었습니다.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 서명 목록 렌더링
|
||||
*/
|
||||
function renderSavedSignatures() {
|
||||
const container = document.getElementById('savedSignatures');
|
||||
|
||||
if (savedSignatures.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--gray-700);">저장된 서명 목록</h3>';
|
||||
|
||||
savedSignatures.forEach((sig, index) => {
|
||||
html += `
|
||||
<div class="saved-signature-card">
|
||||
<img src="${sig.image}" alt="서명 ${index + 1}">
|
||||
<div class="saved-signature-info">
|
||||
<div class="saved-signature-number">방문자 ${index + 1}</div>
|
||||
<div class="saved-signature-date">저장 시간: ${sig.timestamp}</div>
|
||||
</div>
|
||||
<div class="saved-signature-actions">
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteSignature(${sig.id})">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 삭제
|
||||
*/
|
||||
function deleteSignature(signatureId) {
|
||||
if (!confirm('이 서명을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
savedSignatures = savedSignatures.filter(sig => sig.id !== signatureId);
|
||||
|
||||
// 서명 카운트 업데이트
|
||||
document.getElementById('signatureCount').textContent = savedSignatures.length;
|
||||
|
||||
// 목록 다시 렌더링
|
||||
renderSavedSignatures();
|
||||
|
||||
// 교육 완료 버튼 상태 업데이트
|
||||
updateCompleteButton();
|
||||
|
||||
showToast('서명이 삭제되었습니다.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 교육 완료 버튼 활성화/비활성화
|
||||
*/
|
||||
function updateCompleteButton() {
|
||||
const completeBtn = document.getElementById('completeBtn');
|
||||
|
||||
// 체크리스트와 서명이 모두 있어야 활성화
|
||||
const checkboxes = document.querySelectorAll('input[name="safety-check"]');
|
||||
const checkedItems = Array.from(checkboxes).filter(cb => cb.checked);
|
||||
const allChecked = checkedItems.length === checkboxes.length;
|
||||
const hasSignatures = savedSignatures.length > 0;
|
||||
|
||||
completeBtn.disabled = !(allChecked && hasSignatures);
|
||||
}
|
||||
|
||||
// ==================== 교육 완료 처리 ====================
|
||||
|
||||
/**
|
||||
* 교육 완료 처리
|
||||
*/
|
||||
async function completeTraining() {
|
||||
// 체크리스트 검증
|
||||
const checkboxes = document.querySelectorAll('input[name="safety-check"]');
|
||||
const checkedItems = Array.from(checkboxes).filter(cb => cb.checked);
|
||||
|
||||
if (checkedItems.length !== checkboxes.length) {
|
||||
showToast('모든 안전교육 항목을 체크해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 서명 검증
|
||||
if (savedSignatures.length === 0) {
|
||||
showToast('최소 1명 이상의 서명이 필요합니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 확인
|
||||
if (!confirm(`${savedSignatures.length}명의 방문자 안전교육을 완료하시겠습니까?\n완료 후에는 수정할 수 없습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 교육 항목 수집
|
||||
const trainingItems = checkedItems.map(cb => cb.value).join(', ');
|
||||
|
||||
// API 호출
|
||||
const userData = localStorage.getItem('user');
|
||||
const currentUser = userData ? JSON.parse(userData) : null;
|
||||
|
||||
if (!currentUser) {
|
||||
showToast('로그인 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 시간
|
||||
const now = new Date();
|
||||
const currentTime = now.toTimeString().split(' ')[0]; // HH:MM:SS
|
||||
const trainingDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
|
||||
// 각 서명에 대해 개별적으로 API 호출
|
||||
let successCount = 0;
|
||||
for (let i = 0; i < savedSignatures.length; i++) {
|
||||
const sig = savedSignatures[i];
|
||||
|
||||
const payload = {
|
||||
request_id: requestId,
|
||||
conducted_by: currentUser.user_id,
|
||||
training_date: trainingDate,
|
||||
training_start_time: currentTime,
|
||||
training_end_time: currentTime,
|
||||
training_items: trainingItems,
|
||||
visitor_name: `방문자 ${i + 1}`, // 순번으로 구분
|
||||
signature_image: sig.image,
|
||||
notes: `교육 완료 - ${checkedItems.length}개 항목 (${i + 1}/${savedSignatures.length})`
|
||||
};
|
||||
|
||||
const response = await window.apiCall(
|
||||
'/workplace-visits/training',
|
||||
'POST',
|
||||
payload
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error(`서명 ${i + 1} 저장 실패:`, response);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount === savedSignatures.length) {
|
||||
showToast(`${successCount}명의 안전교육이 완료되었습니다.`, 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
}, 1500);
|
||||
} else if (successCount > 0) {
|
||||
showToast(`${successCount}/${savedSignatures.length}명의 교육만 저장되었습니다.`, 'warning');
|
||||
} else {
|
||||
throw new Error('교육 완료 처리 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('교육 완료 처리 오류:', error);
|
||||
showToast(error.message || '교육 완료 처리 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 뒤로 가기
|
||||
*/
|
||||
function goBack() {
|
||||
if (hasSignature || document.querySelector('input[name="safety-check"]:checked')) {
|
||||
if (!confirm('작성 중인 내용이 있습니다. 정말 나가시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.showToast = showToast;
|
||||
window.clearSignature = clearSignature;
|
||||
window.saveSignature = saveSignature;
|
||||
window.deleteSignature = deleteSignature;
|
||||
window.updateCompleteButton = updateCompleteButton;
|
||||
window.completeTraining = completeTraining;
|
||||
window.goBack = goBack;
|
||||
1407
web-ui/js/tbm.js
1407
web-ui/js/tbm.js
File diff suppressed because it is too large
Load Diff
864
web-ui/js/vacation-allocation.js
Normal file
864
web-ui/js/vacation-allocation.js
Normal file
@@ -0,0 +1,864 @@
|
||||
/**
|
||||
* vacation-allocation.js
|
||||
* 휴가 발생 입력 페이지 로직
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from './api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let vacationTypes = [];
|
||||
let currentWorkerBalances = [];
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 관리자 권한 체크
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
console.log('Current user:', user);
|
||||
console.log('Role ID:', user.role_id, 'Role:', user.role);
|
||||
|
||||
// role이 'Admin'이거나 role_id가 1 또는 2인 경우 허용
|
||||
const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id);
|
||||
|
||||
if (!isAdmin) {
|
||||
console.error('Access denied. User:', user);
|
||||
alert('관리자만 접근할 수 있습니다');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadInitialData();
|
||||
initializeYearSelectors();
|
||||
initializeTabNavigation();
|
||||
initializeEventListeners();
|
||||
});
|
||||
|
||||
/**
|
||||
* 초기 데이터 로드
|
||||
*/
|
||||
async function loadInitialData() {
|
||||
await Promise.all([
|
||||
loadWorkers(),
|
||||
loadVacationTypes()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자 목록 로드
|
||||
*/
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
console.log('Loading workers... Token:', token ? 'exists' : 'missing');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/workers`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Workers API Response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Workers API Error:', errorData);
|
||||
throw new Error(errorData.message || '작업자 목록 로드 실패');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Workers data:', result);
|
||||
workers = result.data || [];
|
||||
|
||||
if (workers.length === 0) {
|
||||
console.warn('No workers found in database');
|
||||
showToast('등록된 작업자가 없습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 개별 입력 탭 - 작업자 셀렉트 박스
|
||||
const selectWorker = document.getElementById('individualWorker');
|
||||
workers.forEach(worker => {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.worker_id;
|
||||
option.textContent = `${worker.worker_name} (${worker.employment_status === 'employed' ? '재직' : '퇴사'})`;
|
||||
selectWorker.appendChild(option);
|
||||
});
|
||||
console.log(`Loaded ${workers.length} workers successfully`);
|
||||
} catch (error) {
|
||||
console.error('작업자 로드 오류:', error);
|
||||
showToast(`작업자 목록을 불러오는데 실패했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 유형 목록 로드
|
||||
*/
|
||||
async function loadVacationTypes() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-types`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('휴가 유형 로드 실패');
|
||||
|
||||
const result = await response.json();
|
||||
vacationTypes = result.data || [];
|
||||
|
||||
// 개별 입력 탭 - 휴가 유형 셀렉트 박스
|
||||
const selectType = document.getElementById('individualVacationType');
|
||||
vacationTypes.forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type.id;
|
||||
option.textContent = `${type.type_name} ${type.is_special ? '(특별)' : ''}`;
|
||||
selectType.appendChild(option);
|
||||
});
|
||||
|
||||
// 특별 휴가 관리 탭 테이블 로드
|
||||
loadSpecialTypesTable();
|
||||
} catch (error) {
|
||||
console.error('휴가 유형 로드 오류:', error);
|
||||
showToast('휴가 유형을 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도 셀렉터 초기화
|
||||
*/
|
||||
function initializeYearSelectors() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearSelectors = ['individualYear', 'bulkYear'];
|
||||
|
||||
yearSelectors.forEach(selectorId => {
|
||||
const select = document.getElementById(selectorId);
|
||||
for (let year = currentYear - 1; year <= currentYear + 2; year++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}년`;
|
||||
if (year === currentYear) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 네비게이션 초기화
|
||||
*/
|
||||
function initializeTabNavigation() {
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const tabName = button.dataset.tab;
|
||||
switchTab(tabName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// 탭 버튼 활성화
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
||||
|
||||
// 탭 콘텐츠 표시
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 초기화
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// === 탭 1: 개별 입력 ===
|
||||
document.getElementById('individualWorker').addEventListener('change', loadWorkerBalances);
|
||||
document.getElementById('autoCalculateBtn').addEventListener('click', autoCalculateAnnualLeave);
|
||||
document.getElementById('individualSubmitBtn').addEventListener('click', submitIndividualVacation);
|
||||
document.getElementById('individualResetBtn').addEventListener('click', resetIndividualForm);
|
||||
|
||||
// === 탭 2: 일괄 입력 ===
|
||||
document.getElementById('bulkPreviewBtn').addEventListener('click', previewBulkAllocation);
|
||||
document.getElementById('bulkSubmitBtn').addEventListener('click', submitBulkAllocation);
|
||||
|
||||
// === 탭 3: 특별 휴가 관리 ===
|
||||
document.getElementById('addSpecialTypeBtn').addEventListener('click', () => openVacationTypeModal());
|
||||
|
||||
// 모달 닫기
|
||||
document.querySelectorAll('.modal-close').forEach(btn => {
|
||||
btn.addEventListener('click', closeModals);
|
||||
});
|
||||
|
||||
// 모달 폼 제출
|
||||
document.getElementById('vacationTypeForm').addEventListener('submit', submitVacationType);
|
||||
document.getElementById('editBalanceForm').addEventListener('submit', submitEditBalance);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 탭 1: 개별 입력
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 작업자의 기존 휴가 잔액 로드
|
||||
*/
|
||||
async function loadWorkerBalances() {
|
||||
const workerId = document.getElementById('individualWorker').value;
|
||||
const year = document.getElementById('individualYear').value;
|
||||
|
||||
if (!workerId) {
|
||||
document.getElementById('individualTableBody').innerHTML = `
|
||||
<tr><td colspan="8" class="loading-state"><p>작업자를 선택하세요</p></td></tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/worker/${workerId}/year/${year}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('휴가 잔액 로드 실패');
|
||||
|
||||
const result = await response.json();
|
||||
currentWorkerBalances = result.data || [];
|
||||
|
||||
updateWorkerBalancesTable();
|
||||
} catch (error) {
|
||||
console.error('휴가 잔액 로드 오류:', error);
|
||||
showToast('휴가 잔액을 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자 휴가 잔액 테이블 업데이트
|
||||
*/
|
||||
function updateWorkerBalancesTable() {
|
||||
const tbody = document.getElementById('individualTableBody');
|
||||
|
||||
if (currentWorkerBalances.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="8" class="loading-state"><p>등록된 휴가가 없습니다</p></td></tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = currentWorkerBalances.map(balance => `
|
||||
<tr>
|
||||
<td>${balance.worker_name || '-'}</td>
|
||||
<td>${balance.year}</td>
|
||||
<td>${balance.type_name} ${balance.is_special ? '<span class="badge badge-info">특별</span>' : ''}</td>
|
||||
<td>${balance.total_days}일</td>
|
||||
<td>${balance.used_days}일</td>
|
||||
<td>${balance.remaining_days}일</td>
|
||||
<td>${balance.notes || '-'}</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn btn-sm btn-secondary btn-icon" onclick="window.editBalance(${balance.id})">✏️</button>
|
||||
<button class="btn btn-sm btn-danger btn-icon" onclick="window.deleteBalance(${balance.id})">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 계산 (연차만 해당)
|
||||
*/
|
||||
async function autoCalculateAnnualLeave() {
|
||||
const workerId = document.getElementById('individualWorker').value;
|
||||
const year = document.getElementById('individualYear').value;
|
||||
const typeId = document.getElementById('individualVacationType').value;
|
||||
|
||||
if (!workerId) {
|
||||
showToast('작업자를 선택하세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 선택한 휴가 유형이 ANNUAL인지 확인
|
||||
const selectedType = vacationTypes.find(t => t.id == typeId);
|
||||
if (!selectedType || selectedType.type_code !== 'ANNUAL') {
|
||||
showToast('연차(ANNUAL) 유형만 자동 계산이 가능합니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업자의 입사일 조회
|
||||
const worker = workers.find(w => w.worker_id == workerId);
|
||||
if (!worker || !worker.hire_date) {
|
||||
showToast('작업자의 입사일 정보가 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/auto-calculate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
worker_id: workerId,
|
||||
hire_date: worker.hire_date,
|
||||
year: year
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '자동 계산 실패');
|
||||
}
|
||||
|
||||
// 계산 결과 표시
|
||||
const resultDiv = document.getElementById('autoCalculateResult');
|
||||
resultDiv.innerHTML = `
|
||||
<strong>자동 계산 완료</strong><br>
|
||||
입사일: ${worker.hire_date}<br>
|
||||
계산된 연차: ${result.data.calculated_days}일<br>
|
||||
아래 "총 부여 일수"에 자동으로 입력됩니다.
|
||||
`;
|
||||
resultDiv.style.display = 'block';
|
||||
|
||||
// 폼에 자동 입력
|
||||
document.getElementById('individualTotalDays').value = result.data.calculated_days;
|
||||
document.getElementById('individualNotes').value = `근속년수 기반 자동 계산 (입사일: ${worker.hire_date})`;
|
||||
|
||||
showToast(result.message, 'success');
|
||||
|
||||
// 기존 데이터 새로고침
|
||||
await loadWorkerBalances();
|
||||
} catch (error) {
|
||||
console.error('자동 계산 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 휴가 제출
|
||||
*/
|
||||
async function submitIndividualVacation() {
|
||||
const workerId = document.getElementById('individualWorker').value;
|
||||
const year = document.getElementById('individualYear').value;
|
||||
const typeId = document.getElementById('individualVacationType').value;
|
||||
const totalDays = document.getElementById('individualTotalDays').value;
|
||||
const usedDays = document.getElementById('individualUsedDays').value || 0;
|
||||
const notes = document.getElementById('individualNotes').value;
|
||||
|
||||
if (!workerId || !year || !typeId || !totalDays) {
|
||||
showToast('필수 항목을 모두 입력하세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
worker_id: workerId,
|
||||
vacation_type_id: typeId,
|
||||
year: year,
|
||||
total_days: parseFloat(totalDays),
|
||||
used_days: parseFloat(usedDays),
|
||||
notes: notes
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '저장 실패');
|
||||
}
|
||||
|
||||
showToast('휴가가 등록되었습니다', 'success');
|
||||
resetIndividualForm();
|
||||
await loadWorkerBalances();
|
||||
} catch (error) {
|
||||
console.error('휴가 등록 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 입력 폼 초기화
|
||||
*/
|
||||
function resetIndividualForm() {
|
||||
document.getElementById('individualVacationType').value = '';
|
||||
document.getElementById('individualTotalDays').value = '';
|
||||
document.getElementById('individualUsedDays').value = '0';
|
||||
document.getElementById('individualNotes').value = '';
|
||||
document.getElementById('autoCalculateResult').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 수정 (전역 함수로 노출)
|
||||
*/
|
||||
window.editBalance = function(balanceId) {
|
||||
const balance = currentWorkerBalances.find(b => b.id === balanceId);
|
||||
if (!balance) return;
|
||||
|
||||
document.getElementById('editBalanceId').value = balance.id;
|
||||
document.getElementById('editTotalDays').value = balance.total_days;
|
||||
document.getElementById('editUsedDays').value = balance.used_days;
|
||||
document.getElementById('editNotes').value = balance.notes || '';
|
||||
|
||||
document.getElementById('editBalanceModal').classList.add('active');
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 삭제 (전역 함수로 노출)
|
||||
*/
|
||||
window.deleteBalance = async function(balanceId) {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/${balanceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '삭제 실패');
|
||||
}
|
||||
|
||||
showToast('삭제되었습니다', 'success');
|
||||
await loadWorkerBalances();
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 수정 제출
|
||||
*/
|
||||
async function submitEditBalance(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const balanceId = document.getElementById('editBalanceId').value;
|
||||
const totalDays = document.getElementById('editTotalDays').value;
|
||||
const usedDays = document.getElementById('editUsedDays').value;
|
||||
const notes = document.getElementById('editNotes').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/${balanceId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
total_days: parseFloat(totalDays),
|
||||
used_days: parseFloat(usedDays),
|
||||
notes: notes
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '수정 실패');
|
||||
}
|
||||
|
||||
showToast('수정되었습니다', 'success');
|
||||
closeModals();
|
||||
await loadWorkerBalances();
|
||||
} catch (error) {
|
||||
console.error('수정 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 탭 2: 일괄 입력
|
||||
// =============================================================================
|
||||
|
||||
let bulkPreviewData = [];
|
||||
|
||||
/**
|
||||
* 일괄 할당 미리보기
|
||||
*/
|
||||
async function previewBulkAllocation() {
|
||||
const year = document.getElementById('bulkYear').value;
|
||||
const employmentStatus = document.getElementById('bulkEmploymentStatus').value;
|
||||
|
||||
// 필터링된 작업자 목록
|
||||
let targetWorkers = workers;
|
||||
if (employmentStatus === 'employed') {
|
||||
targetWorkers = workers.filter(w => w.employment_status === 'employed');
|
||||
}
|
||||
|
||||
// ANNUAL 유형 찾기
|
||||
const annualType = vacationTypes.find(t => t.type_code === 'ANNUAL');
|
||||
if (!annualType) {
|
||||
showToast('ANNUAL 휴가 유형이 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 미리보기 데이터 생성
|
||||
bulkPreviewData = targetWorkers.map(worker => {
|
||||
const hireDate = worker.hire_date;
|
||||
if (!hireDate) {
|
||||
return {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
hire_date: '-',
|
||||
years_worked: '-',
|
||||
calculated_days: 0,
|
||||
reason: '입사일 정보 없음',
|
||||
status: 'error'
|
||||
};
|
||||
}
|
||||
|
||||
const calculatedDays = calculateAnnualLeaveDays(hireDate, year);
|
||||
const yearsWorked = calculateYearsWorked(hireDate, year);
|
||||
|
||||
return {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
hire_date: hireDate,
|
||||
years_worked: yearsWorked,
|
||||
calculated_days: calculatedDays,
|
||||
reason: getCalculationReason(yearsWorked, calculatedDays),
|
||||
status: 'ready'
|
||||
};
|
||||
});
|
||||
|
||||
updateBulkPreviewTable();
|
||||
document.getElementById('bulkPreviewSection').style.display = 'block';
|
||||
document.getElementById('bulkSubmitBtn').disabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연차 일수 계산 (한국 근로기준법)
|
||||
*/
|
||||
function calculateAnnualLeaveDays(hireDate, targetYear) {
|
||||
const hire = new Date(hireDate);
|
||||
const targetDate = new Date(targetYear, 0, 1);
|
||||
|
||||
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
|
||||
+ (targetDate.getMonth() - hire.getMonth());
|
||||
|
||||
// 1년 미만: 월 1일
|
||||
if (monthsDiff < 12) {
|
||||
return Math.floor(monthsDiff);
|
||||
}
|
||||
|
||||
// 1년 이상: 15일 기본 + 2년마다 1일 추가 (최대 25일)
|
||||
const yearsWorked = Math.floor(monthsDiff / 12);
|
||||
const additionalDays = Math.floor((yearsWorked - 1) / 2);
|
||||
|
||||
return Math.min(15 + additionalDays, 25);
|
||||
}
|
||||
|
||||
/**
|
||||
* 근속년수 계산
|
||||
*/
|
||||
function calculateYearsWorked(hireDate, targetYear) {
|
||||
const hire = new Date(hireDate);
|
||||
const targetDate = new Date(targetYear, 0, 1);
|
||||
|
||||
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
|
||||
+ (targetDate.getMonth() - hire.getMonth());
|
||||
|
||||
return (monthsDiff / 12).toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계산 근거 생성
|
||||
*/
|
||||
function getCalculationReason(yearsWorked, days) {
|
||||
const years = parseFloat(yearsWorked);
|
||||
if (years < 1) {
|
||||
return `입사 ${Math.floor(years * 12)}개월 (월 1일)`;
|
||||
}
|
||||
if (days === 25) {
|
||||
return '최대 25일 (근속 3년 이상)';
|
||||
}
|
||||
return `근속 ${Math.floor(years)}년 (15일 + ${days - 15}일)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 미리보기 테이블 업데이트
|
||||
*/
|
||||
function updateBulkPreviewTable() {
|
||||
const tbody = document.getElementById('bulkPreviewTableBody');
|
||||
|
||||
tbody.innerHTML = bulkPreviewData.map(item => {
|
||||
const statusBadge = item.status === 'error'
|
||||
? '<span class="badge badge-error">오류</span>'
|
||||
: '<span class="badge badge-success">준비</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${item.worker_name}</td>
|
||||
<td>${item.hire_date}</td>
|
||||
<td>${item.years_worked}년</td>
|
||||
<td>${item.calculated_days}일</td>
|
||||
<td>${item.reason}</td>
|
||||
<td>${statusBadge}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 할당 제출
|
||||
*/
|
||||
async function submitBulkAllocation() {
|
||||
const year = document.getElementById('bulkYear').value;
|
||||
|
||||
// 오류가 없는 항목만 필터링
|
||||
const validItems = bulkPreviewData.filter(item => item.status !== 'error' && item.calculated_days > 0);
|
||||
|
||||
if (validItems.length === 0) {
|
||||
showToast('생성할 항목이 없습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`${validItems.length}명의 연차를 생성하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ANNUAL 유형 찾기
|
||||
const annualType = vacationTypes.find(t => t.type_code === 'ANNUAL');
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const item of validItems) {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/auto-calculate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
worker_id: item.worker_id,
|
||||
hire_date: item.hire_date,
|
||||
year: year
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`완료: ${successCount}건 성공, ${failCount}건 실패`, successCount > 0 ? 'success' : 'error');
|
||||
|
||||
// 미리보기 초기화
|
||||
document.getElementById('bulkPreviewSection').style.display = 'none';
|
||||
document.getElementById('bulkSubmitBtn').disabled = true;
|
||||
bulkPreviewData = [];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 탭 3: 특별 휴가 관리
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 특별 휴가 유형 테이블 로드
|
||||
*/
|
||||
function loadSpecialTypesTable() {
|
||||
const tbody = document.getElementById('specialTypesTableBody');
|
||||
|
||||
if (vacationTypes.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="7" class="loading-state"><p>등록된 휴가 유형이 없습니다</p></td></tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = vacationTypes.map(type => `
|
||||
<tr>
|
||||
<td>${type.type_name}</td>
|
||||
<td>${type.type_code}</td>
|
||||
<td>${type.priority}</td>
|
||||
<td>${type.is_special ? '<span class="badge badge-info">특별</span>' : '-'}</td>
|
||||
<td>${type.is_system ? '<span class="badge badge-warning">시스템</span>' : '-'}</td>
|
||||
<td>${type.description || '-'}</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn btn-sm btn-secondary btn-icon" onclick="window.editVacationType(${type.id})" ${type.is_system ? 'disabled' : ''}>✏️</button>
|
||||
<button class="btn btn-sm btn-danger btn-icon" onclick="window.deleteVacationType(${type.id})" ${type.is_system ? 'disabled' : ''}>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 유형 모달 열기
|
||||
*/
|
||||
function openVacationTypeModal(typeId = null) {
|
||||
const modal = document.getElementById('vacationTypeModal');
|
||||
const form = document.getElementById('vacationTypeForm');
|
||||
form.reset();
|
||||
|
||||
if (typeId) {
|
||||
const type = vacationTypes.find(t => t.id === typeId);
|
||||
if (!type) return;
|
||||
|
||||
document.getElementById('modalTitle').textContent = '휴가 유형 수정';
|
||||
document.getElementById('modalTypeId').value = type.id;
|
||||
document.getElementById('modalTypeName').value = type.type_name;
|
||||
document.getElementById('modalTypeCode').value = type.type_code;
|
||||
document.getElementById('modalPriority').value = type.priority;
|
||||
document.getElementById('modalIsSpecial').checked = type.is_special === 1;
|
||||
document.getElementById('modalDescription').value = type.description || '';
|
||||
} else {
|
||||
document.getElementById('modalTitle').textContent = '휴가 유형 추가';
|
||||
document.getElementById('modalTypeId').value = '';
|
||||
}
|
||||
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 유형 수정 (전역 함수)
|
||||
*/
|
||||
window.editVacationType = function(typeId) {
|
||||
openVacationTypeModal(typeId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 유형 삭제 (전역 함수)
|
||||
*/
|
||||
window.deleteVacationType = async function(typeId) {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-types/${typeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '삭제 실패');
|
||||
}
|
||||
|
||||
showToast('삭제되었습니다', 'success');
|
||||
await loadVacationTypes();
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 유형 제출
|
||||
*/
|
||||
async function submitVacationType(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const typeId = document.getElementById('modalTypeId').value;
|
||||
const typeName = document.getElementById('modalTypeName').value;
|
||||
const typeCode = document.getElementById('modalTypeCode').value;
|
||||
const priority = document.getElementById('modalPriority').value;
|
||||
const isSpecial = document.getElementById('modalIsSpecial').checked ? 1 : 0;
|
||||
const description = document.getElementById('modalDescription').value;
|
||||
|
||||
const data = {
|
||||
type_name: typeName,
|
||||
type_code: typeCode.toUpperCase(),
|
||||
priority: parseInt(priority),
|
||||
is_special: isSpecial,
|
||||
description: description
|
||||
};
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const url = typeId
|
||||
? `${API_BASE_URL}/api/vacation-types/${typeId}`
|
||||
: `${API_BASE_URL}/api/vacation-types`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: typeId ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '저장 실패');
|
||||
}
|
||||
|
||||
showToast(typeId ? '수정되었습니다' : '추가되었습니다', 'success');
|
||||
closeModals();
|
||||
await loadVacationTypes();
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 공통 함수
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 모달 닫기
|
||||
*/
|
||||
function closeModals() {
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
container.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
234
web-ui/js/vacation-common.js
Normal file
234
web-ui/js/vacation-common.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 휴가 관리 공통 함수
|
||||
* 모든 휴가 관련 페이지에서 사용하는 공통 함수 모음
|
||||
*/
|
||||
|
||||
// 전역 변수
|
||||
window.VacationCommon = {
|
||||
workers: [],
|
||||
vacationTypes: [],
|
||||
currentUser: null
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업자 목록 로드
|
||||
*/
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers');
|
||||
if (response.data.success) {
|
||||
window.VacationCommon.workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
return window.VacationCommon.workers;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 유형 목록 로드
|
||||
*/
|
||||
async function loadVacationTypes() {
|
||||
try {
|
||||
const response = await axios.get('/attendance/vacation-types');
|
||||
if (response.data.success) {
|
||||
window.VacationCommon.vacationTypes = response.data.data;
|
||||
return window.VacationCommon.vacationTypes;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 유형 로드 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 가져오기
|
||||
*/
|
||||
function getCurrentUser() {
|
||||
if (!window.VacationCommon.currentUser) {
|
||||
window.VacationCommon.currentUser = JSON.parse(localStorage.getItem('user'));
|
||||
}
|
||||
return window.VacationCommon.currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 목록 렌더링
|
||||
*/
|
||||
function renderVacationRequests(requests, containerId, showActions = false, actionType = 'approval') {
|
||||
const container = document.getElementById(containerId);
|
||||
|
||||
if (!requests || requests.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>휴가 신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>휴가 유형</th>
|
||||
<th>시작일</th>
|
||||
<th>종료일</th>
|
||||
<th>일수</th>
|
||||
<th>상태</th>
|
||||
<th>사유</th>
|
||||
${showActions ? '<th>관리</th>' : ''}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${requests.map(request => {
|
||||
const statusClass = request.status === 'pending' ? 'status-pending' :
|
||||
request.status === 'approved' ? 'status-approved' : 'status-rejected';
|
||||
const statusText = request.status === 'pending' ? '대기' :
|
||||
request.status === 'approved' ? '승인' : '거부';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${request.worker_name || '알 수 없음'}</strong></td>
|
||||
<td>${request.vacation_type_name || request.type_name || '알 수 없음'}</td>
|
||||
<td>${request.start_date}</td>
|
||||
<td>${request.end_date}</td>
|
||||
<td>${request.days_used}일</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass}">
|
||||
${statusText}
|
||||
</span>
|
||||
</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${request.reason || '-'}">
|
||||
${request.reason || '-'}
|
||||
</td>
|
||||
${showActions ? renderActionButtons(request, actionType) : ''}
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 버튼 렌더링
|
||||
*/
|
||||
function renderActionButtons(request, actionType) {
|
||||
if (actionType === 'approval' && request.status === 'pending') {
|
||||
return `
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn-small btn-success" onclick="approveVacationRequest(${request.request_id})" title="승인">
|
||||
✓
|
||||
</button>
|
||||
<button class="btn-small btn-danger" onclick="rejectVacationRequest(${request.request_id})" title="거부">
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
} else if (actionType === 'delete' && request.status === 'pending') {
|
||||
return `
|
||||
<td>
|
||||
<button class="btn-small btn-danger" onclick="deleteVacationRequest(${request.request_id})" title="삭제">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
return '<td>-</td>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 승인
|
||||
*/
|
||||
async function approveVacationRequest(requestId) {
|
||||
if (!confirm('이 휴가 신청을 승인하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.patch(`/vacation-requests/${requestId}/approve`);
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 승인되었습니다.');
|
||||
// 페이지 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 오류:', error);
|
||||
alert(error.response?.data?.message || '승인 중 오류가 발생했습니다.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 거부
|
||||
*/
|
||||
async function rejectVacationRequest(requestId) {
|
||||
const reason = prompt('거부 사유를 입력하세요:');
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.patch(`/vacation-requests/${requestId}/reject`, {
|
||||
review_note: reason
|
||||
});
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 거부되었습니다.');
|
||||
// 페이지 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('거부 오류:', error);
|
||||
alert(error.response?.data?.message || '거부 중 오류가 발생했습니다.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 삭제
|
||||
*/
|
||||
async function deleteVacationRequest(requestId) {
|
||||
if (!confirm('이 휴가 신청을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.delete(`/vacation-requests/${requestId}`);
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 삭제되었습니다.');
|
||||
// 페이지 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert(error.response?.data?.message || '삭제 중 오류가 발생했습니다.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* axios 설정 대기
|
||||
*/
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
531
web-ui/js/visit-request.js
Normal file
531
web-ui/js/visit-request.js
Normal file
@@ -0,0 +1,531 @@
|
||||
// 출입 신청 페이지 JavaScript
|
||||
|
||||
let categories = [];
|
||||
let workplaces = [];
|
||||
let mapRegions = [];
|
||||
let visitPurposes = [];
|
||||
let selectedWorkplace = null;
|
||||
let selectedCategory = null;
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let layoutImage = null;
|
||||
|
||||
// ==================== Toast 알림 ====================
|
||||
|
||||
/**
|
||||
* Toast 메시지 표시
|
||||
*/
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const iconMap = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${iconMap[type] || 'ℹ️'}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// 애니메이션
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
// 자동 제거
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast 컨테이너 생성
|
||||
*/
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toastContainer';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Toast 스타일 추가
|
||||
if (!document.getElementById('toastStyles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'toastStyles';
|
||||
style.textContent = `
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toast-success { border-left: 4px solid #10b981; }
|
||||
.toast-error { border-left: 4px solid #ef4444; }
|
||||
.toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.toast-info { border-left: 4px solid #3b82f6; }
|
||||
.toast-icon { font-size: 20px; }
|
||||
.toast-message { font-size: 14px; color: #374151; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 오늘 날짜 기본값 설정
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('visitDate').value = today;
|
||||
document.getElementById('visitDate').min = today;
|
||||
|
||||
// 현재 시간 + 1시간 기본값 설정
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + 1);
|
||||
const timeString = now.toTimeString().slice(0, 5);
|
||||
document.getElementById('visitTime').value = timeString;
|
||||
|
||||
// 데이터 로드
|
||||
await loadCategories();
|
||||
await loadVisitPurposes();
|
||||
await loadMyRequests();
|
||||
|
||||
// 폼 제출 이벤트
|
||||
document.getElementById('visitRequestForm').addEventListener('submit', handleSubmit);
|
||||
|
||||
// 캔버스 초기화
|
||||
canvas = document.getElementById('workplaceMapCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
});
|
||||
|
||||
// ==================== 데이터 로드 ====================
|
||||
|
||||
/**
|
||||
* 카테고리(공장) 목록 로드
|
||||
*/
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await window.apiCall('/workplaces/categories', 'GET');
|
||||
if (response && response.success) {
|
||||
categories = response.data || [];
|
||||
|
||||
const categorySelect = document.getElementById('categorySelect');
|
||||
categorySelect.innerHTML = '<option value="">구역을 선택하세요</option>';
|
||||
|
||||
categories.forEach(cat => {
|
||||
if (cat.is_active) {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat.category_id;
|
||||
option.textContent = cat.category_name;
|
||||
categorySelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 오류:', error);
|
||||
window.showToast('카테고리 로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 방문 목적 목록 로드
|
||||
*/
|
||||
async function loadVisitPurposes() {
|
||||
try {
|
||||
const response = await window.apiCall('/workplace-visits/purposes/active', 'GET');
|
||||
if (response && response.success) {
|
||||
visitPurposes = response.data || [];
|
||||
|
||||
const purposeSelect = document.getElementById('visitPurpose');
|
||||
purposeSelect.innerHTML = '<option value="">선택하세요</option>';
|
||||
|
||||
visitPurposes.forEach(purpose => {
|
||||
const option = document.createElement('option');
|
||||
option.value = purpose.purpose_id;
|
||||
option.textContent = purpose.purpose_name;
|
||||
purposeSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('방문 목적 로드 오류:', error);
|
||||
window.showToast('방문 목적 로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 출입 신청 목록 로드
|
||||
*/
|
||||
async function loadMyRequests() {
|
||||
try {
|
||||
// localStorage에서 사용자 정보 가져오기
|
||||
const userData = localStorage.getItem('user');
|
||||
const currentUser = userData ? JSON.parse(userData) : null;
|
||||
|
||||
if (!currentUser || !currentUser.user_id) {
|
||||
console.log('사용자 정보 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await window.apiCall(`/workplace-visits/requests?requester_id=${currentUser.user_id}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const requests = response.data || [];
|
||||
renderMyRequests(requests);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('내 신청 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 신청 목록 렌더링
|
||||
*/
|
||||
function renderMyRequests(requests) {
|
||||
const listDiv = document.getElementById('myRequestsList');
|
||||
|
||||
if (requests.length === 0) {
|
||||
listDiv.innerHTML = '<p style="text-align: center; color: var(--gray-500); padding: 32px;">신청 내역이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
requests.forEach(req => {
|
||||
const statusText = {
|
||||
'pending': '승인 대기',
|
||||
'approved': '승인됨',
|
||||
'rejected': '반려됨',
|
||||
'training_completed': '교육 완료'
|
||||
}[req.status] || req.status;
|
||||
|
||||
html += `
|
||||
<div class="request-card">
|
||||
<div class="request-card-header">
|
||||
<h3 style="margin: 0; font-size: var(--text-lg);">${req.visitor_company} (${req.visitor_count}명)</h3>
|
||||
<span class="request-status ${req.status}">${statusText}</span>
|
||||
</div>
|
||||
<div class="request-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">방문 작업장</span>
|
||||
<span class="info-value">${req.category_name} - ${req.workplace_name}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">방문 일시</span>
|
||||
<span class="info-value">${req.visit_date} ${req.visit_time}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">방문 목적</span>
|
||||
<span class="info-value">${req.purpose_name}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">신청일</span>
|
||||
<span class="info-value">${new Date(req.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
${req.rejection_reason ? `<p style="margin-top: 12px; padding: 12px; background: var(--red-50); color: var(--red-700); border-radius: var(--radius-md); font-size: var(--text-sm);"><strong>반려 사유:</strong> ${req.rejection_reason}</p>` : ''}
|
||||
${req.notes ? `<p style="margin-top: 12px; color: var(--gray-600); font-size: var(--text-sm);"><strong>비고:</strong> ${req.notes}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
// ==================== 작업장 지도 모달 ====================
|
||||
|
||||
/**
|
||||
* 지도 모달 열기
|
||||
*/
|
||||
function openMapModal() {
|
||||
document.getElementById('mapModal').style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 모달 닫기
|
||||
*/
|
||||
function closeMapModal() {
|
||||
document.getElementById('mapModal').style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 지도 로드
|
||||
*/
|
||||
async function loadWorkplaceMap() {
|
||||
const categoryId = document.getElementById('categorySelect').value;
|
||||
if (!categoryId) {
|
||||
document.getElementById('mapCanvasContainer').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCategory = categories.find(c => c.category_id == categoryId);
|
||||
|
||||
try {
|
||||
// 작업장 목록 로드
|
||||
const workplacesResponse = await window.apiCall(`/workplaces/categories/${categoryId}`, 'GET');
|
||||
if (workplacesResponse && workplacesResponse.success) {
|
||||
workplaces = workplacesResponse.data || [];
|
||||
}
|
||||
|
||||
// 지도 영역 로드
|
||||
const regionsResponse = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
|
||||
if (regionsResponse && regionsResponse.success) {
|
||||
mapRegions = regionsResponse.data || [];
|
||||
}
|
||||
|
||||
// 레이아웃 이미지가 있으면 표시
|
||||
if (selectedCategory && selectedCategory.layout_image) {
|
||||
// API_BASE_URL에서 /api 제거하고 이미지 경로 생성
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
|
||||
console.log('이미지 URL:', fullImageUrl);
|
||||
loadImageToCanvas(fullImageUrl);
|
||||
document.getElementById('mapCanvasContainer').style.display = 'block';
|
||||
} else {
|
||||
window.showToast('선택한 구역에 레이아웃 지도가 없습니다.', 'warning');
|
||||
document.getElementById('mapCanvasContainer').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업장 지도 로드 오류:', error);
|
||||
window.showToast('작업장 지도 로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지를 캔버스에 로드
|
||||
*/
|
||||
function loadImageToCanvas(imagePath) {
|
||||
const img = new Image();
|
||||
// crossOrigin 제거 - 같은 도메인이므로 불필요
|
||||
img.onload = function() {
|
||||
// 캔버스 크기 설정
|
||||
const maxWidth = 800;
|
||||
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
|
||||
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
layoutImage = img;
|
||||
|
||||
// 영역 표시
|
||||
drawRegions();
|
||||
|
||||
// 클릭 이벤트 등록
|
||||
canvas.onclick = handleCanvasClick;
|
||||
};
|
||||
img.onerror = function() {
|
||||
window.showToast('지도 이미지를 불러올 수 없습니다.', 'error');
|
||||
};
|
||||
img.src = imagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 영역 그리기
|
||||
*/
|
||||
function drawRegions() {
|
||||
mapRegions.forEach(region => {
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
// 영역 박스
|
||||
ctx.strokeStyle = '#10b981';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.1)';
|
||||
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
// 작업장 이름
|
||||
ctx.fillStyle = '#10b981';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 캔버스 클릭 핸들러
|
||||
*/
|
||||
function handleCanvasClick(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
// 클릭한 위치의 영역 찾기
|
||||
for (const region of mapRegions) {
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
|
||||
// 작업장 선택
|
||||
selectWorkplace(region);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.showToast('작업장 영역을 클릭해주세요.', 'warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 선택
|
||||
*/
|
||||
function selectWorkplace(region) {
|
||||
selectedWorkplace = {
|
||||
workplace_id: region.workplace_id,
|
||||
workplace_name: region.workplace_name,
|
||||
category_id: selectedCategory.category_id,
|
||||
category_name: selectedCategory.category_name
|
||||
};
|
||||
|
||||
// 선택 표시
|
||||
const selectionDiv = document.getElementById('workplaceSelection');
|
||||
selectionDiv.classList.add('selected');
|
||||
selectionDiv.innerHTML = `
|
||||
<div class="icon">✅</div>
|
||||
<div class="text">${selectedCategory.category_name} - ${region.workplace_name}</div>
|
||||
`;
|
||||
|
||||
// 상세 정보 카드 표시
|
||||
const infoDiv = document.getElementById('selectedWorkplaceInfo');
|
||||
infoDiv.style.display = 'block';
|
||||
infoDiv.innerHTML = `
|
||||
<div class="workplace-info-card">
|
||||
<div class="icon">📍</div>
|
||||
<div class="details">
|
||||
<div class="name">${region.workplace_name}</div>
|
||||
<div class="category">${selectedCategory.category_name}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="clearWorkplaceSelection()">변경</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 모달 닫기
|
||||
closeMapModal();
|
||||
|
||||
window.showToast(`${region.workplace_name} 작업장이 선택되었습니다.`, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 선택 초기화
|
||||
*/
|
||||
function clearWorkplaceSelection() {
|
||||
selectedWorkplace = null;
|
||||
|
||||
const selectionDiv = document.getElementById('workplaceSelection');
|
||||
selectionDiv.classList.remove('selected');
|
||||
selectionDiv.innerHTML = `
|
||||
<div class="icon">📍</div>
|
||||
<div class="text">지도에서 작업장을 선택하세요</div>
|
||||
`;
|
||||
|
||||
document.getElementById('selectedWorkplaceInfo').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== 폼 제출 ====================
|
||||
|
||||
/**
|
||||
* 출입 신청 제출
|
||||
*/
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedWorkplace) {
|
||||
window.showToast('작업장을 선택해주세요.', 'warning');
|
||||
openMapModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
visitor_company: document.getElementById('visitorCompany').value.trim(),
|
||||
visitor_count: parseInt(document.getElementById('visitorCount').value),
|
||||
category_id: selectedWorkplace.category_id,
|
||||
workplace_id: selectedWorkplace.workplace_id,
|
||||
visit_date: document.getElementById('visitDate').value,
|
||||
visit_time: document.getElementById('visitTime').value,
|
||||
purpose_id: parseInt(document.getElementById('visitPurpose').value),
|
||||
notes: document.getElementById('notes').value.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await window.apiCall('/workplace-visits/requests', 'POST', formData);
|
||||
|
||||
if (response && response.success) {
|
||||
window.showToast('출입 신청 및 안전교육 신청이 완료되었습니다. 안전관리자의 승인을 기다려주세요.', 'success');
|
||||
|
||||
// 폼 초기화
|
||||
resetForm();
|
||||
|
||||
// 내 신청 목록 새로고침
|
||||
await loadMyRequests();
|
||||
} else {
|
||||
throw new Error(response?.message || '신청 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('출입 신청 오류:', error);
|
||||
window.showToast(error.message || '출입 신청 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 초기화
|
||||
*/
|
||||
function resetForm() {
|
||||
document.getElementById('visitRequestForm').reset();
|
||||
clearWorkplaceSelection();
|
||||
|
||||
// 오늘 날짜와 시간 다시 설정
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('visitDate').value = today;
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + 1);
|
||||
const timeString = now.toTimeString().slice(0, 5);
|
||||
document.getElementById('visitTime').value = timeString;
|
||||
|
||||
document.getElementById('visitorCount').value = 1;
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.showToast = showToast;
|
||||
window.openMapModal = openMapModal;
|
||||
window.closeMapModal = closeMapModal;
|
||||
window.loadWorkplaceMap = loadWorkplaceMap;
|
||||
window.clearWorkplaceSelection = clearWorkplaceSelection;
|
||||
window.resetForm = resetForm;
|
||||
494
web-ui/js/workplace-layout-map.js
Normal file
494
web-ui/js/workplace-layout-map.js
Normal file
@@ -0,0 +1,494 @@
|
||||
// 작업장 레이아웃 지도 관리
|
||||
|
||||
// 전역 변수
|
||||
let layoutMapImage = null;
|
||||
let mapRegions = [];
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let isDrawing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let currentRect = null;
|
||||
|
||||
// ==================== 레이아웃 지도 모달 ====================
|
||||
|
||||
/**
|
||||
* 레이아웃 지도 모달 열기
|
||||
*/
|
||||
async function openLayoutMapModal() {
|
||||
// window 객체에서 currentCategoryId 가져오기
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
|
||||
if (!currentCategoryId) {
|
||||
window.window.showToast('공장을 먼저 선택해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('layoutMapModal');
|
||||
if (!modal) return;
|
||||
|
||||
// 캔버스 초기화
|
||||
canvas = document.getElementById('regionCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// 현재 카테고리의 레이아웃 이미지 및 영역 로드
|
||||
await loadLayoutMapData();
|
||||
|
||||
// 작업장 선택 옵션 업데이트
|
||||
updateWorkplaceSelect();
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 지도 모달 닫기
|
||||
*/
|
||||
function closeLayoutMapModal() {
|
||||
const modal = document.getElementById('layoutMapModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// 캔버스 이벤트 리스너 제거
|
||||
if (canvas) {
|
||||
canvas.removeEventListener('mousedown', startDrawing);
|
||||
canvas.removeEventListener('mousemove', draw);
|
||||
canvas.removeEventListener('mouseup', stopDrawing);
|
||||
}
|
||||
|
||||
// 메인 페이지의 레이아웃 미리보기 업데이트
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
const categories = window.categories;
|
||||
|
||||
if (currentCategoryId && categories) {
|
||||
const category = categories.find(c => c.category_id == currentCategoryId);
|
||||
if (category && window.updateLayoutPreview) {
|
||||
window.updateLayoutPreview(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 지도 데이터 로드
|
||||
*/
|
||||
async function loadLayoutMapData() {
|
||||
try {
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
const categories = window.categories;
|
||||
|
||||
// 현재 카테고리 정보 가져오기
|
||||
const category = categories.find(c => c.category_id == currentCategoryId);
|
||||
if (!category) return;
|
||||
|
||||
// 레이아웃 이미지 표시
|
||||
const currentImageDiv = document.getElementById('currentLayoutImage');
|
||||
if (category.layout_image) {
|
||||
// 이미지 경로를 전체 URL로 변환
|
||||
const fullImageUrl = category.layout_image.startsWith('http')
|
||||
? category.layout_image
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
|
||||
|
||||
currentImageDiv.innerHTML = `
|
||||
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
|
||||
`;
|
||||
|
||||
// 캔버스에도 이미지 로드
|
||||
loadImageToCanvas(fullImageUrl);
|
||||
} else {
|
||||
currentImageDiv.innerHTML = '<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>';
|
||||
}
|
||||
|
||||
// 영역 데이터 로드
|
||||
const regionsResponse = await window.apiCall(`/workplaces/categories/${currentCategoryId}/map-regions`, 'GET');
|
||||
if (regionsResponse && regionsResponse.success) {
|
||||
mapRegions = regionsResponse.data || [];
|
||||
} else {
|
||||
mapRegions = [];
|
||||
}
|
||||
|
||||
renderRegionList();
|
||||
} catch (error) {
|
||||
console.error('레이아웃 지도 데이터 로딩 오류:', error);
|
||||
window.window.showToast('레이아웃 지도 데이터를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지를 캔버스에 로드
|
||||
*/
|
||||
function loadImageToCanvas(imagePath) {
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
// 캔버스 크기를 이미지 크기에 맞춤 (최대 800px)
|
||||
const maxWidth = 800;
|
||||
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
|
||||
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
layoutMapImage = img;
|
||||
|
||||
// 기존 영역들 그리기
|
||||
drawExistingRegions();
|
||||
|
||||
// 캔버스 이벤트 리스너 등록
|
||||
setupCanvasEvents();
|
||||
};
|
||||
img.src = imagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 선택 옵션 업데이트
|
||||
*/
|
||||
function updateWorkplaceSelect() {
|
||||
const select = document.getElementById('regionWorkplaceSelect');
|
||||
if (!select) return;
|
||||
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
const workplaces = window.workplaces;
|
||||
|
||||
// 현재 카테고리의 작업장만 필터링
|
||||
const categoryWorkplaces = workplaces.filter(w => w.category_id == currentCategoryId);
|
||||
|
||||
let options = '<option value="">작업장을 선택하세요</option>';
|
||||
categoryWorkplaces.forEach(wp => {
|
||||
// 이미 영역이 정의된 작업장은 표시
|
||||
const hasRegion = mapRegions.some(r => r.workplace_id === wp.workplace_id);
|
||||
options += `<option value="${wp.workplace_id}">${wp.workplace_name}${hasRegion ? ' (영역 정의됨)' : ''}</option>`;
|
||||
});
|
||||
|
||||
select.innerHTML = options;
|
||||
}
|
||||
|
||||
// ==================== 이미지 업로드 ====================
|
||||
|
||||
/**
|
||||
* 이미지 미리보기
|
||||
*/
|
||||
function previewLayoutImage(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const currentImageDiv = document.getElementById('currentLayoutImage');
|
||||
currentImageDiv.innerHTML = `
|
||||
<img src="${e.target.result}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="미리보기">
|
||||
<p style="color: #64748b; font-size: 14px; margin-top: 8px;">미리보기 (저장하려면 "이미지 업로드" 버튼을 클릭하세요)</p>
|
||||
`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 이미지 업로드
|
||||
*/
|
||||
async function uploadLayoutImage() {
|
||||
const fileInput = document.getElementById('layoutImageFile');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
window.showToast('이미지 파일을 선택해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
|
||||
if (!currentCategoryId) {
|
||||
window.showToast('공장을 먼저 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// FormData 생성
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// 업로드 요청
|
||||
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/categories/${currentCategoryId}/layout-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
window.showToast('이미지가 성공적으로 업로드되었습니다.', 'success');
|
||||
|
||||
// 이미지 경로를 전체 URL로 변환
|
||||
const fullImageUrl = `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
|
||||
|
||||
// 이미지를 캔버스에 로드
|
||||
loadImageToCanvas(fullImageUrl);
|
||||
|
||||
// 현재 이미지 미리보기도 업데이트
|
||||
const currentImageDiv = document.getElementById('currentLayoutImage');
|
||||
if (currentImageDiv) {
|
||||
currentImageDiv.innerHTML = `
|
||||
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
|
||||
`;
|
||||
}
|
||||
|
||||
// 카테고리 데이터 새로고침 (workplace-management.js의 loadCategories 함수 호출)
|
||||
if (window.loadCategories) {
|
||||
await window.loadCategories();
|
||||
|
||||
// 메인 페이지 미리보기도 업데이트
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
const categories = window.categories;
|
||||
if (currentCategoryId && categories && window.updateLayoutPreview) {
|
||||
const category = categories.find(c => c.category_id == currentCategoryId);
|
||||
if (category) {
|
||||
window.updateLayoutPreview(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message || '업로드 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 오류:', error);
|
||||
window.showToast(error.message || '이미지 업로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 영역 그리기 ====================
|
||||
|
||||
/**
|
||||
* 캔버스 이벤트 설정
|
||||
*/
|
||||
function setupCanvasEvents() {
|
||||
canvas.addEventListener('mousedown', startDrawing);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDrawing);
|
||||
canvas.addEventListener('mouseleave', stopDrawing);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 시작
|
||||
*/
|
||||
function startDrawing(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
startX = e.clientX - rect.left;
|
||||
startY = e.clientY - rect.top;
|
||||
isDrawing = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기
|
||||
*/
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const currentX = e.clientX - rect.left;
|
||||
const currentY = e.clientY - rect.top;
|
||||
|
||||
// 캔버스 다시 그리기
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (layoutMapImage) {
|
||||
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// 기존 영역들 그리기
|
||||
drawExistingRegions();
|
||||
|
||||
// 현재 그리는 사각형
|
||||
const width = currentX - startX;
|
||||
const height = currentY - startY;
|
||||
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(startX, startY, width, height);
|
||||
|
||||
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
|
||||
ctx.fillRect(startX, startY, width, height);
|
||||
|
||||
currentRect = { startX, startY, endX: currentX, endY: currentY };
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 종료
|
||||
*/
|
||||
function stopDrawing() {
|
||||
isDrawing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 영역들 그리기
|
||||
*/
|
||||
function drawExistingRegions() {
|
||||
mapRegions.forEach(region => {
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
ctx.strokeStyle = '#10b981';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
||||
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
// 작업장 이름 표시
|
||||
ctx.fillStyle = '#10b981';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 영역 지우기
|
||||
*/
|
||||
function clearCurrentRegion() {
|
||||
currentRect = null;
|
||||
|
||||
// 캔버스 다시 그리기
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (layoutMapImage) {
|
||||
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
drawExistingRegions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역 저장
|
||||
*/
|
||||
async function saveRegion() {
|
||||
const workplaceId = document.getElementById('regionWorkplaceSelect').value;
|
||||
|
||||
if (!workplaceId) {
|
||||
window.showToast('작업장을 선택해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentRect) {
|
||||
window.showToast('영역을 그려주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
|
||||
try {
|
||||
// 비율로 변환 (0~100%)
|
||||
const xStart = Math.min(currentRect.startX, currentRect.endX) / canvas.width * 100;
|
||||
const yStart = Math.min(currentRect.startY, currentRect.endY) / canvas.height * 100;
|
||||
const xEnd = Math.max(currentRect.startX, currentRect.endX) / canvas.width * 100;
|
||||
const yEnd = Math.max(currentRect.startY, currentRect.endY) / canvas.height * 100;
|
||||
|
||||
// 기존 영역이 있는지 확인
|
||||
const existingRegion = mapRegions.find(r => r.workplace_id == workplaceId);
|
||||
|
||||
const regionData = {
|
||||
workplace_id: parseInt(workplaceId),
|
||||
category_id: parseInt(currentCategoryId),
|
||||
x_start: xStart.toFixed(2),
|
||||
y_start: yStart.toFixed(2),
|
||||
x_end: xEnd.toFixed(2),
|
||||
y_end: yEnd.toFixed(2),
|
||||
shape: 'rect'
|
||||
};
|
||||
|
||||
let response;
|
||||
if (existingRegion) {
|
||||
// 수정
|
||||
response = await window.apiCall(`/workplaces/map-regions/${existingRegion.region_id}`, 'PUT', regionData);
|
||||
} else {
|
||||
// 신규 등록
|
||||
response = await window.apiCall('/workplaces/map-regions', 'POST', regionData);
|
||||
}
|
||||
|
||||
if (response && response.success) {
|
||||
window.showToast('영역이 성공적으로 저장되었습니다.', 'success');
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadLayoutMapData();
|
||||
|
||||
// 현재 그림 초기화
|
||||
clearCurrentRegion();
|
||||
|
||||
// 작업장 선택 초기화
|
||||
document.getElementById('regionWorkplaceSelect').value = '';
|
||||
} else {
|
||||
throw new Error(response?.message || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('영역 저장 오류:', error);
|
||||
window.showToast(error.message || '영역 저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역 목록 렌더링
|
||||
*/
|
||||
function renderRegionList() {
|
||||
const listDiv = document.getElementById('regionList');
|
||||
if (!listDiv) return;
|
||||
|
||||
if (mapRegions.length === 0) {
|
||||
listDiv.innerHTML = '<p style="color: #94a3b8; text-align: center; padding: 20px;">정의된 영역이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let listHtml = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||||
|
||||
mapRegions.forEach(region => {
|
||||
listHtml += `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: white; border: 1px solid #e5e7eb; border-radius: 6px;">
|
||||
<div>
|
||||
<span style="font-weight: 600; color: #1e293b;">${region.workplace_name}</span>
|
||||
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">
|
||||
(${region.x_start}%, ${region.y_start}%) ~ (${region.x_end}%, ${region.y_end}%)
|
||||
</span>
|
||||
</div>
|
||||
<button onclick="deleteRegion(${region.region_id})" class="btn-small btn-delete" style="padding: 4px 8px; font-size: 12px;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listHtml += '</div>';
|
||||
listDiv.innerHTML = listHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역 삭제
|
||||
*/
|
||||
async function deleteRegion(regionId) {
|
||||
if (!confirm('이 영역을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/workplaces/map-regions/${regionId}`, 'DELETE');
|
||||
|
||||
if (response && response.success) {
|
||||
window.showToast('영역이 삭제되었습니다.', 'success');
|
||||
await loadLayoutMapData();
|
||||
} else {
|
||||
throw new Error(response?.message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('영역 삭제 오류:', error);
|
||||
window.showToast(error.message || '영역 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.openLayoutMapModal = openLayoutMapModal;
|
||||
window.closeLayoutMapModal = closeLayoutMapModal;
|
||||
window.previewLayoutImage = previewLayoutImage;
|
||||
window.uploadLayoutImage = uploadLayoutImage;
|
||||
window.clearCurrentRegion = clearCurrentRegion;
|
||||
window.saveRegion = saveRegion;
|
||||
window.deleteRegion = deleteRegion;
|
||||
448
web-ui/js/workplace-status.js
Normal file
448
web-ui/js/workplace-status.js
Normal file
@@ -0,0 +1,448 @@
|
||||
// 작업장 현황 JavaScript
|
||||
|
||||
let selectedCategory = null;
|
||||
let workplaceData = [];
|
||||
let mapRegions = []; // 작업장 영역 데이터
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let canvasImage = null;
|
||||
|
||||
// 금일 TBM 작업자 데이터
|
||||
let todayWorkers = [];
|
||||
|
||||
// 금일 출입 신청 데이터
|
||||
let todayVisitors = [];
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadCategories();
|
||||
|
||||
// 이벤트 리스너
|
||||
document.getElementById('categorySelect').addEventListener('change', onCategoryChange);
|
||||
document.getElementById('refreshMapBtn').addEventListener('click', refreshMapData);
|
||||
|
||||
// 기본값으로 제1공장 선택
|
||||
await selectFirstCategory();
|
||||
});
|
||||
|
||||
// ==================== 카테고리 (공장) 로드 ====================
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await window.apiCall('/workplaces/categories', 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const categories = response.data || [];
|
||||
const select = document.getElementById('categorySelect');
|
||||
|
||||
categories.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat.category_id;
|
||||
option.textContent = cat.category_name;
|
||||
option.dataset.layoutImage = cat.layout_image;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 번째 카테고리 자동 선택
|
||||
*/
|
||||
async function selectFirstCategory() {
|
||||
const select = document.getElementById('categorySelect');
|
||||
if (select.options.length > 1) {
|
||||
// 첫 번째 옵션 선택 (인덱스 0은 "공장을 선택하세요")
|
||||
select.selectedIndex = 1;
|
||||
// 변경 이벤트 트리거
|
||||
await onCategoryChange({ target: select });
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 공장 선택 ====================
|
||||
|
||||
async function onCategoryChange(e) {
|
||||
const categoryId = e.target.value;
|
||||
|
||||
if (!categoryId) {
|
||||
document.getElementById('workplaceMapContainer').style.display = 'none';
|
||||
document.getElementById('mapPlaceholder').style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOption = e.target.options[e.target.selectedIndex];
|
||||
const layoutImage = selectedOption.dataset.layoutImage;
|
||||
|
||||
selectedCategory = {
|
||||
category_id: categoryId,
|
||||
category_name: selectedOption.textContent,
|
||||
layout_image: layoutImage
|
||||
};
|
||||
|
||||
// 지도 로드
|
||||
await loadWorkplaceMap();
|
||||
|
||||
// 금일 작업 데이터 로드
|
||||
await loadTodayData();
|
||||
|
||||
// 지도 렌더링
|
||||
renderMap();
|
||||
}
|
||||
|
||||
// ==================== 작업장 지도 로드 ====================
|
||||
|
||||
async function loadWorkplaceMap() {
|
||||
try {
|
||||
// 작업장 데이터 로드
|
||||
const response = await window.apiCall(`/workplaces?category_id=${selectedCategory.category_id}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
workplaceData = response.data || [];
|
||||
}
|
||||
|
||||
// 작업장 영역 데이터 로드 (map-regions API)
|
||||
const regionsResponse = await window.apiCall(`/workplaces/categories/${selectedCategory.category_id}/map-regions`, 'GET');
|
||||
|
||||
if (regionsResponse && regionsResponse.success) {
|
||||
mapRegions = regionsResponse.data || [];
|
||||
console.log('[지도] 로드된 영역:', mapRegions);
|
||||
}
|
||||
|
||||
// 이미지 로드
|
||||
await loadMapImage();
|
||||
|
||||
// 지도 컨테이너 표시
|
||||
document.getElementById('mapPlaceholder').style.display = 'none';
|
||||
document.getElementById('workplaceMapContainer').style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('작업장 데이터 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMapImage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
|
||||
img.onload = () => {
|
||||
canvasImage = img;
|
||||
|
||||
// 캔버스 초기화
|
||||
canvas = document.getElementById('workplaceMapCanvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// 클릭 이벤트
|
||||
canvas.addEventListener('click', onMapClick);
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('이미지 로드 실패:', fullImageUrl);
|
||||
reject();
|
||||
};
|
||||
|
||||
img.src = fullImageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 금일 데이터 로드 ====================
|
||||
|
||||
async function loadTodayData() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// TBM 작업자 데이터 로드
|
||||
await loadTodayWorkers(today);
|
||||
|
||||
// 출입 신청 데이터 로드
|
||||
await loadTodayVisitors(today);
|
||||
}
|
||||
|
||||
async function loadTodayWorkers(date) {
|
||||
try {
|
||||
const response = await window.apiCall(`/tbm/sessions/date/${date}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const sessions = response.data || [];
|
||||
todayWorkers = [];
|
||||
|
||||
// 각 세션의 작업 정보 추가
|
||||
sessions.forEach(session => {
|
||||
if (session.workplace_id) {
|
||||
const memberCount = session.team_member_count || 0;
|
||||
const leaderCount = session.leader_id ? 1 : 0;
|
||||
const totalCount = memberCount + leaderCount;
|
||||
|
||||
todayWorkers.push({
|
||||
workplace_id: session.workplace_id,
|
||||
task_name: session.task_name || '작업',
|
||||
work_location: session.work_location || '',
|
||||
member_count: totalCount,
|
||||
project_name: session.project_name || ''
|
||||
});
|
||||
|
||||
console.log(`[TBM] 작업 추가: ${session.work_location || session.workplace_id} - ${session.task_name} (${totalCount}명)`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('로드된 작업자:', todayWorkers);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('TBM 작업자 데이터 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTodayVisitors(date) {
|
||||
try {
|
||||
// 날짜 형식 확인 (YYYY-MM-DD)
|
||||
const formattedDate = date.split('T')[0];
|
||||
|
||||
const response = await window.apiCall(`/workplace-visits/requests`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const requests = response.data || [];
|
||||
|
||||
// 금일 날짜와 승인된 요청 필터링
|
||||
todayVisitors = requests.filter(req => {
|
||||
const visitDate = new Date(req.visit_date).toISOString().split('T')[0];
|
||||
return visitDate === formattedDate &&
|
||||
(req.status === 'approved' || req.status === 'training_completed');
|
||||
}).map(req => ({
|
||||
workplace_id: req.workplace_id,
|
||||
visitor_company: req.visitor_company,
|
||||
visitor_count: req.visitor_count,
|
||||
visit_time: req.visit_time,
|
||||
purpose_name: req.purpose_name,
|
||||
status: req.status
|
||||
}));
|
||||
|
||||
console.log('로드된 방문자:', todayVisitors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('출입 신청 데이터 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 지도 렌더링 ====================
|
||||
|
||||
function renderMap() {
|
||||
if (!canvas || !ctx || !canvasImage) return;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(canvasImage, 0, 0);
|
||||
|
||||
// 모든 작업장 영역 표시
|
||||
mapRegions.forEach(region => {
|
||||
// 해당 작업장의 작업자/방문자 인원 계산
|
||||
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
|
||||
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
|
||||
|
||||
const totalWorkerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
|
||||
const totalVisitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
|
||||
|
||||
// 영역 그리기
|
||||
drawWorkplaceRegion(region, totalWorkerCount, totalVisitorCount);
|
||||
});
|
||||
}
|
||||
|
||||
function drawWorkplaceRegion(region, workerCount, visitorCount) {
|
||||
// 사각형 좌표 변환
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
const centerX = x1 + width / 2;
|
||||
const centerY = y1 + height / 2;
|
||||
|
||||
// 색상 결정
|
||||
let fillColor, strokeColor;
|
||||
const hasActivity = workerCount > 0 || visitorCount > 0;
|
||||
|
||||
if (workerCount > 0 && visitorCount > 0) {
|
||||
// 둘 다 있음 - 초록색
|
||||
fillColor = 'rgba(34, 197, 94, 0.3)';
|
||||
strokeColor = 'rgb(34, 197, 94)';
|
||||
} else if (workerCount > 0) {
|
||||
// 내부 작업자만 - 파란색
|
||||
fillColor = 'rgba(59, 130, 246, 0.3)';
|
||||
strokeColor = 'rgb(59, 130, 246)';
|
||||
} else if (visitorCount > 0) {
|
||||
// 외부 방문자만 - 보라색
|
||||
fillColor = 'rgba(168, 85, 247, 0.3)';
|
||||
strokeColor = 'rgb(168, 85, 247)';
|
||||
} else {
|
||||
// 인원 없음 - 회색 테두리만
|
||||
fillColor = 'rgba(0, 0, 0, 0)'; // 투명
|
||||
strokeColor = 'rgb(156, 163, 175)'; // 회색
|
||||
}
|
||||
|
||||
// 사각형 그리기
|
||||
ctx.save();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(x1, y1, width, height);
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = hasActivity ? 3 : 2;
|
||||
ctx.strokeRect(x1, y1, width, height);
|
||||
ctx.restore();
|
||||
|
||||
// 인원수 표시 (인원이 있을 때만)
|
||||
if (hasActivity) {
|
||||
ctx.save();
|
||||
ctx.font = 'bold 16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// 배경 원
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, 20, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// 텍스트
|
||||
const totalCount = workerCount + visitorCount;
|
||||
ctx.fillStyle = strokeColor;
|
||||
ctx.fillText(totalCount.toString(), centerX, centerY);
|
||||
ctx.restore();
|
||||
} else {
|
||||
// 인원이 없을 때는 작업장 이름만 표시
|
||||
ctx.save();
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = 'rgb(107, 114, 128)';
|
||||
ctx.fillText(region.workplace_name, centerX, centerY);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 지도 클릭 ====================
|
||||
|
||||
function onMapClick(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width * canvas.width;
|
||||
const y = (e.clientY - rect.top) / rect.height * canvas.height;
|
||||
|
||||
// 클릭한 위치의 작업장 영역 찾기
|
||||
for (const region of mapRegions) {
|
||||
if (isPointInRegion(x, y, region)) {
|
||||
// 작업장 정보를 찾아서 모달 표시
|
||||
const workplace = workplaceData.find(w => w.workplace_id === region.workplace_id);
|
||||
if (workplace) {
|
||||
showWorkplaceDetail({ ...workplace, ...region });
|
||||
} else {
|
||||
// 작업장 정보가 없으면 region 데이터만 사용
|
||||
showWorkplaceDetail(region);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPointInRegion(x, y, region) {
|
||||
// 사각형 영역 내부 체크
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
return x >= x1 && x <= x2 && y >= y1 && y <= y2;
|
||||
}
|
||||
|
||||
// ==================== 작업장 상세 정보 모달 ====================
|
||||
|
||||
function showWorkplaceDetail(workplace) {
|
||||
const workers = todayWorkers.filter(w => w.workplace_id === workplace.workplace_id);
|
||||
const visitors = todayVisitors.filter(v => v.workplace_id === workplace.workplace_id);
|
||||
|
||||
// 모달 제목
|
||||
document.getElementById('modalWorkplaceName').textContent = `${selectedCategory.category_name} - ${workplace.workplace_name}`;
|
||||
|
||||
// 내부 작업자 목록
|
||||
const workersList = document.getElementById('internalWorkersList');
|
||||
if (workers.length === 0) {
|
||||
workersList.innerHTML = '<p style="color: var(--gray-500); font-size: var(--text-sm);">금일 작업 예정 인원이 없습니다.</p>';
|
||||
} else {
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
||||
workers.forEach(worker => {
|
||||
html += `
|
||||
<div style="padding: 12px; background: var(--blue-50); border-left: 4px solid var(--blue-500); border-radius: var(--radius-md);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<strong style="font-size: var(--text-base);">${worker.task_name}</strong>
|
||||
<span style="margin-left: 8px; padding: 2px 8px; background: var(--blue-100); color: var(--blue-700); border-radius: var(--radius-sm); font-size: var(--text-xs);">${worker.member_count}명</span>
|
||||
</div>
|
||||
</div>
|
||||
${worker.work_location ? `<div style="margin-top: 8px; font-size: var(--text-sm); color: var(--gray-600);">📍 ${worker.work_location}</div>` : ''}
|
||||
${worker.project_name ? `<div style="margin-top: 4px; font-size: var(--text-sm); color: var(--gray-600);">📁 ${worker.project_name}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
workersList.innerHTML = html;
|
||||
}
|
||||
|
||||
// 외부 방문자 목록
|
||||
const visitorsList = document.getElementById('externalVisitorsList');
|
||||
if (visitors.length === 0) {
|
||||
visitorsList.innerHTML = '<p style="color: var(--gray-500); font-size: var(--text-sm);">금일 방문 예정 인원이 없습니다.</p>';
|
||||
} else {
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
||||
visitors.forEach(visitor => {
|
||||
const statusText = visitor.status === 'training_completed' ? '교육 완료' : '승인됨';
|
||||
const statusColor = visitor.status === 'training_completed' ? 'var(--green-500)' : 'var(--yellow-500)';
|
||||
|
||||
html += `
|
||||
<div style="padding: 12px; background: var(--purple-50); border-left: 4px solid var(--purple-500); border-radius: var(--radius-md);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div>
|
||||
<strong style="font-size: var(--text-base);">${visitor.visitor_company}</strong>
|
||||
<span style="margin-left: 8px; padding: 2px 8px; background: var(--purple-100); color: var(--purple-700); border-radius: var(--radius-sm); font-size: var(--text-xs);">${visitor.visitor_count}명</span>
|
||||
</div>
|
||||
<span style="padding: 2px 8px; background: ${statusColor}20; color: ${statusColor}; border-radius: var(--radius-sm); font-size: var(--text-xs); font-weight: 600;">${statusText}</span>
|
||||
</div>
|
||||
<div style="font-size: var(--text-sm); color: var(--gray-600);">
|
||||
<div>⏰ 방문 시간: ${visitor.visit_time}</div>
|
||||
<div>📋 목적: ${visitor.purpose_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
visitorsList.innerHTML = html;
|
||||
}
|
||||
|
||||
// 모달 표시
|
||||
document.getElementById('workplaceDetailModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeWorkplaceModal() {
|
||||
document.getElementById('workplaceDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== 새로고침 ====================
|
||||
|
||||
async function refreshMapData() {
|
||||
if (!selectedCategory) return;
|
||||
|
||||
await loadTodayData();
|
||||
renderMap();
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.closeWorkplaceModal = closeWorkplaceModal;
|
||||
Reference in New Issue
Block a user