feat: 모바일 UX 대폭 개선 + PWA 구현 + 로그인 루프 수정

- 모바일 하단 네비: 메뉴 제거, 4개 핵심 기능(홈/TBM/작업보고/출근) SVG 아이콘
- 모바일 사이드바 스킵: 768px 이하에서 사이드바 미로드, 레이아웃 오프셋 해결
- 모바일 헤더: 햄버거 메뉴 숨김, 본문 margin/overflow 정리
- TBM 모바일: 풀스크린 모달, 저장 버튼 하단 고정, 터치 UX 개선
- PWA: manifest.json, sw.js(network-first), 앱 아이콘, iOS 메타태그, 킬스위치
- 로그인 무한루프 수정: 토큰 만료 검증, 쿠키 정리, loginPage 경로 수정
- 신고 메뉴 tkreport 리다이렉트: navbar + sidebar cross-system-link 적용
- TBM API: 작업장별 안전점검 체크리스트 조회 엔드포인트 추가
- 안전점검 체크리스트 관리 UI 개선
- tkuser: 이슈유형 관리 기능 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-24 08:20:50 +09:00
parent 3cc29c03a8
commit d36303101e
60 changed files with 1418 additions and 270 deletions

View File

@@ -31,6 +31,31 @@ let loadedDaysCount = 7; // 처음에 로드할 일수
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
let allLoadedSessions = []; // 전체 로드된 세션
// 모달 스크롤 잠금
let scrollLockY = 0;
let scrollLockCount = 0;
function lockBodyScroll() {
scrollLockCount++;
if (scrollLockCount > 1) return; // 이미 잠금 상태
scrollLockY = window.scrollY;
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
document.body.style.top = `-${scrollLockY}px`;
document.body.classList.add('tbm-modal-open');
}
function unlockBodyScroll() {
scrollLockCount--;
if (scrollLockCount > 0) return; // 아직 열린 모달 있음
scrollLockCount = 0;
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
document.body.style.top = '';
window.scrollTo(0, scrollLockY);
document.body.classList.remove('tbm-modal-open');
}
// ==================== 유틸리티 함수 ====================
/**
@@ -541,11 +566,14 @@ function createSessionCard(session) {
${session.status === 'draft' ? `
<div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
&#128101; 팀 구성
&#128101; 수정
</button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
&#10003; 안전 체크
</button>
<button class="tbm-btn tbm-btn-danger tbm-btn-sm" onclick="event.stopPropagation(); confirmDeleteTbm(${safeSessionId})">
&#128465; 삭제
</button>
</div>
` : ''}
</div>
@@ -591,7 +619,7 @@ function openNewTbmModal() {
renderWorkerTaskList();
document.getElementById('tbmModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
}
window.openNewTbmModal = openNewTbmModal;
@@ -697,7 +725,7 @@ window.loadTasksByWorkType = loadTasksByWorkType;
// TBM 모달 닫기
function closeTbmModal() {
document.getElementById('tbmModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeTbmModal = closeTbmModal;
@@ -915,7 +943,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
return `
<div style="padding: 0.75rem; margin-bottom: 0.5rem; background: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="tbm-task-grid" style="margin-bottom: 0.5rem;">
<!-- 프로젝트 선택 -->
<button type="button"
onclick="openItemSelect('project', ${safeWorkerIndex}, ${safeTaskIndex})"
@@ -992,6 +1020,7 @@ function openWorkerSelectionModal() {
}).join('');
document.getElementById('workerSelectionModal').style.display = 'flex';
lockBodyScroll();
}
window.openWorkerSelectionModal = openWorkerSelectionModal;
@@ -1090,6 +1119,7 @@ window.confirmWorkerSelection = confirmWorkerSelection;
// 작업자 선택 모달 닫기
function closeWorkerSelectionModal() {
document.getElementById('workerSelectionModal').style.display = 'none';
unlockBodyScroll();
selectedWorkersInModal.clear();
}
window.closeWorkerSelectionModal = closeWorkerSelectionModal;
@@ -1168,6 +1198,7 @@ function openBulkSettingModal() {
document.getElementById('bulkWorkplaceBtn').classList.add('btn-secondary');
document.getElementById('bulkSettingModal').style.display = 'flex';
lockBodyScroll();
}
window.openBulkSettingModal = openBulkSettingModal;
@@ -1221,6 +1252,7 @@ window.deselectAllForBulk = deselectAllForBulk;
// 일괄 설정 모달 닫기
function closeBulkSettingModal() {
document.getElementById('bulkSettingModal').style.display = 'none';
unlockBodyScroll();
isBulkMode = false;
}
window.closeBulkSettingModal = closeBulkSettingModal;
@@ -1279,6 +1311,7 @@ function openBulkItemSelect(type) {
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
modal.style.display = 'flex';
lockBodyScroll();
}
window.openBulkItemSelect = openBulkItemSelect;
@@ -1318,6 +1351,7 @@ function openBulkWorkplaceSelect() {
isBulkMode = true;
loadWorkplaceCategories();
document.getElementById('workplaceSelectModal').style.display = 'flex';
lockBodyScroll();
}
window.openBulkWorkplaceSelect = openBulkWorkplaceSelect;
@@ -1418,6 +1452,7 @@ function openItemSelect(type, workerIndex, taskIndex) {
`).join('') : '<div style="text-align: center; padding: 2rem; color: #9ca3af;">선택 가능한 항목이 없습니다</div>';
modal.style.display = 'flex';
lockBodyScroll();
}
window.openItemSelect = openItemSelect;
@@ -1449,6 +1484,7 @@ window.selectItem = selectItem;
// 항목 선택 모달 닫기
function closeItemSelectModal() {
document.getElementById('itemSelectModal').style.display = 'none';
unlockBodyScroll();
currentEditingTaskLine = null;
}
window.closeItemSelectModal = closeItemSelectModal;
@@ -1460,12 +1496,14 @@ async function openWorkplaceSelect(workerIndex, taskIndex) {
currentEditingTaskLine = { workerIndex, taskIndex };
await loadWorkplaceCategories();
document.getElementById('workplaceSelectModal').style.display = 'flex';
lockBodyScroll();
}
window.openWorkplaceSelect = openWorkplaceSelect;
// 작업장 선택 모달 닫기
function closeWorkplaceSelectModal() {
document.getElementById('workplaceSelectModal').style.display = 'none';
unlockBodyScroll();
document.getElementById('workplaceSelectionArea').style.display = 'none';
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceList').style.display = 'none';
@@ -1527,19 +1565,34 @@ async function selectCategory(categoryId, categoryName) {
// 해당 카테고리 정보 가져오기
const category = allWorkplaceCategories.find(c => c.category_id === categoryId);
const isMobile = window.innerWidth <= 768;
// 지도 또는 리스트 로드
if (category && category.layout_image) {
// 지도가 있는 경우 - 지도 영역 표시
// 지도가 있는 경우 - 지도를 기본 표시
await loadWorkplaceMap(categoryId, category.layout_image);
document.getElementById('layoutMapArea').style.display = 'block';
if (isMobile) {
// 모바일: 리스트 숨기고 "리스트로 선택" 토글 표시
document.getElementById('workplaceListSection').style.display = 'none';
document.getElementById('toggleListBtn').style.display = 'inline-flex';
document.getElementById('toggleListBtn').textContent = '리스트로 선택';
} else {
// 데스크톱: 리스트도 함께 표시
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('workplaceListSection').style.display = 'block';
document.getElementById('toggleListBtn').style.display = 'none';
}
} else {
// 지도가 없는 경우 - 리스트만 표시
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('toggleListBtn').style.display = 'none';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('workplaceListSection').style.display = 'block';
}
// 해당 카테고리의 작업장 리스트 로드 (오류 대비용)
// 해당 카테고리의 작업장 리스트 로드
await loadWorkplacesByCategory(categoryId);
// 선택 완료 버튼 비활성화 (작업장 선택 필요)
@@ -1638,22 +1691,18 @@ function confirmWorkplaceSelection() {
}
window.confirmWorkplaceSelection = confirmWorkplaceSelection;
// 리스트 토글 함수 (레거시 호환)
// 리스트 토글 함수
function toggleWorkplaceList() {
const list = document.getElementById('workplaceList');
const icon = document.getElementById('toggleListIcon');
const listSection = document.getElementById('workplaceListSection');
const btn = document.getElementById('toggleListBtn');
if (list.style.display === 'none' || list.style.display === '') {
list.style.display = 'flex';
icon.textContent = '';
btn.textContent = ' 리스트 닫기';
btn.insertBefore(icon, btn.firstChild);
if (listSection.style.display === 'none') {
listSection.style.display = 'block';
document.getElementById('workplaceList').style.display = 'flex';
btn.textContent = '리스트 숨기기';
} else {
list.style.display = 'none';
icon.textContent = '';
btn.textContent = ' 리스트 보기';
btn.insertBefore(icon, btn.firstChild);
listSection.style.display = 'none';
btn.textContent = '리스트로 선택';
}
}
window.toggleWorkplaceList = toggleWorkplaceList;
@@ -1693,8 +1742,10 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
mapImage.crossOrigin = 'anonymous';
mapImage.onload = function() {
// 캔버스 크기 설정 (최대 너비 800px)
const maxWidth = 800;
// 캔버스 크기 설정 (모바일 대응)
const maxWidth = window.innerWidth <= 768
? Math.min(window.innerWidth - 32, 600)
: 800;
const scale = mapImage.width > maxWidth ? maxWidth / mapImage.width : 1;
mapCanvas.width = mapImage.width * scale;
@@ -1712,6 +1763,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath) {
mapImage.onerror = function() {
console.error('❌ 지도 이미지 로드 실패');
document.getElementById('layoutMapArea').style.display = 'none';
document.getElementById('workplaceListSection').style.display = 'block';
document.getElementById('workplaceList').style.display = 'flex';
document.getElementById('toggleListBtn').style.display = 'none';
showToast('지도를 불러올 수 없어 리스트로 표시합니다.', 'warning');
@@ -1779,8 +1831,12 @@ function handleMapClick(event) {
if (!mapCanvas || mapRegions.length === 0) return;
const rect = mapCanvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// CSS 스케일 보정: 캔버스의 논리적 크기와 화면 표시 크기가 다를 수 있음
const scaleX = mapCanvas.width / rect.width;
const scaleY = mapCanvas.height / rect.height;
const x = (event.clientX - rect.left) * scaleX;
const y = (event.clientY - rect.top) * scaleY;
// 클릭한 위치에 있는 영역 찾기
for (let i = mapRegions.length - 1; i >= 0; i--) {
@@ -1894,7 +1950,7 @@ async function openTeamCompositionModal(sessionId) {
renderWorkerTaskList();
document.getElementById('tbmModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
} catch (error) {
console.error('❌ 팀 구성 로드 오류:', error);
@@ -1964,7 +2020,7 @@ window.deselectAllWorkers = deselectAllWorkers;
// 팀 구성 모달 닫기
function closeTeamModal() {
document.getElementById('teamModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeTeamModal = closeTeamModal;
@@ -2094,7 +2150,7 @@ async function openSafetyCheckModal(sessionId) {
container.innerHTML = html;
document.getElementById('safetyModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
} catch (error) {
console.error('❌ 안전 체크 조회 오류:', error);
@@ -2183,7 +2239,7 @@ function renderCheckItems(items) {
// 안전 체크 모달 닫기
function closeSafetyModal() {
document.getElementById('safetyModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeSafetyModal = closeSafetyModal;
@@ -2226,14 +2282,14 @@ function openCompleteTbmModal(sessionId) {
document.getElementById('endTime').value = timeString;
document.getElementById('completeModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
}
window.openCompleteTbmModal = openCompleteTbmModal;
// 완료 모달 닫기
function closeCompleteModal() {
document.getElementById('completeModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeCompleteModal = closeCompleteModal;
@@ -2289,46 +2345,86 @@ async function viewTbmSession(sessionId) {
}
// 기본 정보 표시
const leaderDisplay = session.leader_name || session.created_by_name || '-';
const dateDisplay = formatDate(session.session_date) || '-';
const statusMap = { draft: '진행중', completed: '완료', cancelled: '취소' };
const statusText = statusMap[session.status] || session.status;
const basicInfo = document.getElementById('detailBasicInfo');
basicInfo.innerHTML = `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀장</div>
<div style="font-weight: 600; color: #111827;">${session.leader_name}</div>
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">입력자</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(leaderDisplay)}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">날짜</div>
<div style="font-weight: 600; color: #111827;">${session.session_date}</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(dateDisplay)}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">프로젝트</div>
<div style="font-weight: 600; color: #111827;">${session.project_name || '-'}</div>
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">상태</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(statusText)}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 장소</div>
<div style="font-weight: 600; color: #111827;">${session.work_location || '-'}</div>
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 수</div>
<div style="font-weight: 600; color: #111827;">${parseInt(session.team_member_count) || team.length}</div>
</div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; grid-column: span 2;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업 내용</div>
<div style="color: #111827;">${session.work_description || '-'}</div>
</div>
${session.safety_notes ? `
<div style="padding: 0.75rem; background: #fef3c7; border-radius: 0.375rem; grid-column: span 2;">
<div style="font-size: 0.75rem; color: #92400e; margin-bottom: 0.25rem;">⚠️ 안전 특이사항</div>
<div style="color: #78350f;">${session.safety_notes}</div>
${session.project_name ? `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">프로젝트</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(session.project_name)}</div>
</div>
` : ''}
${session.work_location ? `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">작업장</div>
<div style="font-weight: 600; color: #111827;">${escapeHtml(session.work_location)}</div>
</div>
` : ''}
`;
// 팀 구성 표시
const teamMembers = document.getElementById('detailTeamMembers');
// 팀 구성 표시 (작업자별 작업 정보 포함)
const teamContainer = document.getElementById('detailTeamMembers');
if (team.length === 0) {
teamMembers.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">등록된 팀원이 없습니다.</p>';
teamContainer.innerHTML = '<p style="color: #6b7280; font-size: 0.875rem;">등록된 팀원이 없습니다.</p>';
} else {
teamMembers.innerHTML = team.map(member => `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<div style="font-weight: 600; color: #111827; margin-bottom: 0.25rem;">${member.worker_name}</div>
<div style="font-size: 0.75rem; color: #6b7280;">${member.job_type || ''}</div>
${member.is_present ? '' : '<div style="font-size: 0.75rem; color: #ef4444; margin-top: 0.25rem;">결석</div>'}
// 작업자별로 그룹화
const workerMap = new Map();
team.forEach(member => {
if (!workerMap.has(member.worker_id)) {
workerMap.set(member.worker_id, {
worker_name: member.worker_name,
job_type: member.job_type,
is_present: member.is_present,
tasks: []
});
}
workerMap.get(member.worker_id).tasks.push(member);
});
teamContainer.style.display = 'flex';
teamContainer.style.flexDirection = 'column';
teamContainer.style.gap = '0.75rem';
teamContainer.style.gridTemplateColumns = '';
teamContainer.innerHTML = Array.from(workerMap.values()).map(worker => `
<div style="border: 1px solid #e5e7eb; border-radius: 0.5rem; overflow: hidden;">
<div style="padding: 0.625rem 0.875rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; display: flex; align-items: center; justify-content: space-between;">
<div>
<span style="font-weight: 600;">${escapeHtml(worker.worker_name)}</span>
<span style="font-size: 0.75rem; opacity: 0.85; margin-left: 0.25rem;">${escapeHtml(worker.job_type || '')}</span>
</div>
${!worker.is_present ? '<span style="font-size: 0.75rem; background: rgba(239,68,68,0.8); padding: 0.125rem 0.5rem; border-radius: 4px;">결석</span>' : ''}
</div>
<div style="padding: 0.625rem 0.875rem;">
${worker.tasks.map(t => `
<div style="display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.375rem;">
${t.project_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 4px;">${escapeHtml(t.project_name)}</span>` : ''}
${t.work_type_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #fef3c7; color: #92400e; border-radius: 4px;">${escapeHtml(t.work_type_name)}</span>` : ''}
${t.task_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #dcfce7; color: #166534; border-radius: 4px;">${escapeHtml(t.task_name)}</span>` : ''}
${t.workplace_name ? `<span style="font-size: 0.75rem; padding: 0.125rem 0.5rem; background: #f1f5f9; color: #475569; border-radius: 4px;">${escapeHtml((t.workplace_category_name ? t.workplace_category_name + ' > ' : '') + t.workplace_name)}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`).join('');
}
@@ -2371,8 +2467,28 @@ async function viewTbmSession(sessionId) {
`).join('');
}
// 푸터 버튼 동적 생성
const footer = document.getElementById('detailModalFooter');
const safeId = parseInt(session.session_id) || 0;
console.log('📋 TBM 상세 - session_id:', safeId, 'status:', session.status);
if (session.status === 'draft') {
footer.innerHTML = `
<button type="button" class="tbm-btn tbm-btn-danger" onclick="confirmDeleteTbm(${safeId})">
삭제
</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="closeDetailModal(); openTeamCompositionModal(${safeId})">
수정
</button>
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
`;
} else {
footer.innerHTML = `
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
`;
}
document.getElementById('detailModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
} catch (error) {
console.error('❌ TBM 상세 조회 오류:', error);
@@ -2381,10 +2497,42 @@ async function viewTbmSession(sessionId) {
}
window.viewTbmSession = viewTbmSession;
// TBM 삭제 확인
function confirmDeleteTbm(sessionId) {
if (!confirm('이 TBM을 삭제하시겠습니까?\n삭제 후 복구할 수 없습니다.')) return;
deleteTbmSession(sessionId);
}
window.confirmDeleteTbm = confirmDeleteTbm;
// TBM 세션 삭제
async function deleteTbmSession(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}`, 'DELETE');
if (response && response.success) {
showToast('TBM이 삭제되었습니다.', 'success');
closeDetailModal();
// 목록 새로고침
if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm();
} else {
await loadRecentTbmGroupedByDate();
}
} else {
showToast(response?.message || 'TBM 삭제에 실패했습니다.', 'error');
}
} catch (error) {
console.error('❌ TBM 삭제 오류:', error);
showToast('TBM 삭제 중 오류가 발생했습니다.', 'error');
}
}
window.deleteTbmSession = deleteTbmSession;
// 상세보기 모달 닫기
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeDetailModal = closeDetailModal;
@@ -2448,7 +2596,7 @@ async function openHandoverModal(sessionId) {
document.getElementById('handoverNotes').value = '';
document.getElementById('handoverModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
lockBodyScroll();
} catch (error) {
console.error('❌ 인계 모달 열기 오류:', error);
@@ -2460,7 +2608,7 @@ window.openHandoverModal = openHandoverModal;
// 인계 모달 닫기
function closeHandoverModal() {
document.getElementById('handoverModal').style.display = 'none';
document.body.style.overflow = 'auto';
unlockBodyScroll();
}
window.closeHandoverModal = closeHandoverModal;