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

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

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

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

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

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

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

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

View File

@@ -26,6 +26,11 @@ let selectedWorkplaceName = '';
let isBulkMode = false; // 일괄 설정 모드인지 여부
let bulkSelectedWorkers = new Set(); // 일괄 설정에서 선택된 작업자 인덱스
// TBM 관리 탭용 변수
let loadedDaysCount = 7; // 처음에 로드할 일수
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
let allLoadedSessions = []; // 전체 로드된 세션
// ==================== 유틸리티 함수 ====================
/**
@@ -87,8 +92,10 @@ document.addEventListener('DOMContentLoaded', async () => {
// 오늘 날짜 설정 (서울 시간대 기준)
const today = getTodayKST();
document.getElementById('tbmDate').value = today;
document.getElementById('sessionDate').value = today;
const tbmDateEl = document.getElementById('tbmDate');
const sessionDateEl = document.getElementById('sessionDate');
if (tbmDateEl) tbmDateEl.value = today;
if (sessionDateEl) sessionDateEl.value = today;
// 이벤트 리스너 설정
setupEventListeners();
@@ -100,22 +107,16 @@ document.addEventListener('DOMContentLoaded', async () => {
// 이벤트 리스너 설정
function setupEventListeners() {
const tbmDateInput = document.getElementById('tbmDate');
if (tbmDateInput) {
tbmDateInput.addEventListener('change', () => {
const date = tbmDateInput.value;
loadTbmSessionsByDate(date);
});
}
// 날짜 선택기 제거됨 - 날짜별 그룹 뷰 사용
}
// 초기 데이터 로드
async function loadInitialData() {
try {
// 현재 로그인한 사용자 정보 가져오기
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
const userInfo = JSON.parse(localStorage.getItem('user') || '{}');
currentUser = userInfo;
console.log('👤 로그인 사용자:', currentUser);
console.log('👤 로그인 사용자:', currentUser, 'worker_id:', currentUser?.worker_id);
// 작업자 목록 로드
const workersResponse = await window.apiCall('/workers?limit=1000');
@@ -202,12 +203,7 @@ function switchTbmTab(tabName) {
if (tabName === 'tbm-input') {
loadTodayOnlyTbm();
} else if (tabName === 'tbm-manage') {
const tbmDate = document.getElementById('tbmDate');
if (tbmDate && tbmDate.value) {
loadTbmSessionsByDate(tbmDate.value);
} else {
loadTodayTbm();
}
loadRecentTbmGroupedByDate();
}
}
window.switchTbmTab = switchTbmTab;
@@ -268,36 +264,175 @@ function displayTodayTbmSessions() {
// ==================== TBM 관리 탭 ====================
// 오늘 TBM 로드 (TBM 관리 탭용)
// 오늘 TBM 로드 (TBM 관리 탭용) - 레거시 호환
async function loadTodayTbm() {
const today = getTodayKST();
document.getElementById('tbmDate').value = today;
await loadTbmSessionsByDate(today);
await loadRecentTbmGroupedByDate();
}
window.loadTodayTbm = loadTodayTbm;
// 전체 TBM 로드
// 전체 TBM 로드 - 레거시 호환
async function loadAllTbm() {
try {
const response = await window.apiCall('/tbm/sessions');
if (response && response.success) {
allSessions = response.data || [];
document.getElementById('tbmDate').value = '';
displayTbmSessions();
} else {
allSessions = [];
displayTbmSessions();
}
} catch (error) {
console.error('❌ 전체 TBM 조회 오류:', error);
showToast('전체 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
allSessions = [];
displayTbmSessions();
}
loadedDaysCount = 30; // 30일치 로드
await loadRecentTbmGroupedByDate();
}
window.loadAllTbm = loadAllTbm;
// ==================== 날짜별 그룹 TBM 로드 (새 기능) ====================
/**
* 사용자가 Admin인지 확인
*/
function isAdminUser() {
if (!currentUser) return false;
return currentUser.role === 'Admin' || currentUser.role === 'System Admin';
}
/**
* 최근 TBM을 날짜별로 그룹화하여 로드
*/
async function loadRecentTbmGroupedByDate() {
try {
const today = new Date();
const dates = [];
// 최근 N일의 날짜 생성
for (let i = 0; i < loadedDaysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
dates.push(dateStr);
}
// 각 날짜의 TBM 로드
dateGroupedSessions = {};
allLoadedSessions = [];
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
const results = await Promise.all(promises);
results.forEach((response, index) => {
const date = dates[index];
if (response && response.success && response.data && response.data.length > 0) {
let sessions = response.data;
// admin이 아니면 본인이 작성한 TBM만 필터링
if (!isAdminUser()) {
const userId = currentUser?.user_id;
const workerId = currentUser?.worker_id;
sessions = sessions.filter(s => {
return s.created_by === userId ||
s.leader_id === workerId ||
s.created_by_name === currentUser?.name;
});
}
if (sessions.length > 0) {
dateGroupedSessions[date] = sessions;
allLoadedSessions = allLoadedSessions.concat(sessions);
}
}
});
// 날짜별 그룹 표시
displayTbmGroupedByDate();
// 뷰 모드 표시
updateViewModeIndicator();
} catch (error) {
console.error('❌ TBM 날짜별 로드 오류:', error);
showToast('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
dateGroupedSessions = {};
displayTbmGroupedByDate();
}
}
window.loadRecentTbmGroupedByDate = loadRecentTbmGroupedByDate;
/**
* 뷰 모드 표시 업데이트
*/
function updateViewModeIndicator() {
const indicator = document.getElementById('viewModeIndicator');
const text = document.getElementById('viewModeText');
if (indicator && text) {
if (isAdminUser()) {
indicator.style.display = 'none'; // Admin은 표시 안 함 (전체가 기본)
} else {
indicator.style.display = 'inline-flex';
text.textContent = '내 TBM';
}
}
}
/**
* 날짜별 그룹으로 TBM 표시
*/
function displayTbmGroupedByDate() {
const container = document.getElementById('tbmDateGroupsContainer');
const emptyState = document.getElementById('emptyState');
const totalSessionsEl = document.getElementById('totalSessions');
const completedSessionsEl = document.getElementById('completedSessions');
if (!container) return;
// 날짜별로 정렬 (최신순)
const sortedDates = Object.keys(dateGroupedSessions).sort((a, b) => new Date(b) - new Date(a));
if (sortedDates.length === 0 || allLoadedSessions.length === 0) {
container.innerHTML = '';
if (emptyState) emptyState.style.display = 'flex';
if (totalSessionsEl) totalSessionsEl.textContent = '0';
if (completedSessionsEl) completedSessionsEl.textContent = '0';
return;
}
if (emptyState) emptyState.style.display = 'none';
// 통계 업데이트
const completedCount = allLoadedSessions.filter(s => s.status === 'completed').length;
if (totalSessionsEl) totalSessionsEl.textContent = allLoadedSessions.length;
if (completedSessionsEl) completedSessionsEl.textContent = completedCount;
// 날짜별 그룹 HTML 생성
const today = getTodayKST();
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
container.innerHTML = sortedDates.map(date => {
const sessions = dateGroupedSessions[date];
const dateObj = new Date(date + 'T00:00:00');
const dayName = dayNames[dateObj.getDay()];
const isToday = date === today;
// 날짜 포맷팅 (YYYY-MM-DD → MM월 DD일)
const [year, month, day] = date.split('-');
const displayDate = `${parseInt(month)}${parseInt(day)}`;
return `
<div class="date-group">
<div class="date-group-header ${isToday ? 'today' : ''}">
<span class="date-group-date">${displayDate}</span>
<span class="date-group-day">${dayName}요일${isToday ? ' (오늘)' : ''}</span>
<span class="date-group-count">${sessions.length}건</span>
</div>
<div class="date-group-grid">
${sessions.map(session => createSessionCard(session)).join('')}
</div>
</div>
`;
}).join('');
}
/**
* 더 많은 날짜 로드
*/
async function loadMoreTbmDays() {
loadedDaysCount += 7; // 7일씩 추가
await loadRecentTbmGroupedByDate();
showToast(`최근 ${loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
}
window.loadMoreTbmDays = loadMoreTbmDays;
// 특정 날짜의 TBM 세션 목록 로드
async function loadTbmSessionsByDate(date) {
try {
@@ -318,28 +453,22 @@ async function loadTbmSessionsByDate(date) {
}
}
// TBM 세션 목록 표시 (관리 탭용)
// TBM 세션 목록 표시 (관리 탭용) - 레거시 호환 (날짜별 그룹 뷰 사용)
function displayTbmSessions() {
const grid = document.getElementById('tbmSessionsGrid');
const emptyState = document.getElementById('emptyState');
const totalSessionsEl = document.getElementById('totalSessions');
const completedSessionsEl = document.getElementById('completedSessions');
if (allSessions.length === 0) {
grid.innerHTML = '';
emptyState.style.display = 'flex';
totalSessionsEl.textContent = '0';
completedSessionsEl.textContent = '0';
return;
// 새 날짜별 그룹 뷰로 리다이렉트
if (allSessions.length > 0) {
// allSessions를 날짜별로 그룹화
dateGroupedSessions = {};
allSessions.forEach(session => {
const date = formatDate(session.session_date);
if (!dateGroupedSessions[date]) {
dateGroupedSessions[date] = [];
}
dateGroupedSessions[date].push(session);
});
allLoadedSessions = allSessions;
}
emptyState.style.display = 'none';
const completedCount = allSessions.filter(s => s.status === 'completed').length;
totalSessionsEl.textContent = allSessions.length;
completedSessionsEl.textContent = completedCount;
grid.innerHTML = allSessions.map(session => createSessionCard(session)).join('');
displayTbmGroupedByDate();
}
// TBM 세션 카드 생성 (공통)
@@ -432,12 +561,12 @@ function openNewTbmModal() {
if (currentUser && currentUser.worker_id) {
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
if (worker) {
document.getElementById('leaderName').value = `${worker.worker_name} (${worker.job_type || ''})`;
document.getElementById('leaderName').value = worker.worker_name;
document.getElementById('leaderId').value = worker.worker_id;
}
} else if (currentUser && currentUser.name) {
// 관리자: 관리자로 표시
document.getElementById('leaderName').value = `${currentUser.name} (관리자)`;
// 관리자: 이름만 표시
document.getElementById('leaderName').value = currentUser.name;
document.getElementById('leaderId').value = '';
}
@@ -459,7 +588,8 @@ function populateLeaderSelect() {
// 작업자와 연결된 경우: 자동으로 선택하고 비활성화
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
if (worker) {
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name} (${worker.job_type || ''})</option>`;
const jobTypeText = worker.job_type ? ` (${worker.job_type})` : '';
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name}${jobTypeText}</option>`;
leaderSelect.disabled = true;
console.log('✅ 입력자 자동 설정:', worker.worker_name);
} else {
@@ -474,9 +604,10 @@ function populateLeaderSelect() {
);
leaderSelect.innerHTML = '<option value="">입력자 선택...</option>' +
leaders.map(w => `
<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>
`).join('');
leaders.map(w => {
const jobTypeText = w.job_type ? ` (${w.job_type})` : '';
return `<option value="${w.worker_id}">${w.worker_name}${jobTypeText}</option>`;
}).join('');
leaderSelect.disabled = false;
console.log('✅ 관리자: 입력자 선택 가능');
}
@@ -1856,65 +1987,92 @@ async function saveTeamComposition() {
}
window.saveTeamComposition = saveTeamComposition;
// 안전 체크 모달 열기
// 안전 체크 모달 열기 (기본 + 날씨별 + 작업별)
async function openSafetyCheckModal(sessionId) {
currentSessionId = sessionId;
// 기존 안전 체크 기록 로드
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
const existingRecords = response && response.success ? response.data : [];
// 필터링된 체크리스트 조회 (기본 + 날씨 + 작업별)
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`);
// 카테고리별로 그룹화
const grouped = {};
allSafetyChecks.forEach(check => {
if (!grouped[check.check_category]) {
grouped[check.check_category] = [];
}
if (!response || !response.success) {
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
}
const existingRecord = existingRecords.find(r => r.check_id === check.check_id);
grouped[check.check_category].push({
...check,
is_checked: existingRecord ? existingRecord.is_checked : false,
notes: existingRecord ? existingRecord.notes : ''
});
});
const { basic, weather, task, weatherInfo } = response.data;
const categoryNames = {
'PPE': '개인 보호 장비',
'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경',
'EMERGENCY': '비상 대응'
'EMERGENCY': '비상 대응',
'WEATHER': '날씨',
'TASK': '작업'
};
const weatherIcons = {
clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥',
cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷'
};
const container = document.getElementById('safetyChecklistContainer');
container.innerHTML = Object.keys(grouped).map(category => `
<div style="margin-bottom: 1.5rem;">
<div style="font-weight: 600; font-size: 0.9375rem; color: #374151; padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; margin-bottom: 0.5rem;">
${categoryNames[category] || category}
</div>
${grouped[category].map(check => `
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6;">
<label style="display: flex; align-items: start; gap: 0.75rem; cursor: pointer;">
<input type="checkbox"
class="safety-check"
data-check-id="${check.check_id}"
${check.is_checked ? 'checked' : ''}
${check.is_required ? 'required' : ''}
style="width: 18px; height: 18px; margin-top: 0.125rem; cursor: pointer;">
<div style="flex: 1;">
<div style="font-weight: 500; color: #111827;">
${check.check_item}
${check.is_required ? '<span style="color: #ef4444;">*</span>' : ''}
</div>
${check.description ? `<div style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">${check.description}</div>` : ''}
</div>
</label>
</div>
`).join('')}
</div>
`).join('');
let html = '';
// 1. 기본 사항 섹션
if (basic && basic.length > 0) {
const basicGrouped = groupChecksByCategory(basic);
html += `
<div class="safety-section" style="margin-bottom: 1.5rem;">
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
<span>📋</span> 기본 안전 사항 (${basic.length}개)
</div>
${renderCategoryGroups(basicGrouped, categoryNames)}
</div>
`;
}
// 2. 날씨별 섹션
if (weather && weather.length > 0) {
const weatherConditions = weatherInfo?.weather_conditions || [];
const conditionNames = weatherConditions.map(c => {
const icon = weatherIcons[c] || '🌤️';
return `${icon} ${getWeatherConditionName(c)}`;
}).join(', ') || '맑음';
html += `
<div class="safety-section" style="margin-bottom: 1.5rem;">
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #f59e0b, #d97706); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
<span>🌤️</span> 오늘 날씨 관련 (${conditionNames}) - ${weather.length}
</div>
${renderCheckItems(weather)}
</div>
`;
}
// 3. 작업별 섹션
if (task && task.length > 0) {
const taskGrouped = groupChecksByTask(task);
html += `
<div class="safety-section" style="margin-bottom: 1.5rem;">
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #10b981, #059669); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
<span>🔧</span> 작업별 안전 사항 - ${task.length}
</div>
${renderTaskGroups(taskGrouped)}
</div>
`;
}
// 체크리스트가 없는 경우
if ((!basic || basic.length === 0) && (!weather || weather.length === 0) && (!task || task.length === 0)) {
html = `
<div style="text-align: center; padding: 2rem; color: #6b7280;">
<div style="font-size: 2rem; margin-bottom: 0.5rem;">📋</div>
<p>등록된 안전 체크 항목이 없습니다.</p>
</div>
`;
}
container.innerHTML = html;
document.getElementById('safetyModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
@@ -1925,6 +2083,83 @@ async function openSafetyCheckModal(sessionId) {
}
window.openSafetyCheckModal = openSafetyCheckModal;
// 카테고리별 그룹화
function groupChecksByCategory(checks) {
return checks.reduce((acc, check) => {
const category = check.check_category || 'OTHER';
if (!acc[category]) acc[category] = [];
acc[category].push(check);
return acc;
}, {});
}
// 작업별 그룹화
function groupChecksByTask(checks) {
return checks.reduce((acc, check) => {
const taskId = check.task_id || 0;
const taskName = check.task_name || '기타 작업';
if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] };
acc[taskId].items.push(check);
return acc;
}, {});
}
// 날씨 조건명 반환
function getWeatherConditionName(code) {
const names = {
clear: '맑음', rain: '비', snow: '눈', heat: '폭염',
cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지'
};
return names[code] || code;
}
// 카테고리 그룹 렌더링
function renderCategoryGroups(grouped, categoryNames) {
return Object.keys(grouped).map(category => `
<div style="margin-bottom: 1rem; background: white; border-radius: 0.5rem; border: 1px solid #e5e7eb; overflow: hidden;">
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; padding: 0.625rem 0.875rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
${categoryNames[category] || category}
</div>
${renderCheckItems(grouped[category])}
</div>
`).join('');
}
// 작업 그룹 렌더링
function renderTaskGroups(grouped) {
return Object.values(grouped).map(group => `
<div style="margin-bottom: 1rem; background: white; border-radius: 0.5rem; border: 1px solid #e5e7eb; overflow: hidden;">
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; padding: 0.625rem 0.875rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
📋 ${group.name}
</div>
${renderCheckItems(group.items)}
</div>
`).join('');
}
// 체크 항목 렌더링
function renderCheckItems(items) {
return items.map(check => `
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6;">
<label style="display: flex; align-items: start; gap: 0.75rem; cursor: pointer;">
<input type="checkbox"
class="safety-check"
data-check-id="${check.check_id}"
${check.is_checked ? 'checked' : ''}
${check.is_required ? 'required' : ''}
style="width: 18px; height: 18px; margin-top: 0.125rem; cursor: pointer;">
<div style="flex: 1;">
<div style="font-weight: 500; color: #111827;">
${check.check_item}
${check.is_required ? '<span style="color: #ef4444;">*</span>' : ''}
</div>
${check.description ? `<div style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">${check.description}</div>` : ''}
</div>
</label>
</div>
`).join('');
}
// 안전 체크 모달 닫기
function closeSafetyModal() {
document.getElementById('safetyModal').style.display = 'none';