diff --git a/gateway/html/login.html b/gateway/html/login.html index b275384..ccd0ba3 100644 --- a/gateway/html/login.html +++ b/gateway/html/login.html @@ -171,11 +171,30 @@ } } - // 이미 로그인 되어있으면 포털로 (쿠키 또는 localStorage 체크) + // 토큰 만료 확인 + function isTokenValid(token) { + try { + var payload = JSON.parse(atob(token.split('.')[1])); + return payload.exp > Math.floor(Date.now() / 1000); + } catch (e) { + return false; + } + } + + // 이미 로그인 되어있으면 포털로 (쿠키 또는 localStorage 체크 + 만료 확인) var existingToken = ssoCookie.get('sso_token') || localStorage.getItem('sso_token'); if (existingToken && existingToken !== 'undefined' && existingToken !== 'null') { - var redirect = new URLSearchParams(location.search).get('redirect'); - window.location.href = redirect || '/'; + if (isTokenValid(existingToken)) { + var redirect = new URLSearchParams(location.search).get('redirect'); + window.location.href = redirect || '/'; + } else { + // 만료된 토큰 정리 + ssoCookie.remove('sso_token'); + ssoCookie.remove('sso_user'); + ssoCookie.remove('sso_refresh_token'); + localStorage.removeItem('sso_token'); + localStorage.removeItem('sso_user'); + } } diff --git a/system1-factory/api/config/routes.js b/system1-factory/api/config/routes.js index 7a084e2..faef062 100644 --- a/system1-factory/api/config/routes.js +++ b/system1-factory/api/config/routes.js @@ -48,7 +48,7 @@ function setupRoutes(app) { const vacationTypeRoutes = require('../routes/vacationTypeRoutes'); const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes'); const visitRequestRoutes = require('../routes/visitRequestRoutes'); - // workIssueRoutes removed - moved to System 2 (신고 시스템) + const workIssueRoutes = require('../routes/workIssueRoutes'); const departmentRoutes = require('../routes/departmentRoutes'); const patrolRoutes = require('../routes/patrolRoutes'); const notificationRoutes = require('../routes/notificationRoutes'); @@ -159,14 +159,7 @@ function setupRoutes(app) { app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리 app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 app.use('/api/tbm', tbmRoutes); // TBM 시스템 - // work-issues moved to System 2 - redirect - app.use('/api/work-issues', (req, res) => { - res.status(301).json({ - success: false, - error: '신고 시스템이 분리되었습니다', - redirect: '/report/api/work-issues' + req.url - }); - }); + app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유) app.use('/api/departments', departmentRoutes); // 부서 관리 app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템 app.use('/api/notifications', notificationRoutes); // 알림 시스템 diff --git a/system1-factory/api/controllers/tbmController.js b/system1-factory/api/controllers/tbmController.js index dd6fd39..5ab7dc6 100644 --- a/system1-factory/api/controllers/tbmController.js +++ b/system1-factory/api/controllers/tbmController.js @@ -176,6 +176,36 @@ const TbmController = { }); }, + /** + * TBM 세션 삭제 (draft 상태만) + */ + deleteSession: (req, res) => { + const { sessionId } = req.params; + + TbmModel.deleteSession(sessionId, (err, result) => { + if (err) { + console.error('TBM 세션 삭제 오류:', err); + return res.status(500).json({ + success: false, + message: 'TBM 세션 삭제 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: 'TBM 세션을 찾을 수 없거나 이미 완료된 세션입니다.' + }); + } + + res.json({ + success: true, + message: 'TBM 세션이 삭제되었습니다.' + }); + }); + }, + // ==================== 팀 구성 관련 ==================== /** diff --git a/system1-factory/api/controllers/workIssueController.js b/system1-factory/api/controllers/workIssueController.js index 0811be6..54b472b 100644 --- a/system1-factory/api/controllers/workIssueController.js +++ b/system1-factory/api/controllers/workIssueController.js @@ -26,7 +26,7 @@ exports.getAllCategories = (req, res) => { exports.getCategoriesByType = (req, res) => { const { type } = req.params; - if (!['nonconformity', 'safety'].includes(type)) { + if (!['nonconformity', 'safety', 'facility'].includes(type)) { return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' }); } diff --git a/system1-factory/api/models/tbmModel.js b/system1-factory/api/models/tbmModel.js index a191243..6100b4b 100644 --- a/system1-factory/api/models/tbmModel.js +++ b/system1-factory/api/models/tbmModel.js @@ -97,22 +97,36 @@ const TbmModel = { w.worker_name as leader_name, w.job_type as leader_job_type, w.phone_number as leader_phone, - p.project_name, - p.job_no, - p.site, - wt.name as work_type_name, - wt.category as work_type_category, - t.task_name, - t.description as task_description, u.username as created_by_username, - u.name as created_by_name + u.name as created_by_name, + COUNT(DISTINCT ta.worker_id) as team_member_count, + first_p.project_name, + first_p.job_no, + first_wt.name as work_type_name, + first_wt.category as work_type_category, + first_t.task_name, + first_t.description as task_description, + first_wp.workplace_name as work_location, + first_wc.category_name as workplace_category_name FROM tbm_sessions s LEFT JOIN workers w ON s.leader_id = w.worker_id - LEFT JOIN projects p ON s.project_id = p.project_id - LEFT JOIN work_types wt ON s.work_type_id = wt.id - LEFT JOIN tasks t ON s.task_id = t.task_id LEFT JOIN users u ON s.created_by = u.user_id + LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id + LEFT JOIN ( + SELECT * FROM tbm_team_assignments + WHERE (session_id, assignment_id) IN ( + SELECT session_id, MIN(assignment_id) + FROM tbm_team_assignments + GROUP BY session_id + ) + ) first_ta ON s.session_id = first_ta.session_id + LEFT JOIN projects first_p ON first_ta.project_id = first_p.project_id + LEFT JOIN work_types first_wt ON first_ta.work_type_id = first_wt.id + LEFT JOIN tasks first_t ON first_ta.task_id = first_t.task_id + LEFT JOIN workplaces first_wp ON first_ta.workplace_id = first_wp.workplace_id + LEFT JOIN workplace_categories first_wc ON first_ta.workplace_category_id = first_wc.category_id WHERE s.session_id = ? + GROUP BY s.session_id `; const [rows] = await db.query(sql, [sessionId]); @@ -174,6 +188,23 @@ const TbmModel = { } }, + /** + * TBM 세션 삭제 (draft 상태만 가능) + */ + deleteSession: async (sessionId, callback) => { + try { + const db = await getDb(); + // draft 상태인 세션만 삭제 허용 + const [result] = await db.query( + `DELETE FROM tbm_sessions WHERE session_id = ? AND status = 'draft'`, + [sessionId] + ); + callback(null, result); + } catch (err) { + callback(err); + } + }, + // ==================== 팀 구성 관련 ==================== /** diff --git a/system1-factory/api/routes/tbmRoutes.js b/system1-factory/api/routes/tbmRoutes.js index 5e22a7a..aa7e8eb 100644 --- a/system1-factory/api/routes/tbmRoutes.js +++ b/system1-factory/api/routes/tbmRoutes.js @@ -24,6 +24,9 @@ router.put('/sessions/:sessionId', requireAuth, TbmController.updateSession); // TBM 세션 완료 처리 router.post('/sessions/:sessionId/complete', requireAuth, TbmController.completeSession); +// TBM 세션 삭제 (draft 상태만) +router.delete('/sessions/:sessionId', requireAuth, TbmController.deleteSession); + // ==================== 팀 구성 관련 ==================== // 팀원 추가 (단일) diff --git a/system1-factory/web/components/mobile-nav.html b/system1-factory/web/components/mobile-nav.html index edd2765..a525486 100644 --- a/system1-factory/web/components/mobile-nav.html +++ b/system1-factory/web/components/mobile-nav.html @@ -1,26 +1,37 @@ - + diff --git a/system1-factory/web/components/navbar.html b/system1-factory/web/components/navbar.html index 5718092..a656d69 100644 --- a/system1-factory/web/components/navbar.html +++ b/system1-factory/web/components/navbar.html @@ -56,7 +56,7 @@ 대시보드 - + 신고 @@ -739,12 +739,7 @@ body { } .mobile-menu-btn { - width: 32px; - height: 32px; - margin-right: 0.25rem; - font-size: 1.125rem; - background: rgba(255, 255, 255, 0.12); - border: none; + display: none !important; } .user-profile { diff --git a/system1-factory/web/components/sidebar-nav.html b/system1-factory/web/components/sidebar-nav.html index 120618c..0a555f5 100644 --- a/system1-factory/web/components/sidebar-nav.html +++ b/system1-factory/web/components/sidebar-nav.html @@ -139,7 +139,7 @@ 설비 관리 - + 신고 카테고리 관리 diff --git a/system1-factory/web/css/mobile.css b/system1-factory/web/css/mobile.css index 14e8d45..40417bf 100644 --- a/system1-factory/web/css/mobile.css +++ b/system1-factory/web/css/mobile.css @@ -10,6 +10,11 @@ padding: 0 0.5rem !important; } + html, body { + overflow-x: hidden !important; + max-width: 100vw !important; + } + body { padding-top: 52px !important; } @@ -81,6 +86,19 @@ /* ========== 공통 모바일 스타일 ========== */ @media (max-width: 768px) { + /* 사이드바 마진 완전 제거 */ + .dashboard-container, + .dashboard-main, + .page-container, + .main-content, + .work-report-container, + .analysis-container { + margin-left: 0 !important; + margin-right: 0 !important; + max-width: 100% !important; + width: 100% !important; + } + /* 기본 여백 조정 */ .dashboard-main, .page-container, diff --git a/system1-factory/web/css/modern-dashboard.css b/system1-factory/web/css/modern-dashboard.css index bffcea6..33effbf 100644 --- a/system1-factory/web/css/modern-dashboard.css +++ b/system1-factory/web/css/modern-dashboard.css @@ -1050,8 +1050,9 @@ @media (max-width: 768px) { .dashboard-main { padding: 0.75rem; - margin-left: 0; + margin: 0; max-width: 100%; + width: 100%; } /* 헤더는 항상 가로 배치 유지 (navbar.html에서 관리) */ diff --git a/system1-factory/web/css/tbm.css b/system1-factory/web/css/tbm.css index 82ae095..2a4753e 100644 --- a/system1-factory/web/css/tbm.css +++ b/system1-factory/web/css/tbm.css @@ -496,10 +496,15 @@ display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: 1100; padding: 1rem; } +/* 모달 열릴 때 하단 네비게이션 숨기기 */ +body.tbm-modal-open .mobile-bottom-nav { + display: none !important; +} + .tbm-modal { background: white; border-radius: 16px; @@ -1037,6 +1042,13 @@ color: #92400e; } +/* ===== 태스크 그리드 ===== */ +.tbm-task-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; +} + /* ===== 반응형 ===== */ @media (max-width: 768px) { .tbm-container { @@ -1075,18 +1087,114 @@ .tbm-worker-select-grid { grid-template-columns: repeat(2, 1fr); + max-height: 50vh; + } + + /* 모달 → 풀스크린 시트 */ + .tbm-modal-overlay { + padding: 0; } .tbm-modal { - max-width: 100%; - max-height: 100%; + max-width: 100% !important; + width: 100%; + height: 100vh; + height: 100dvh; + max-height: none; + border-radius: 0; + overflow: hidden; + animation: mobileSlideUp 0.25s ease-out; + } + + .tbm-modal-header { + flex-shrink: 0; border-radius: 0; } - .tbm-modal-header, - .tbm-modal-footer { - border-radius: 0; + .tbm-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + padding: 1rem; } + + .tbm-modal-footer { + flex-shrink: 0; + border-radius: 0; + padding: 0.75rem 1rem; + padding-bottom: calc(0.75rem + env(safe-area-inset-bottom)); + box-shadow: 0 -2px 10px rgba(0,0,0,0.15); + background: #f8fafc; + border-top: 1px solid #e2e8f0; + } + + .tbm-modal-footer .tbm-btn { + flex: 1; + justify-content: center; + } + + /* 버튼 최소 터치 영역 44px */ + .tbm-btn { min-height: 44px; } + .tbm-btn-sm { min-height: 40px; padding: 0.5rem 1rem; } + + /* 셀렉트 버튼 확대 */ + .tbm-select-btn { + min-height: 48px; + font-size: 0.9rem; + padding: 0.75rem 1rem; + } + + /* 작업자 카드 터치 영역 */ + .tbm-worker-select-card { padding: 1rem; } + + /* 안전 체크 항목 */ + .tbm-safety-item { padding: 0.875rem; min-height: 48px; } + + /* 항목 선택 리스트 */ + .tbm-item-option { padding: 1rem; min-height: 48px; } + .tbm-item-list { max-height: 60vh; } + + /* 삭제 버튼 확대 (28px → 36px) */ + .tbm-worker-remove { width: 36px; height: 36px; font-size: 1.25rem; } + + /* 세션 카드 액션 버튼 */ + .tbm-card-footer { + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem 1rem; + } + .tbm-card-footer .tbm-btn { + flex: 1; + min-width: 0; + justify-content: center; + } + + /* 터치 피드백 */ + .tbm-btn:active, + .tbm-select-btn:active, + .tbm-worker-select-card:active, + .tbm-safety-item:active, + .tbm-item-option:active, + .tbm-session-card:active { + transform: scale(0.97); + opacity: 0.85; + transition: transform 0.1s, opacity 0.1s; + } + + /* 탭 하이라이트 제거 */ + .tbm-btn, .tbm-select-btn, .tbm-worker-select-card, + .tbm-safety-item, .tbm-item-option, .tbm-session-card, + .tbm-tab-btn, .tbm-modal-close { + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + } +} + +@keyframes mobileSlideUp { + from { transform: translateY(30%); opacity: 0.5; } + to { transform: translateY(0); opacity: 1; } } @media (max-width: 480px) { @@ -1098,6 +1206,11 @@ grid-template-columns: 1fr; } + .tbm-task-grid { + grid-template-columns: 1fr; + gap: 0.375rem; + } + .tbm-section-header { flex-direction: column; align-items: stretch; diff --git a/system1-factory/web/docs/PWA-GUIDE.md b/system1-factory/web/docs/PWA-GUIDE.md new file mode 100644 index 0000000..6bce42b --- /dev/null +++ b/system1-factory/web/docs/PWA-GUIDE.md @@ -0,0 +1,82 @@ +# PWA (Progressive Web App) 가이드 + +## 개요 + +tkfb.technicalkorea.net에 PWA를 적용하여 모바일에서 홈 화면 앱처럼 사용 가능합니다. + +- **전략**: network-first (항상 네트워크 우선, 실패 시 캐시) +- **목적**: 앱 느낌 제공 (주소창 없음, 스플래시 화면, 홈 아이콘) +- **오프라인**: 제한적 (이전 방문 페이지만 캐시에서 제공) + +## 파일 구조 + +``` +web/ +├── manifest.json # PWA 매니페스트 (앱 이름, 아이콘, 테마) +├── sw.js # 서비스 워커 (network-first 캐시) +├── js/app-init.js # SW 등록 + manifest 동적 삽입 (setupPWA 함수) +└── img/ + ├── icon-192x192.png # PWA 아이콘 (192px) + └── icon-512x512.png # PWA 아이콘 (512px) +``` + +## 주의사항 + +### sw.js 수정 시 반드시 지킬 것 + +1. **CACHE_VERSION을 반드시 올릴 것** (예: `tkfb-v1` → `tkfb-v2`) + - 버전을 안 올리면 사용자가 이전 캐시를 계속 사용 +2. **network-first 전략을 유지할 것** + - `cache-first`로 바꾸면 배포해도 사용자에게 반영 안 됨 +3. **API 요청은 절대 캐시하지 말 것** + - `if (request.url.includes('/api/')) return;` 라인 유지 +4. **가능하면 sw.js를 건드리지 말 것** + - 잘못된 sw.js가 배포되면 최대 24시간 동안 사용자 브라우저에 캐시됨 + - 일반 HTML/CSS/JS 수정은 sw.js와 무관하게 정상 반영됨 + +### 비상 복구 (킬스위치) + +sw.js에 문제가 생겼을 때 사용자 브라우저에서 서비스 워커를 해제하는 방법: + +``` +https://tkfb.technicalkorea.net/pages/dashboard.html?sw-kill +``` + +이 URL로 접속하면: +- 등록된 서비스 워커 모두 해제 +- 캐시 스토리지 전체 삭제 +- 페이지 자동 새로고침 + +### 일반 배포 시 + +- HTML/CSS/JS 파일 수정 → **sw.js 수정 불필요** (network-first라 항상 최신 파일 가져옴) +- Cloudflare 캐시 제거 → 정상 반영됨 +- 버전 파라미터 변경 (`?v=5` → `?v=6`) → 브라우저 캐시도 우회 + +### Cloudflare 캐시와 관계 + +| 상황 | 동작 | +|------|------| +| CF 캐시 제거 + 일반 파일 수정 | 즉시 반영 (network-first) | +| CF 캐시 제거 + sw.js 수정 | 최대 24시간 후 반영 (브라우저 SW 갱신 주기) | +| 네트워크 끊김 | 이전 방문 페이지만 캐시에서 제공 | +| ?sw-kill 사용 | SW + 캐시 전체 삭제, 원래 웹사이트로 동작 | + +## 홈 화면 추가 방법 + +### iPhone (iOS Safari) +1. Safari에서 tkfb.technicalkorea.net 접속 +2. 하단 공유 버튼 (□↑) 탭 +3. "홈 화면에 추가" 선택 +4. "추가" 탭 + +### Android (Chrome) +1. Chrome에서 tkfb.technicalkorea.net 접속 +2. 자동으로 "홈 화면에 추가" 배너 표시 (또는 메뉴 → "앱 설치") +3. "설치" 탭 + +## 알려진 제한사항 + +- **iOS**: tkreport, tkqc 외부 링크 클릭 시 Safari가 별도로 열림 (PWA 한계) +- **iOS**: 백그라운드 동기화 미지원 +- **전체**: 완전한 오프라인 지원은 아님 (network-first 전략) diff --git a/system1-factory/web/img/icon-192x192.png b/system1-factory/web/img/icon-192x192.png new file mode 100644 index 0000000..bf51366 Binary files /dev/null and b/system1-factory/web/img/icon-192x192.png differ diff --git a/system1-factory/web/img/icon-512x512.png b/system1-factory/web/img/icon-512x512.png new file mode 100644 index 0000000..56cdb97 Binary files /dev/null and b/system1-factory/web/img/icon-512x512.png differ diff --git a/system1-factory/web/js/api-base.js b/system1-factory/web/js/api-base.js index 412540f..87d62cc 100644 --- a/system1-factory/web/js/api-base.js +++ b/system1-factory/web/js/api-base.js @@ -128,7 +128,8 @@ var url = window.API_BASE_URL + endpoint; var config = { method: method, - headers: window.getAuthHeaders() + headers: window.getAuthHeaders(), + cache: 'no-store' }; if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) { diff --git a/system1-factory/web/js/api-config.js b/system1-factory/web/js/api-config.js index 6106538..e2cbc56 100644 --- a/system1-factory/web/js/api-config.js +++ b/system1-factory/web/js/api-config.js @@ -77,6 +77,12 @@ function clearAuthData() { localStorage.removeItem('sso_user'); localStorage.removeItem('userInfo'); localStorage.removeItem('currentUser'); + // SSO 쿠키도 삭제 (로그인 페이지 자동 리다이렉트 방지) + var cookieDomain = window.location.hostname.includes('technicalkorea.net') + ? '; domain=.technicalkorea.net' : ''; + document.cookie = 'sso_token=; path=/; max-age=0' + cookieDomain; + document.cookie = 'sso_user=; path=/; max-age=0' + cookieDomain; + document.cookie = 'sso_refresh_token=; path=/; max-age=0' + cookieDomain; } function getAuthHeaders() { diff --git a/system1-factory/web/js/app-init.js b/system1-factory/web/js/app-init.js index edbe41f..4c10013 100644 --- a/system1-factory/web/js/app-init.js +++ b/system1-factory/web/js/app-init.js @@ -491,28 +491,35 @@ } } - // 3. 사이드바 컨테이너 생성 (없으면) - let sidebarContainer = document.getElementById('sidebar-container'); - if (!sidebarContainer) { - sidebarContainer = document.createElement('div'); - sidebarContainer.id = 'sidebar-container'; - document.body.prepend(sidebarContainer); - console.log('📦 사이드바 컨테이너 생성됨'); + // 3. 네비바 로드 (모바일이면 사이드바 스킵) + var isMobile = window.innerWidth <= 768; + + if (!isMobile) { + // 데스크톱: 사이드바 컨테이너 생성 및 로드 + let sidebarContainer = document.getElementById('sidebar-container'); + if (!sidebarContainer) { + sidebarContainer = document.createElement('div'); + sidebarContainer.id = 'sidebar-container'; + document.body.prepend(sidebarContainer); + } + + console.log('📥 컴포넌트 로딩 시작 (데스크톱: 네비바+사이드바)'); + await Promise.all([ + loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)), + loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys)) + ]); + + setupNavbarEvents(); + setupSidebarEvents(); + document.body.classList.add('has-sidebar'); + } else { + // 모바일: 네비바만 로드, 사이드바 없음 + console.log('📥 컴포넌트 로딩 시작 (모바일: 네비바만)'); + await loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)); + setupNavbarEvents(); } - - // 4. 네비바와 사이드바 동시 로드 - console.log('📥 컴포넌트 로딩 시작'); - await Promise.all([ - loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)), - loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys)) - ]); console.log('✅ 컴포넌트 로딩 완료'); - // 5. 이벤트 설정 - setupNavbarEvents(); - setupSidebarEvents(); - document.body.classList.add('has-sidebar'); - // 6. 페이지 전환 로딩 인디케이터 설정 setupPageTransitionLoader(); @@ -527,9 +534,69 @@ setTimeout(loadNotifications, 200); setInterval(loadNotifications, 30000); + // 10. PWA 설정 (manifest + 서비스 워커 + iOS 메타태그) + setupPWA(); + console.log('✅ app-init 완료'); } + // ===== PWA 설정 ===== + function setupPWA() { + // manifest.json 동적 추가 + if (!document.querySelector('link[rel="manifest"]')) { + var manifest = document.createElement('link'); + manifest.rel = 'manifest'; + manifest.href = '/manifest.json'; + document.head.appendChild(manifest); + } + + // iOS 홈 화면 앱 메타태그 + if (!document.querySelector('meta[name="apple-mobile-web-app-capable"]')) { + var metaTags = [ + { name: 'apple-mobile-web-app-capable', content: 'yes' }, + { name: 'apple-mobile-web-app-status-bar-style', content: 'default' }, + { name: 'apple-mobile-web-app-title', content: 'TK공장' }, + { name: 'theme-color', content: '#1e40af' } + ]; + metaTags.forEach(function(tag) { + var meta = document.createElement('meta'); + meta.name = tag.name; + meta.content = tag.content; + document.head.appendChild(meta); + }); + + // iOS 아이콘 + var appleIcon = document.createElement('link'); + appleIcon.rel = 'apple-touch-icon'; + appleIcon.href = '/img/icon-192x192.png'; + document.head.appendChild(appleIcon); + } + + // 서비스 워커 등록 (킬스위치 포함) + if ('serviceWorker' in navigator) { + // 킬스위치: ?sw-kill 파라미터로 서비스 워커 해제 + if (window.location.search.includes('sw-kill')) { + navigator.serviceWorker.getRegistrations().then(function(regs) { + regs.forEach(function(r) { r.unregister(); }); + caches.keys().then(function(keys) { + keys.forEach(function(k) { caches.delete(k); }); + }); + console.log('SW 해제 완료'); + window.location.replace(window.location.pathname); + }); + return; + } + + navigator.serviceWorker.register('/sw.js') + .then(function(reg) { + console.log('SW 등록 완료'); + }) + .catch(function(err) { + console.warn('SW 등록 실패:', err); + }); + } + } + // ===== 페이지 전환 로딩 인디케이터 ===== function setupPageTransitionLoader() { // 로딩 바 스타일 추가 diff --git a/system1-factory/web/js/config.js b/system1-factory/web/js/config.js index 598e1cd..3127196 100644 --- a/system1-factory/web/js/config.js +++ b/system1-factory/web/js/config.js @@ -15,7 +15,7 @@ export const config = { // 페이지 경로 설정 paths: { // 로그인 페이지 경로 - loginPage: '/index.html', + loginPage: '/login', // 메인 대시보드 경로 (모든 사용자 공통) dashboard: '/pages/dashboard.html', // 하위 호환성을 위한 별칭들 diff --git a/system1-factory/web/js/safety-checklist-manage.js b/system1-factory/web/js/safety-checklist-manage.js index 4529edb..d0bdc02 100644 --- a/system1-factory/web/js/safety-checklist-manage.js +++ b/system1-factory/web/js/safety-checklist-manage.js @@ -146,7 +146,7 @@ function populateWorkTypeSelects() { const modalSelect = document.getElementById('modalWorkType'); const options = workTypes.map(wt => - `` + `` ).join(''); if (filterSelect) { @@ -204,7 +204,7 @@ function renderBasicChecks() { console.log('기본 체크항목:', basicChecks.length, '개'); if (basicChecks.length === 0) { - container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.'); + container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.') + renderInlineAddStandalone('basic'); return; } @@ -213,7 +213,7 @@ function renderBasicChecks() { container.innerHTML = Object.entries(grouped).map(([category, items]) => renderChecklistGroup(category, items) - ).join(''); + ).join('') + renderInlineAddStandalone('basic'); } /** @@ -229,8 +229,10 @@ function renderWeatherChecks() { weatherChecks = weatherChecks.filter(c => c.weather_condition === filterValue); } + const inlineRow = filterValue ? renderInlineAddStandalone('weather') : ''; + if (weatherChecks.length === 0) { - container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.'); + container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.') + inlineRow; return; } @@ -243,7 +245,7 @@ function renderWeatherChecks() { const name = conditionInfo?.condition_name || condition; return renderChecklistGroup(`${icon} ${name}`, items, condition); - }).join(''); + }).join('') + inlineRow; } /** @@ -254,6 +256,12 @@ function renderTaskChecks() { const workTypeId = document.getElementById('workTypeFilter')?.value; const taskId = document.getElementById('taskFilter')?.value; + // 공정 미선택 시 안내 + if (!workTypeId) { + container.innerHTML = renderGuideState('공정을 먼저 선택해주세요.'); + return; + } + let taskChecks = allChecks.filter(c => c.check_type === 'task'); if (taskId) { @@ -264,8 +272,10 @@ function renderTaskChecks() { taskChecks = taskChecks.filter(c => taskIds.includes(c.task_id)); } + const inlineRow = taskId ? renderInlineAddStandalone('task') : ''; + if (taskChecks.length === 0) { - container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.'); + container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.') + inlineRow; return; } @@ -277,7 +287,7 @@ function renderTaskChecks() { const taskName = task?.task_name || `작업 ${taskId}`; return renderChecklistGroup(`📋 ${taskName}`, items, null, taskId); - }).join(''); + }).join('') + inlineRow; } /** @@ -384,6 +394,18 @@ function renderEmptyState(message) { `; } +/** + * 안내 상태 렌더링 (필터 미선택 시) + */ +function renderGuideState(message) { + return ` +
+
👆
+

${message}

+
+ `; +} + /** * 날씨 필터 변경 */ @@ -409,7 +431,7 @@ async function filterByWorkType() { } try { - const response = await apiCall(`/tasks/work-type/${workTypeId}`); + const response = await apiCall(`/tasks/by-work-type/${workTypeId}`); if (response && response.success) { tasks = response.data || []; taskSelect.innerHTML = '' + @@ -446,7 +468,7 @@ async function loadModalTasks() { } try { - const response = await apiCall(`/tasks/work-type/${workTypeId}`); + const response = await apiCall(`/tasks/by-work-type/${workTypeId}`); if (response && response.success) { const modalTasks = response.data || []; taskSelect.innerHTML = '' + @@ -486,6 +508,29 @@ function openAddModal() { } toggleConditionalFields(); + + // 날씨별 탭: 현재 필터의 날씨 조건 반영 + if (currentTab === 'weather') { + const weatherFilter = document.getElementById('weatherFilter')?.value; + if (weatherFilter) { + document.getElementById('weatherCondition').value = weatherFilter; + } + } + + // 작업별 탭: 현재 필터의 공정/작업 반영 + if (currentTab === 'task') { + const workTypeId = document.getElementById('workTypeFilter')?.value; + if (workTypeId) { + document.getElementById('modalWorkType').value = workTypeId; + loadModalTasks().then(() => { + const taskId = document.getElementById('taskFilter')?.value; + if (taskId) { + document.getElementById('modalTask').value = taskId; + } + }); + } + } + showModal(); } @@ -660,6 +705,132 @@ async function deleteCheck(checkId) { } } +/** + * 인라인 추가 행 렌더링 + */ +function renderInlineAddRow(tabType) { + if (tabType === 'basic') { + const categoryOptions = Object.entries(CATEGORIES) + .filter(([key]) => !['WEATHER', 'TASK'].includes(key)) + .map(([key, val]) => ``) + .join(''); + + return ` +
+ + + +
+ `; + } + + if (tabType === 'weather') { + return ` +
+ + +
+ `; + } + + if (tabType === 'task') { + return ` +
+ + +
+ `; + } + + return ''; +} + +/** + * 인라인 추가 행을 standalone 컨테이너로 감싸기 (빈 상태용) + */ +function renderInlineAddStandalone(tabType) { + return `
${renderInlineAddRow(tabType)}
`; +} + +/** + * 인라인으로 체크 항목 추가 + */ +async function addInlineCheck(tabType) { + let checkItem, data; + + if (tabType === 'basic') { + const input = document.getElementById('inlineBasicInput'); + const categorySelect = document.getElementById('inlineCategory'); + checkItem = input?.value.trim(); + if (!checkItem) { input?.focus(); return; } + + data = { + check_type: 'basic', + check_item: checkItem, + check_category: categorySelect?.value || 'PPE', + is_required: true, + display_order: 0 + }; + } else if (tabType === 'weather') { + const input = document.getElementById('inlineWeatherInput'); + checkItem = input?.value.trim(); + if (!checkItem) { input?.focus(); return; } + + const weatherFilter = document.getElementById('weatherFilter')?.value; + if (!weatherFilter) { + showToast('날씨 조건을 먼저 선택해주세요.', 'error'); + return; + } + + data = { + check_type: 'weather', + check_item: checkItem, + check_category: 'WEATHER', + weather_condition: weatherFilter, + is_required: true, + display_order: 0 + }; + } else if (tabType === 'task') { + const input = document.getElementById('inlineTaskInput'); + checkItem = input?.value.trim(); + if (!checkItem) { input?.focus(); return; } + + const taskId = document.getElementById('taskFilter')?.value; + if (!taskId) { + showToast('작업을 먼저 선택해주세요.', 'error'); + return; + } + + data = { + check_type: 'task', + check_item: checkItem, + check_category: 'TASK', + task_id: parseInt(taskId), + is_required: true, + display_order: 0 + }; + } else { + return; + } + + try { + const response = await apiCall('/tbm/safety-checks', 'POST', data); + if (response && response.success) { + showToast('항목이 추가되었습니다.', 'success'); + await loadAllChecks(); + renderCurrentTab(); + } else { + showToast(response?.message || '추가에 실패했습니다.', 'error'); + } + } catch (error) { + console.error('인라인 추가 실패:', error); + showToast('추가 중 오류가 발생했습니다.', 'error'); + } +} + /** * 토스트 메시지 표시 */ @@ -716,3 +887,4 @@ window.filterByWorkType = filterByWorkType; window.filterByTask = filterByTask; window.loadModalTasks = loadModalTasks; window.toggleConditionalFields = toggleConditionalFields; +window.addInlineCheck = addInlineCheck; diff --git a/system1-factory/web/js/tbm.js b/system1-factory/web/js/tbm.js index 9297dd2..ea0e759 100644 --- a/system1-factory/web/js/tbm.js +++ b/system1-factory/web/js/tbm.js @@ -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' ? ` ` : ''} @@ -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 `
-
+
'; 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('') : '
선택 가능한 항목이 없습니다
'; 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 = `
-
팀장
-
${session.leader_name}
+
입력자
+
${escapeHtml(leaderDisplay)}
날짜
-
${session.session_date}
+
${escapeHtml(dateDisplay)}
-
프로젝트
-
${session.project_name || '-'}
+
상태
+
${escapeHtml(statusText)}
-
작업 장소
-
${session.work_location || '-'}
+
팀원 수
+
${parseInt(session.team_member_count) || team.length}명
-
-
작업 내용
-
${session.work_description || '-'}
-
- ${session.safety_notes ? ` -
-
⚠️ 안전 특이사항
-
${session.safety_notes}
+ ${session.project_name ? ` +
+
프로젝트
+
${escapeHtml(session.project_name)}
+
+ ` : ''} + ${session.work_location ? ` +
+
작업장
+
${escapeHtml(session.work_location)}
` : ''} `; - // 팀 구성 표시 - const teamMembers = document.getElementById('detailTeamMembers'); + // 팀 구성 표시 (작업자별 작업 정보 포함) + const teamContainer = document.getElementById('detailTeamMembers'); if (team.length === 0) { - teamMembers.innerHTML = '

등록된 팀원이 없습니다.

'; + teamContainer.innerHTML = '

등록된 팀원이 없습니다.

'; } else { - teamMembers.innerHTML = team.map(member => ` -
-
${member.worker_name}
-
${member.job_type || ''}
- ${member.is_present ? '' : '
결석
'} + // 작업자별로 그룹화 + 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 => ` +
+
+
+ ${escapeHtml(worker.worker_name)} + ${escapeHtml(worker.job_type || '')} +
+ ${!worker.is_present ? '결석' : ''} +
+
+ ${worker.tasks.map(t => ` +
+ ${t.project_name ? `${escapeHtml(t.project_name)}` : ''} + ${t.work_type_name ? `${escapeHtml(t.work_type_name)}` : ''} + ${t.task_name ? `${escapeHtml(t.task_name)}` : ''} + ${t.workplace_name ? `${escapeHtml((t.workplace_category_name ? t.workplace_category_name + ' > ' : '') + t.workplace_name)}` : ''} +
+ `).join('')} +
`).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 = ` + + + + `; + } else { + footer.innerHTML = ` + + `; + } + 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; diff --git a/system1-factory/web/manifest.json b/system1-factory/web/manifest.json new file mode 100644 index 0000000..7f7ffe5 --- /dev/null +++ b/system1-factory/web/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "TK 공장관리 - 테크니컬코리아", + "short_name": "TK공장", + "description": "테크니컬코리아 공장관리 시스템", + "start_url": "/pages/dashboard.html", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1e40af", + "orientation": "any", + "icons": [ + { + "src": "/img/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/img/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/system1-factory/web/pages/admin/accounts.html b/system1-factory/web/pages/admin/accounts.html index 622b723..c76d05d 100644 --- a/system1-factory/web/pages/admin/accounts.html +++ b/system1-factory/web/pages/admin/accounts.html @@ -279,7 +279,7 @@ - + diff --git a/system1-factory/web/pages/admin/attendance-report.html b/system1-factory/web/pages/admin/attendance-report.html index dca51ea..b2bbc91 100644 --- a/system1-factory/web/pages/admin/attendance-report.html +++ b/system1-factory/web/pages/admin/attendance-report.html @@ -8,7 +8,7 @@ - +