From d36303101e6733046304224bd2f824a6686f54c4 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 24 Feb 2026 08:20:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20UX=20?= =?UTF-8?q?=EB=8C=80=ED=8F=AD=20=EA=B0=9C=EC=84=A0=20+=20PWA=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20+=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모바일 하단 네비: 메뉴 제거, 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 --- gateway/html/login.html | 25 +- system1-factory/api/config/routes.js | 11 +- .../api/controllers/tbmController.js | 30 ++ .../api/controllers/workIssueController.js | 2 +- system1-factory/api/models/tbmModel.js | 53 +++- system1-factory/api/routes/tbmRoutes.js | 3 + .../web/components/mobile-nav.html | 110 ++++---- system1-factory/web/components/navbar.html | 9 +- .../web/components/sidebar-nav.html | 2 +- system1-factory/web/css/mobile.css | 18 ++ system1-factory/web/css/modern-dashboard.css | 3 +- system1-factory/web/css/tbm.css | 125 ++++++++- system1-factory/web/docs/PWA-GUIDE.md | 82 ++++++ system1-factory/web/img/icon-192x192.png | Bin 0 -> 6869 bytes system1-factory/web/img/icon-512x512.png | Bin 0 -> 31812 bytes system1-factory/web/js/api-base.js | 3 +- system1-factory/web/js/api-config.js | 6 + system1-factory/web/js/app-init.js | 105 +++++-- system1-factory/web/js/config.js | 2 +- .../web/js/safety-checklist-manage.js | 190 ++++++++++++- system1-factory/web/js/tbm.js | 260 ++++++++++++++---- system1-factory/web/manifest.json | 24 ++ system1-factory/web/pages/admin/accounts.html | 2 +- .../web/pages/admin/attendance-report.html | 2 +- .../web/pages/admin/departments.html | 2 +- .../web/pages/admin/equipment-detail.html | 2 +- .../web/pages/admin/equipments.html | 2 +- .../web/pages/admin/issue-categories.html | 2 +- .../web/pages/admin/notifications.html | 2 +- system1-factory/web/pages/admin/projects.html | 2 +- system1-factory/web/pages/admin/tasks.html | 2 +- system1-factory/web/pages/admin/workers.html | 2 +- .../web/pages/admin/workplaces.html | 2 +- .../web/pages/attendance/annual-overview.html | 2 +- .../web/pages/attendance/checkin.html | 2 +- .../web/pages/attendance/daily.html | 2 +- .../web/pages/attendance/monthly.html | 2 +- .../pages/attendance/my-vacation-info.html | 2 +- .../pages/attendance/vacation-allocation.html | 2 +- .../pages/attendance/vacation-approval.html | 2 +- .../web/pages/attendance/vacation-input.html | 2 +- .../pages/attendance/vacation-management.html | 2 +- .../pages/attendance/vacation-request.html | 2 +- .../web/pages/attendance/work-status.html | 2 +- system1-factory/web/pages/dashboard.html | 8 +- .../web/pages/inspection/daily-patrol.html | 2 +- .../web/pages/inspection/zone-detail.html | 2 +- .../web/pages/safety/checklist-manage.html | 81 ++++-- .../web/pages/safety/management.html | 2 +- system1-factory/web/pages/safety/report.html | 2 +- .../web/pages/safety/training-conduct.html | 2 +- .../web/pages/safety/visit-request.html | 2 +- system1-factory/web/pages/work/analysis.html | 2 +- .../web/pages/work/nonconformity.html | 2 +- .../web/pages/work/report-create.html | 4 +- system1-factory/web/pages/work/tbm.html | 45 ++- system1-factory/web/sw.js | 73 +++++ user-management/web/index.html | 140 +++++++++- .../web/static/js/tkuser-issue-types.js | 213 +++++++++++++- user-management/web/static/js/tkuser-tabs.js | 1 + 60 files changed, 1418 insertions(+), 270 deletions(-) create mode 100644 system1-factory/web/docs/PWA-GUIDE.md create mode 100644 system1-factory/web/img/icon-192x192.png create mode 100644 system1-factory/web/img/icon-512x512.png create mode 100644 system1-factory/web/manifest.json create mode 100644 system1-factory/web/sw.js 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 0000000000000000000000000000000000000000..bf513661e0bf290d0e67caf7dd400b5e0da4b793 GIT binary patch literal 6869 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 V)jd+eLqh!iQ&5-xBWwEg{{Zv7bIt$& literal 0 HcmV?d00001 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 @@ - +