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:
Hyungi Ahn
2026-01-29 15:46:47 +09:00
parent e1227a69fe
commit b6485e3140
87 changed files with 17509 additions and 698 deletions

View File

@@ -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);
}
});

View 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);
}

View File

@@ -243,4 +243,7 @@ setInterval(() => {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
}
}, config.app.tokenRefreshInterval); // 5분마다 확인
}, config.app.tokenRefreshInterval); // 5분마다 확인
// ES6 모듈 export
export { API_URL as API_BASE_URL };

View File

@@ -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) 제거.
})();

View File

@@ -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++) {

View File

@@ -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();

View File

@@ -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);
}
}
/**

View File

@@ -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 {

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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);
}

View 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
View 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;

View 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;

View 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;