diff --git a/CODING_GUIDE.md b/CODING_GUIDE.md index acfbb34..78adb10 100644 --- a/CODING_GUIDE.md +++ b/CODING_GUIDE.md @@ -92,36 +92,74 @@ docker-compose up -d # 수동 실행 - **일관된 헤더**: 모든 페이지에서 `` 사용 - **CSS 로딩 순서**: `design-system.css` → 페이지별 CSS -### 페이지 구조 (2026-01-20 개편) +### 페이지 구조 (2026-02-02 현행) ``` web-ui/pages/ -├── dashboard.html # 메인 대시보드 -├── work/ # 작업 관련 페이지 +├── dashboard.html # 메인 대시보드 (작업장 현황 지도 포함) +├── work/ # 작업 관련 페이지 (현장 입력/생산) +│ ├── tbm.html # TBM(Tool Box Meeting) 관리 │ ├── report-create.html # 작업보고서 작성 │ ├── report-view.html # 작업보고서 조회 │ └── analysis.html # 작업 분석 -├── admin/ # 관리자 기능 -│ ├── index.html # 관리 메뉴 허브 -│ ├── projects.html # 프로젝트 관리 +├── safety/ # 안전 관리 페이지 (신규) +│ ├── issue-report.html # 이슈 신고 +│ ├── issue-list.html # 이슈 목록 +│ ├── issue-detail.html # 이슈 상세 +│ ├── visit-request.html # 출입 신청 +│ ├── management.html # 안전 관리 (출입 승인) +│ ├── training-conduct.html # 안전교육 진행 +│ └── checklist-manage.html # 안전 체크리스트 관리 +├── attendance/ # 근태 관리 페이지 (common → attendance 변경) +│ ├── daily.html # 일일 출퇴근 입력 +│ ├── monthly.html # 월별 출퇴근 현황 +│ ├── vacation-request.html # 휴가 신청 +│ ├── vacation-management.html # 휴가 관리 (통합) +│ ├── vacation-approval.html # 휴가 승인 관리 +│ ├── vacation-input.html # 휴가 직접 입력 +│ ├── vacation-allocation.html # 휴가 발생 입력 +│ └── annual-overview.html # 연간 연차 현황 +├── admin/ # 시스템 관리 페이지 +│ ├── accounts.html # 계정 관리 +│ ├── page-access.html # 페이지 접근 권한 관리 │ ├── workers.html # 작업자 관리 +│ ├── projects.html # 프로젝트 관리 +│ ├── tasks.html # 작업 관리 +│ ├── workplaces.html # 작업장 관리 (지도 구역 설정) +│ ├── equipments.html # 설비 관리 │ ├── codes.html # 코드 관리 -│ └── accounts.html # 계정 관리 +│ └── attendance-report.html # 출퇴근-작업보고서 대조 ├── profile/ # 사용자 프로필 │ ├── info.html # 내 정보 │ └── password.html # 비밀번호 변경 └── .archived-*/ # 미사용 페이지 보관 ``` +**폴더 분류 기준** (2026-02-02 변경): +- `work/`: 현장 입력/생산 활동 (TBM, 작업보고서) +- `safety/`: 안전 관리/분석 (이슈, 출입, 안전교육) +- `attendance/`: 근태/휴가 관리 +- `admin/`: 시스템 관리 (관리자 전용) +- `profile/`: 개인 설정 페이지 + **네이밍 규칙**: - 메인 페이지: 단일 명사 (`dashboard.html`) - 관리 페이지: 복수형 명사 (`projects.html`, `workers.html`) -- 기능 페이지: 동사-명사 (`report-create.html`, `report-view.html`) -- 폴더명: 단수형, 소문자 (`work/`, `admin/`, `profile/`) +- 기능 페이지: 동사-명사 또는 명사 (`report-create.html`, `daily.html`) +- 폴더명: 단수형, 소문자 (`work/`, `safety/`, `attendance/`, `admin/`, `profile/`) **네비게이션 구조**: -- 1차: `dashboard.html` (메인 진입점) -- 2차: `admin/index.html` (관리 허브) -- 모든 페이지: navbar를 통해 profile, 작업 페이지로 이동 가능 +- 1차: `dashboard.html` (메인 진입점, 작업장 현황 지도) +- 2차: 사이드 메뉴 또는 빠른 작업 카드를 통한 각 기능 페이지 이동 +- 모든 페이지: navbar를 통해 profile, 로그아웃 가능 + +### 대기 중인 DB 마이그레이션 +페이지 구조 변경에 따른 DB 마이그레이션이 필요합니다: +```bash +cd /Users/hyungiahn/Documents/code/TK-FB-Project/api.hyungi.net +npx knex migrate:latest +``` +- 마이그레이션 파일: `db/migrations/20260202200000_reorganize_pages.js` +- 내용: pages 테이블 경로 업데이트, role_default_pages 테이블 생성 ### 표준 컴포넌트 (2026-01-20 업데이트) diff --git a/api.hyungi.net/config/routes.js b/api.hyungi.net/config/routes.js index 6bd43f8..31a4702 100644 --- a/api.hyungi.net/config/routes.js +++ b/api.hyungi.net/config/routes.js @@ -48,6 +48,7 @@ function setupRoutes(app) { const vacationTypeRoutes = require('../routes/vacationTypeRoutes'); const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes'); const visitRequestRoutes = require('../routes/visitRequestRoutes'); + const workIssueRoutes = require('../routes/workIssueRoutes'); // Rate Limiters 설정 const rateLimit = require('express-rate-limit'); @@ -151,6 +152,7 @@ function setupRoutes(app) { app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리 app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 app.use('/api/tbm', tbmRoutes); // TBM 시스템 + app.use('/api/work-issues', workIssueRoutes); // 문제 신고 시스템 app.use('/api', uploadBgRoutes); // Swagger API 문서 diff --git a/api.hyungi.net/controllers/tbmController.js b/api.hyungi.net/controllers/tbmController.js index 422b6c3..dd6fd39 100644 --- a/api.hyungi.net/controllers/tbmController.js +++ b/api.hyungi.net/controllers/tbmController.js @@ -409,6 +409,268 @@ const TbmController = { }); }, + // ==================== 필터링된 안전 체크리스트 (확장) ==================== + + /** + * 세션에 맞는 필터링된 안전 체크 항목 조회 + * 기본 + 날씨 + 작업별 체크항목 통합 + */ + getFilteredSafetyChecks: async (req, res) => { + const { sessionId } = req.params; + + try { + // 날씨 정보 확인 (이미 저장된 경우 사용, 없으면 새로 조회) + const weatherService = require('../services/weatherService'); + let weatherRecord = await weatherService.getWeatherRecord(sessionId); + let weatherConditions = []; + + if (weatherRecord && weatherRecord.weather_conditions) { + weatherConditions = weatherRecord.weather_conditions; + } else { + // 날씨 정보가 없으면 현재 날씨 조회 + const currentWeather = await weatherService.getCurrentWeather(); + weatherConditions = await weatherService.determineWeatherConditions(currentWeather); + // 날씨 기록 저장 + await weatherService.saveWeatherRecord(sessionId, currentWeather, weatherConditions); + } + + TbmModel.getFilteredSafetyChecks(sessionId, weatherConditions, (err, results) => { + if (err) { + console.error('필터링된 안전 체크 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '안전 체크리스트 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ + success: true, + data: results + }); + }); + } catch (error) { + console.error('필터링된 안전 체크 조회 오류:', error); + res.status(500).json({ + success: false, + message: '안전 체크리스트 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + }, + + /** + * 현재 날씨 조회 + */ + getCurrentWeather: async (req, res) => { + try { + const weatherService = require('../services/weatherService'); + const { nx, ny } = req.query; + + const weatherData = await weatherService.getCurrentWeather(nx, ny); + const conditions = await weatherService.determineWeatherConditions(weatherData); + const conditionList = await weatherService.getWeatherConditionList(); + + // 현재 조건의 상세 정보 매핑 + const activeConditions = conditionList.filter(c => conditions.includes(c.condition_code)); + + res.json({ + success: true, + data: { + ...weatherData, + conditions, + conditionDetails: activeConditions + } + }); + } catch (error) { + console.error('날씨 조회 오류:', error); + res.status(500).json({ + success: false, + message: '날씨 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + }, + + /** + * 세션 날씨 정보 저장 + */ + saveSessionWeather: async (req, res) => { + const { sessionId } = req.params; + const { weatherConditions } = req.body; + + try { + const weatherService = require('../services/weatherService'); + + // 현재 날씨 조회 + const weatherData = await weatherService.getCurrentWeather(); + const conditions = weatherConditions || await weatherService.determineWeatherConditions(weatherData); + + // 저장 + await weatherService.saveWeatherRecord(sessionId, weatherData, conditions); + + res.json({ + success: true, + message: '날씨 정보가 저장되었습니다.', + data: { conditions } + }); + } catch (error) { + console.error('날씨 저장 오류:', error); + res.status(500).json({ + success: false, + message: '날씨 저장 중 오류가 발생했습니다.', + error: error.message + }); + } + }, + + /** + * 세션 날씨 정보 조회 + */ + getSessionWeather: async (req, res) => { + const { sessionId } = req.params; + + try { + const weatherService = require('../services/weatherService'); + const weatherRecord = await weatherService.getWeatherRecord(sessionId); + + if (!weatherRecord) { + return res.status(404).json({ + success: false, + message: '날씨 기록이 없습니다.' + }); + } + + res.json({ + success: true, + data: weatherRecord + }); + } catch (error) { + console.error('날씨 조회 오류:', error); + res.status(500).json({ + success: false, + message: '날씨 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + }, + + /** + * 날씨 조건 목록 조회 + */ + getWeatherConditions: async (req, res) => { + try { + const weatherService = require('../services/weatherService'); + const conditions = await weatherService.getWeatherConditionList(); + + res.json({ + success: true, + data: conditions + }); + } catch (error) { + console.error('날씨 조건 조회 오류:', error); + res.status(500).json({ + success: false, + message: '날씨 조건 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + }, + + // ==================== 안전 체크항목 관리 (관리자용) ==================== + + /** + * 안전 체크 항목 생성 + */ + createSafetyCheck: (req, res) => { + const checkData = req.body; + + if (!checkData.check_category || !checkData.check_item) { + return res.status(400).json({ + success: false, + message: '카테고리와 체크 항목은 필수입니다.' + }); + } + + TbmModel.createSafetyCheck(checkData, (err, result) => { + if (err) { + console.error('안전 체크 항목 생성 오류:', err); + return res.status(500).json({ + success: false, + message: '안전 체크 항목 생성 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.status(201).json({ + success: true, + message: '안전 체크 항목이 생성되었습니다.', + data: { check_id: result.insertId } + }); + }); + }, + + /** + * 안전 체크 항목 수정 + */ + updateSafetyCheck: (req, res) => { + const { checkId } = req.params; + const checkData = req.body; + + TbmModel.updateSafetyCheck(checkId, checkData, (err, result) => { + if (err) { + console.error('안전 체크 항목 수정 오류:', err); + return res.status(500).json({ + success: false, + message: '안전 체크 항목 수정 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '안전 체크 항목을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '안전 체크 항목이 수정되었습니다.' + }); + }); + }, + + /** + * 안전 체크 항목 삭제 (비활성화) + */ + deleteSafetyCheck: (req, res) => { + const { checkId } = req.params; + + TbmModel.deleteSafetyCheck(checkId, (err, result) => { + if (err) { + console.error('안전 체크 항목 삭제 오류:', err); + return res.status(500).json({ + success: false, + message: '안전 체크 항목 삭제 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: '안전 체크 항목을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: '안전 체크 항목이 삭제되었습니다.' + }); + }); + }, + // ==================== 작업 인계 관련 ==================== /** diff --git a/api.hyungi.net/controllers/userController.js b/api.hyungi.net/controllers/userController.js index 25eca27..3252b2aa 100644 --- a/api.hyungi.net/controllers/userController.js +++ b/api.hyungi.net/controllers/userController.js @@ -218,7 +218,7 @@ const updateUser = asyncHandler(async (req, res) => { checkAdminPermission(req.user); const { id } = req.params; - const { username, name, email, phone, role, role_id, password } = req.body; + const { username, name, email, role, role_id, password } = req.body; if (!id || isNaN(id)) { throw new ValidationError('유효하지 않은 사용자 ID입니다'); @@ -227,7 +227,7 @@ const updateUser = asyncHandler(async (req, res) => { logger.info('사용자 수정 요청', { userId: id, body: req.body }); // 최소 하나의 수정 필드가 필요 - if (!username && !name && email === undefined && phone === undefined && !role && !role_id && !password) { + if (!username && !name && email === undefined && !role && !role_id && !password) { throw new ValidationError('수정할 필드가 없습니다'); } @@ -278,11 +278,6 @@ const updateUser = asyncHandler(async (req, res) => { values.push(email || null); } - if (phone !== undefined) { - updates.push('phone = ?'); - values.push(phone || null); - } - // role_id 또는 role 문자열 처리 if (role_id) { // role_id가 유효한지 확인 @@ -497,6 +492,7 @@ const getUserPageAccess = asyncHandler(async (req, res) => { const db = await getDb(); try { + // 권한 조회: user_page_access에 명시적 권한이 있으면 사용, 없으면 is_default_accessible 사용 const query = ` SELECT p.id as page_id, @@ -504,10 +500,10 @@ const getUserPageAccess = asyncHandler(async (req, res) => { p.page_name, p.page_path, p.category, - COALESCE(upa.can_access, 0) as can_access + p.is_default_accessible, + COALESCE(upa.can_access, p.is_default_accessible) as can_access FROM pages p LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ? - WHERE p.is_active = 1 ORDER BY p.category, p.display_order `; @@ -595,6 +591,55 @@ const updateUserPageAccess = asyncHandler(async (req, res) => { } }); +/** + * 사용자 비밀번호 초기화 (000000) + */ +const resetUserPassword = asyncHandler(async (req, res) => { + checkAdminPermission(req.user); + + const { id } = req.params; + + if (!id || isNaN(id)) { + throw new ValidationError('유효하지 않은 사용자 ID입니다'); + } + + const { getDb } = require('../dbPool'); + const db = await getDb(); + + try { + // 사용자 존재 확인 + const [existing] = await db.execute('SELECT user_id, username FROM users WHERE user_id = ?', [id]); + + if (existing.length === 0) { + throw new NotFoundError('사용자를 찾을 수 없습니다'); + } + + // 비밀번호를 000000으로 초기화 + const hashedPassword = await bcrypt.hash('000000', 10); + await db.execute( + 'UPDATE users SET password = ?, password_changed_at = NULL, updated_at = NOW() WHERE user_id = ?', + [hashedPassword, id] + ); + + logger.info('사용자 비밀번호 초기화 성공', { + userId: id, + username: existing[0].username, + resetBy: req.user.username + }); + + res.json({ + success: true, + message: '비밀번호가 000000으로 초기화되었습니다' + }); + } catch (error) { + if (error instanceof NotFoundError || error instanceof ValidationError) { + throw error; + } + logger.error('비밀번호 초기화 실패', { userId: id, error: error.message }); + throw new DatabaseError('비밀번호 초기화에 실패했습니다'); + } +}); + module.exports = { getAllUsers, getUserById, @@ -603,5 +648,6 @@ module.exports = { updateUserStatus, deleteUser, getUserPageAccess, - updateUserPageAccess + updateUserPageAccess, + resetUserPassword }; diff --git a/api.hyungi.net/controllers/workIssueController.js b/api.hyungi.net/controllers/workIssueController.js new file mode 100644 index 0000000..bd831be --- /dev/null +++ b/api.hyungi.net/controllers/workIssueController.js @@ -0,0 +1,643 @@ +/** + * 작업 중 문제 신고 컨트롤러 + */ + +const workIssueModel = require('../models/workIssueModel'); +const imageUploadService = require('../services/imageUploadService'); + +// ==================== 신고 카테고리 관리 ==================== + +/** + * 모든 카테고리 조회 + */ +exports.getAllCategories = (req, res) => { + workIssueModel.getAllCategories((err, categories) => { + if (err) { + console.error('카테고리 조회 실패:', err); + return res.status(500).json({ success: false, error: '카테고리 조회 실패' }); + } + res.json({ success: true, data: categories }); + }); +}; + +/** + * 타입별 카테고리 조회 + */ +exports.getCategoriesByType = (req, res) => { + const { type } = req.params; + + if (!['nonconformity', 'safety'].includes(type)) { + return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' }); + } + + workIssueModel.getCategoriesByType(type, (err, categories) => { + if (err) { + console.error('카테고리 조회 실패:', err); + return res.status(500).json({ success: false, error: '카테고리 조회 실패' }); + } + res.json({ success: true, data: categories }); + }); +}; + +/** + * 카테고리 생성 + */ +exports.createCategory = (req, res) => { + const { category_type, category_name, description, display_order } = req.body; + + if (!category_type || !category_name) { + return res.status(400).json({ success: false, error: '카테고리 타입과 이름은 필수입니다.' }); + } + + workIssueModel.createCategory( + { category_type, category_name, description, display_order }, + (err, categoryId) => { + if (err) { + console.error('카테고리 생성 실패:', err); + return res.status(500).json({ success: false, error: '카테고리 생성 실패' }); + } + res.status(201).json({ + success: true, + message: '카테고리가 생성되었습니다.', + data: { category_id: categoryId } + }); + } + ); +}; + +/** + * 카테고리 수정 + */ +exports.updateCategory = (req, res) => { + const { id } = req.params; + const { category_name, description, display_order, is_active } = req.body; + + workIssueModel.updateCategory( + id, + { category_name, description, display_order, is_active }, + (err, result) => { + if (err) { + console.error('카테고리 수정 실패:', err); + return res.status(500).json({ success: false, error: '카테고리 수정 실패' }); + } + res.json({ success: true, message: '카테고리가 수정되었습니다.' }); + } + ); +}; + +/** + * 카테고리 삭제 + */ +exports.deleteCategory = (req, res) => { + const { id } = req.params; + + workIssueModel.deleteCategory(id, (err, result) => { + if (err) { + console.error('카테고리 삭제 실패:', err); + return res.status(500).json({ success: false, error: '카테고리 삭제 실패' }); + } + res.json({ success: true, message: '카테고리가 삭제되었습니다.' }); + }); +}; + +// ==================== 사전 정의 항목 관리 ==================== + +/** + * 카테고리별 항목 조회 + */ +exports.getItemsByCategory = (req, res) => { + const { categoryId } = req.params; + + workIssueModel.getItemsByCategory(categoryId, (err, items) => { + if (err) { + console.error('항목 조회 실패:', err); + return res.status(500).json({ success: false, error: '항목 조회 실패' }); + } + res.json({ success: true, data: items }); + }); +}; + +/** + * 모든 항목 조회 + */ +exports.getAllItems = (req, res) => { + workIssueModel.getAllItems((err, items) => { + if (err) { + console.error('항목 조회 실패:', err); + return res.status(500).json({ success: false, error: '항목 조회 실패' }); + } + res.json({ success: true, data: items }); + }); +}; + +/** + * 항목 생성 + */ +exports.createItem = (req, res) => { + const { category_id, item_name, description, severity, display_order } = req.body; + + if (!category_id || !item_name) { + return res.status(400).json({ success: false, error: '카테고리 ID와 항목명은 필수입니다.' }); + } + + workIssueModel.createItem( + { category_id, item_name, description, severity, display_order }, + (err, itemId) => { + if (err) { + console.error('항목 생성 실패:', err); + return res.status(500).json({ success: false, error: '항목 생성 실패' }); + } + res.status(201).json({ + success: true, + message: '항목이 생성되었습니다.', + data: { item_id: itemId } + }); + } + ); +}; + +/** + * 항목 수정 + */ +exports.updateItem = (req, res) => { + const { id } = req.params; + const { item_name, description, severity, display_order, is_active } = req.body; + + workIssueModel.updateItem( + id, + { item_name, description, severity, display_order, is_active }, + (err, result) => { + if (err) { + console.error('항목 수정 실패:', err); + return res.status(500).json({ success: false, error: '항목 수정 실패' }); + } + res.json({ success: true, message: '항목이 수정되었습니다.' }); + } + ); +}; + +/** + * 항목 삭제 + */ +exports.deleteItem = (req, res) => { + const { id } = req.params; + + workIssueModel.deleteItem(id, (err, result) => { + if (err) { + console.error('항목 삭제 실패:', err); + return res.status(500).json({ success: false, error: '항목 삭제 실패' }); + } + res.json({ success: true, message: '항목이 삭제되었습니다.' }); + }); +}; + +// ==================== 문제 신고 관리 ==================== + +/** + * 신고 생성 + */ +exports.createReport = async (req, res) => { + try { + const { + factory_category_id, + workplace_id, + custom_location, + tbm_session_id, + visit_request_id, + issue_category_id, + issue_item_id, + additional_description, + photos = [] + } = req.body; + + const reporter_id = req.user.user_id; + + if (!issue_category_id) { + return res.status(400).json({ success: false, error: '신고 카테고리는 필수입니다.' }); + } + + // 위치 정보 검증 (지도 선택 또는 기타 위치) + if (!factory_category_id && !custom_location) { + return res.status(400).json({ success: false, error: '위치 정보는 필수입니다.' }); + } + + // 사진 저장 (최대 5장) + const photoPaths = { + photo_path1: null, + photo_path2: null, + photo_path3: null, + photo_path4: null, + photo_path5: null + }; + + for (let i = 0; i < Math.min(photos.length, 5); i++) { + if (photos[i]) { + const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue'); + if (savedPath) { + photoPaths[`photo_path${i + 1}`] = savedPath; + } + } + } + + const reportData = { + reporter_id, + factory_category_id: factory_category_id || null, + workplace_id: workplace_id || null, + custom_location: custom_location || null, + tbm_session_id: tbm_session_id || null, + visit_request_id: visit_request_id || null, + issue_category_id, + issue_item_id: issue_item_id || null, + additional_description: additional_description || null, + ...photoPaths + }; + + workIssueModel.createReport(reportData, (err, reportId) => { + if (err) { + console.error('신고 생성 실패:', err); + return res.status(500).json({ success: false, error: '신고 생성 실패' }); + } + res.status(201).json({ + success: true, + message: '문제 신고가 등록되었습니다.', + data: { report_id: reportId } + }); + }); + } catch (error) { + console.error('신고 생성 에러:', error); + res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' }); + } +}; + +/** + * 신고 목록 조회 + */ +exports.getAllReports = (req, res) => { + const filters = { + status: req.query.status, + category_type: req.query.category_type, + issue_category_id: req.query.issue_category_id, + factory_category_id: req.query.factory_category_id, + workplace_id: req.query.workplace_id, + assigned_user_id: req.query.assigned_user_id, + start_date: req.query.start_date, + end_date: req.query.end_date, + search: req.query.search, + limit: req.query.limit, + offset: req.query.offset + }; + + // 일반 사용자는 자신의 신고만 조회 (관리자 제외) + const userLevel = req.user.access_level; + if (!['admin', 'system', 'support_team'].includes(userLevel)) { + filters.reporter_id = req.user.user_id; + } + + workIssueModel.getAllReports(filters, (err, reports) => { + if (err) { + console.error('신고 목록 조회 실패:', err); + return res.status(500).json({ success: false, error: '신고 목록 조회 실패' }); + } + res.json({ success: true, data: reports }); + }); +}; + +/** + * 신고 상세 조회 + */ +exports.getReportById = (req, res) => { + const { id } = req.params; + + workIssueModel.getReportById(id, (err, report) => { + if (err) { + console.error('신고 상세 조회 실패:', err); + return res.status(500).json({ success: false, error: '신고 상세 조회 실패' }); + } + + if (!report) { + return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' }); + } + + // 권한 확인: 본인, 담당자, 또는 관리자 + const userLevel = req.user.access_level; + const isOwner = report.reporter_id === req.user.user_id; + const isAssignee = report.assigned_user_id === req.user.user_id; + const isManager = ['admin', 'system', 'support_team'].includes(userLevel); + + if (!isOwner && !isAssignee && !isManager) { + return res.status(403).json({ success: false, error: '권한이 없습니다.' }); + } + + res.json({ success: true, data: report }); + }); +}; + +/** + * 신고 수정 + */ +exports.updateReport = async (req, res) => { + try { + const { id } = req.params; + + // 기존 신고 확인 + workIssueModel.getReportById(id, async (err, report) => { + if (err) { + console.error('신고 조회 실패:', err); + return res.status(500).json({ success: false, error: '신고 조회 실패' }); + } + + if (!report) { + return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' }); + } + + // 권한 확인 + const userLevel = req.user.access_level; + const isOwner = report.reporter_id === req.user.user_id; + const isManager = ['admin', 'system'].includes(userLevel); + + if (!isOwner && !isManager) { + return res.status(403).json({ success: false, error: '수정 권한이 없습니다.' }); + } + + // 상태 확인: reported 상태에서만 수정 가능 (관리자 제외) + if (!isManager && report.status !== 'reported') { + return res.status(400).json({ success: false, error: '이미 접수된 신고는 수정할 수 없습니다.' }); + } + + const { + factory_category_id, + workplace_id, + custom_location, + issue_category_id, + issue_item_id, + additional_description, + photos = [] + } = req.body; + + // 사진 업데이트 처리 + const photoPaths = {}; + for (let i = 0; i < Math.min(photos.length, 5); i++) { + if (photos[i]) { + // 기존 사진 삭제 + const oldPath = report[`photo_path${i + 1}`]; + if (oldPath) { + await imageUploadService.deleteFile(oldPath); + } + // 새 사진 저장 + const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue'); + if (savedPath) { + photoPaths[`photo_path${i + 1}`] = savedPath; + } + } + } + + const updateData = { + factory_category_id, + workplace_id, + custom_location, + issue_category_id, + issue_item_id, + additional_description, + ...photoPaths + }; + + workIssueModel.updateReport(id, updateData, req.user.user_id, (updateErr, result) => { + if (updateErr) { + console.error('신고 수정 실패:', updateErr); + return res.status(500).json({ success: false, error: '신고 수정 실패' }); + } + res.json({ success: true, message: '신고가 수정되었습니다.' }); + }); + }); + } catch (error) { + console.error('신고 수정 에러:', error); + res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' }); + } +}; + +/** + * 신고 삭제 + */ +exports.deleteReport = async (req, res) => { + const { id } = req.params; + + workIssueModel.getReportById(id, async (err, report) => { + if (err) { + console.error('신고 조회 실패:', err); + return res.status(500).json({ success: false, error: '신고 조회 실패' }); + } + + if (!report) { + return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' }); + } + + // 권한 확인 + const userLevel = req.user.access_level; + const isOwner = report.reporter_id === req.user.user_id; + const isManager = ['admin', 'system'].includes(userLevel); + + if (!isOwner && !isManager) { + return res.status(403).json({ success: false, error: '삭제 권한이 없습니다.' }); + } + + workIssueModel.deleteReport(id, async (deleteErr, { result, photos }) => { + if (deleteErr) { + console.error('신고 삭제 실패:', deleteErr); + return res.status(500).json({ success: false, error: '신고 삭제 실패' }); + } + + // 사진 파일 삭제 + if (photos) { + const allPhotos = [ + photos.photo_path1, photos.photo_path2, photos.photo_path3, + photos.photo_path4, photos.photo_path5, + photos.resolution_photo_path1, photos.resolution_photo_path2 + ].filter(Boolean); + await imageUploadService.deleteMultipleFiles(allPhotos); + } + + res.json({ success: true, message: '신고가 삭제되었습니다.' }); + }); + }); +}; + +// ==================== 상태 관리 ==================== + +/** + * 신고 접수 + */ +exports.receiveReport = (req, res) => { + const { id } = req.params; + + workIssueModel.receiveReport(id, req.user.user_id, (err, result) => { + if (err) { + console.error('신고 접수 실패:', err); + return res.status(400).json({ success: false, error: err.message || '신고 접수 실패' }); + } + res.json({ success: true, message: '신고가 접수되었습니다.' }); + }); +}; + +/** + * 담당자 배정 + */ +exports.assignReport = (req, res) => { + const { id } = req.params; + const { assigned_department, assigned_user_id } = req.body; + + if (!assigned_user_id) { + return res.status(400).json({ success: false, error: '담당자는 필수입니다.' }); + } + + workIssueModel.assignReport(id, { + assigned_department, + assigned_user_id, + assigned_by: req.user.user_id + }, (err, result) => { + if (err) { + console.error('담당자 배정 실패:', err); + return res.status(400).json({ success: false, error: err.message || '담당자 배정 실패' }); + } + res.json({ success: true, message: '담당자가 배정되었습니다.' }); + }); +}; + +/** + * 처리 시작 + */ +exports.startProcessing = (req, res) => { + const { id } = req.params; + + workIssueModel.startProcessing(id, req.user.user_id, (err, result) => { + if (err) { + console.error('처리 시작 실패:', err); + return res.status(400).json({ success: false, error: err.message || '처리 시작 실패' }); + } + res.json({ success: true, message: '처리가 시작되었습니다.' }); + }); +}; + +/** + * 처리 완료 + */ +exports.completeReport = async (req, res) => { + try { + const { id } = req.params; + const { resolution_notes, resolution_photos = [] } = req.body; + + // 완료 사진 저장 + let resolution_photo_path1 = null; + let resolution_photo_path2 = null; + + if (resolution_photos[0]) { + resolution_photo_path1 = await imageUploadService.saveBase64Image(resolution_photos[0], 'resolution'); + } + if (resolution_photos[1]) { + resolution_photo_path2 = await imageUploadService.saveBase64Image(resolution_photos[1], 'resolution'); + } + + workIssueModel.completeReport(id, { + resolution_notes, + resolution_photo_path1, + resolution_photo_path2, + resolved_by: req.user.user_id + }, (err, result) => { + if (err) { + console.error('처리 완료 실패:', err); + return res.status(400).json({ success: false, error: err.message || '처리 완료 실패' }); + } + res.json({ success: true, message: '처리가 완료되었습니다.' }); + }); + } catch (error) { + console.error('처리 완료 에러:', error); + res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' }); + } +}; + +/** + * 신고 종료 + */ +exports.closeReport = (req, res) => { + const { id } = req.params; + + workIssueModel.closeReport(id, req.user.user_id, (err, result) => { + if (err) { + console.error('신고 종료 실패:', err); + return res.status(400).json({ success: false, error: err.message || '신고 종료 실패' }); + } + res.json({ success: true, message: '신고가 종료되었습니다.' }); + }); +}; + +/** + * 상태 변경 이력 조회 + */ +exports.getStatusLogs = (req, res) => { + const { id } = req.params; + + workIssueModel.getStatusLogs(id, (err, logs) => { + if (err) { + console.error('상태 이력 조회 실패:', err); + return res.status(500).json({ success: false, error: '상태 이력 조회 실패' }); + } + res.json({ success: true, data: logs }); + }); +}; + +// ==================== 통계 ==================== + +/** + * 통계 요약 + */ +exports.getStatsSummary = (req, res) => { + const filters = { + start_date: req.query.start_date, + end_date: req.query.end_date, + factory_category_id: req.query.factory_category_id + }; + + workIssueModel.getStatsSummary(filters, (err, stats) => { + if (err) { + console.error('통계 조회 실패:', err); + return res.status(500).json({ success: false, error: '통계 조회 실패' }); + } + res.json({ success: true, data: stats }); + }); +}; + +/** + * 카테고리별 통계 + */ +exports.getStatsByCategory = (req, res) => { + const filters = { + start_date: req.query.start_date, + end_date: req.query.end_date + }; + + workIssueModel.getStatsByCategory(filters, (err, stats) => { + if (err) { + console.error('카테고리별 통계 조회 실패:', err); + return res.status(500).json({ success: false, error: '통계 조회 실패' }); + } + res.json({ success: true, data: stats }); + }); +}; + +/** + * 작업장별 통계 + */ +exports.getStatsByWorkplace = (req, res) => { + const filters = { + start_date: req.query.start_date, + end_date: req.query.end_date, + factory_category_id: req.query.factory_category_id + }; + + workIssueModel.getStatsByWorkplace(filters, (err, stats) => { + if (err) { + console.error('작업장별 통계 조회 실패:', err); + return res.status(500).json({ success: false, error: '통계 조회 실패' }); + } + res.json({ success: true, data: stats }); + }); +}; diff --git a/api.hyungi.net/controllers/workReportController.js b/api.hyungi.net/controllers/workReportController.js index 59e85cc..2b3d555 100644 --- a/api.hyungi.net/controllers/workReportController.js +++ b/api.hyungi.net/controllers/workReportController.js @@ -106,3 +106,70 @@ exports.getSummary = asyncHandler(async (req, res) => { message: '월간 요약 조회 성공' }); }); + +// ========== 부적합 원인 관리 API ========== + +/** + * 작업 보고서의 부적합 원인 목록 조회 + */ +exports.getReportDefects = asyncHandler(async (req, res) => { + const { reportId } = req.params; + const rows = await workReportService.getReportDefectsService(reportId); + + res.json({ + success: true, + data: rows, + message: '부적합 원인 조회 성공' + }); +}); + +/** + * 부적합 원인 저장 (전체 교체) + * 기존 부적합 원인을 모두 삭제하고 새로 저장 + */ +exports.saveReportDefects = asyncHandler(async (req, res) => { + const { reportId } = req.params; + const { defects } = req.body; // [{ error_type_id, defect_hours, note }] + + const result = await workReportService.saveReportDefectsService(reportId, defects); + + res.json({ + success: true, + data: result, + message: '부적합 원인이 저장되었습니다' + }); +}); + +/** + * 부적합 원인 추가 (단일) + */ +exports.addReportDefect = asyncHandler(async (req, res) => { + const { reportId } = req.params; + const { error_type_id, defect_hours, note } = req.body; + + const result = await workReportService.addReportDefectService(reportId, { + error_type_id, + defect_hours, + note + }); + + res.json({ + success: true, + data: result, + message: '부적합 원인이 추가되었습니다' + }); +}); + +/** + * 부적합 원인 삭제 + */ +exports.removeReportDefect = asyncHandler(async (req, res) => { + const { defectId } = req.params; + const result = await workReportService.removeReportDefectService(defectId); + + res.json({ + success: true, + data: result, + message: '부적합 원인이 삭제되었습니다' + }); +}); diff --git a/api.hyungi.net/db/migrations/20260130000001_create_work_issue_system.js b/api.hyungi.net/db/migrations/20260130000001_create_work_issue_system.js new file mode 100644 index 0000000..73af1b1 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260130000001_create_work_issue_system.js @@ -0,0 +1,266 @@ +/** + * 마이그레이션: 작업 중 문제 신고 시스템 + * - 신고 카테고리 테이블 (부적합/안전) + * - 사전 정의 신고 항목 테이블 + * - 문제 신고 메인 테이블 + * - 상태 변경 이력 테이블 + */ + +exports.up = async function(knex) { + // 1. 신고 카테고리 테이블 생성 + await knex.schema.createTable('issue_report_categories', function(table) { + table.increments('category_id').primary().comment('카테고리 ID'); + table.enum('category_type', ['nonconformity', 'safety']).notNullable().comment('카테고리 유형 (부적합/안전)'); + table.string('category_name', 100).notNullable().comment('카테고리명'); + table.text('description').nullable().comment('카테고리 설명'); + table.integer('display_order').defaultTo(0).comment('표시 순서'); + table.boolean('is_active').defaultTo(true).comment('활성 여부'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index('category_type', 'idx_irc_category_type'); + table.index('is_active', 'idx_irc_is_active'); + }); + + // 카테고리 초기 데이터 삽입 + await knex('issue_report_categories').insert([ + // 부적합 사항 + { category_type: 'nonconformity', category_name: '자재누락', display_order: 1, is_active: true }, + { category_type: 'nonconformity', category_name: '설계미스', display_order: 2, is_active: true }, + { category_type: 'nonconformity', category_name: '입고불량', display_order: 3, is_active: true }, + { category_type: 'nonconformity', category_name: '검사미스', display_order: 4, is_active: true }, + { category_type: 'nonconformity', category_name: '기타 부적합', display_order: 99, is_active: true }, + // 안전 관련 + { category_type: 'safety', category_name: '보호구 미착용', display_order: 1, is_active: true }, + { category_type: 'safety', category_name: '위험구역 출입', display_order: 2, is_active: true }, + { category_type: 'safety', category_name: '안전시설 파손', display_order: 3, is_active: true }, + { category_type: 'safety', category_name: '안전수칙 위반', display_order: 4, is_active: true }, + { category_type: 'safety', category_name: '기타 안전', display_order: 99, is_active: true } + ]); + + // 2. 사전 정의 신고 항목 테이블 생성 + await knex.schema.createTable('issue_report_items', function(table) { + table.increments('item_id').primary().comment('항목 ID'); + table.integer('category_id').unsigned().notNullable().comment('소속 카테고리 ID'); + table.string('item_name', 200).notNullable().comment('신고 항목명'); + table.text('description').nullable().comment('항목 설명'); + table.enum('severity', ['low', 'medium', 'high', 'critical']).defaultTo('medium').comment('심각도'); + table.integer('display_order').defaultTo(0).comment('표시 순서'); + table.boolean('is_active').defaultTo(true).comment('활성 여부'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.foreign('category_id') + .references('category_id') + .inTable('issue_report_categories') + .onDelete('CASCADE') + .onUpdate('CASCADE'); + + table.index('category_id', 'idx_iri_category_id'); + table.index('is_active', 'idx_iri_is_active'); + }); + + // 사전 정의 항목 초기 데이터 삽입 + await knex('issue_report_items').insert([ + // 자재누락 (category_id: 1) + { category_id: 1, item_name: '배관 자재 미입고', severity: 'high', display_order: 1 }, + { category_id: 1, item_name: '피팅류 부족', severity: 'medium', display_order: 2 }, + { category_id: 1, item_name: '밸브류 미입고', severity: 'high', display_order: 3 }, + { category_id: 1, item_name: '가스켓/볼트류 부족', severity: 'low', display_order: 4 }, + { category_id: 1, item_name: '서포트 자재 부족', severity: 'medium', display_order: 5 }, + + // 설계미스 (category_id: 2) + { category_id: 2, item_name: '도면 치수 오류', severity: 'high', display_order: 1 }, + { category_id: 2, item_name: '스펙 불일치', severity: 'high', display_order: 2 }, + { category_id: 2, item_name: '누락된 상세도', severity: 'medium', display_order: 3 }, + { category_id: 2, item_name: '간섭 발생', severity: 'critical', display_order: 4 }, + + // 입고불량 (category_id: 3) + { category_id: 3, item_name: '외관 불량', severity: 'medium', display_order: 1 }, + { category_id: 3, item_name: '치수 불량', severity: 'high', display_order: 2 }, + { category_id: 3, item_name: '수량 부족', severity: 'medium', display_order: 3 }, + { category_id: 3, item_name: '재질 불일치', severity: 'critical', display_order: 4 }, + + // 검사미스 (category_id: 4) + { category_id: 4, item_name: '치수 검사 누락', severity: 'high', display_order: 1 }, + { category_id: 4, item_name: '외관 검사 누락', severity: 'medium', display_order: 2 }, + { category_id: 4, item_name: '용접 검사 누락', severity: 'critical', display_order: 3 }, + { category_id: 4, item_name: '도장 검사 누락', severity: 'medium', display_order: 4 }, + + // 보호구 미착용 (category_id: 6) + { category_id: 6, item_name: '안전모 미착용', severity: 'high', display_order: 1 }, + { category_id: 6, item_name: '안전화 미착용', severity: 'high', display_order: 2 }, + { category_id: 6, item_name: '보안경 미착용', severity: 'medium', display_order: 3 }, + { category_id: 6, item_name: '안전대 미착용', severity: 'critical', display_order: 4 }, + { category_id: 6, item_name: '귀마개 미착용', severity: 'low', display_order: 5 }, + { category_id: 6, item_name: '안전장갑 미착용', severity: 'medium', display_order: 6 }, + + // 위험구역 출입 (category_id: 7) + { category_id: 7, item_name: '통제구역 무단 출입', severity: 'critical', display_order: 1 }, + { category_id: 7, item_name: '고소 작업 구역 무단 출입', severity: 'critical', display_order: 2 }, + { category_id: 7, item_name: '밀폐공간 무단 진입', severity: 'critical', display_order: 3 }, + { category_id: 7, item_name: '장비 가동 구역 무단 접근', severity: 'high', display_order: 4 }, + + // 안전시설 파손 (category_id: 8) + { category_id: 8, item_name: '안전난간 파손', severity: 'high', display_order: 1 }, + { category_id: 8, item_name: '경고 표지판 훼손', severity: 'medium', display_order: 2 }, + { category_id: 8, item_name: '안전망 파손', severity: 'high', display_order: 3 }, + { category_id: 8, item_name: '비상조명 고장', severity: 'medium', display_order: 4 }, + { category_id: 8, item_name: '소화설비 파손', severity: 'critical', display_order: 5 }, + + // 안전수칙 위반 (category_id: 9) + { category_id: 9, item_name: '지정 통로 미사용', severity: 'medium', display_order: 1 }, + { category_id: 9, item_name: '고소 작업 안전 미준수', severity: 'critical', display_order: 2 }, + { category_id: 9, item_name: '화기 작업 절차 미준수', severity: 'critical', display_order: 3 }, + { category_id: 9, item_name: '정리정돈 미흡', severity: 'low', display_order: 4 }, + { category_id: 9, item_name: '장비 조작 절차 미준수', severity: 'high', display_order: 5 } + ]); + + // 3. 문제 신고 메인 테이블 생성 + await knex.schema.createTable('work_issue_reports', function(table) { + table.increments('report_id').primary().comment('신고 ID'); + + // 신고자 정보 + table.integer('reporter_id').notNullable().comment('신고자 user_id'); + table.datetime('report_date').defaultTo(knex.fn.now()).comment('신고 일시'); + + // 위치 정보 + table.integer('factory_category_id').unsigned().nullable().comment('공장 카테고리 ID (지도 외 위치 시 null)'); + table.integer('workplace_id').unsigned().nullable().comment('작업장 ID (지도 외 위치 시 null)'); + table.string('custom_location', 200).nullable().comment('기타 위치 (지도 외 선택 시)'); + + // 작업 연결 정보 (선택적) + table.integer('tbm_session_id').unsigned().nullable().comment('연결된 TBM 세션'); + table.integer('visit_request_id').unsigned().nullable().comment('연결된 출입 신청'); + + // 신고 내용 + table.integer('issue_category_id').unsigned().notNullable().comment('신고 카테고리 ID'); + table.integer('issue_item_id').unsigned().nullable().comment('사전 정의 신고 항목 ID'); + table.text('additional_description').nullable().comment('추가 설명'); + + // 사진 (최대 5장) + table.string('photo_path1', 255).nullable().comment('사진 1'); + table.string('photo_path2', 255).nullable().comment('사진 2'); + table.string('photo_path3', 255).nullable().comment('사진 3'); + table.string('photo_path4', 255).nullable().comment('사진 4'); + table.string('photo_path5', 255).nullable().comment('사진 5'); + + // 상태 관리 + table.enum('status', ['reported', 'received', 'in_progress', 'completed', 'closed']) + .defaultTo('reported') + .comment('상태: 신고→접수→처리중→완료→종료'); + + // 담당자 배정 + table.string('assigned_department', 100).nullable().comment('담당 부서'); + table.integer('assigned_user_id').nullable().comment('담당자 user_id'); + table.datetime('assigned_at').nullable().comment('배정 일시'); + table.integer('assigned_by').nullable().comment('배정자 user_id'); + + // 처리 정보 + table.text('resolution_notes').nullable().comment('처리 내용'); + table.string('resolution_photo_path1', 255).nullable().comment('처리 완료 사진 1'); + table.string('resolution_photo_path2', 255).nullable().comment('처리 완료 사진 2'); + table.datetime('resolved_at').nullable().comment('처리 완료 일시'); + table.integer('resolved_by').nullable().comment('처리 완료자 user_id'); + + // 수정 이력 (JSON) + table.json('modification_history').nullable().comment('수정 이력 추적'); + + // 타임스탬프 + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // 외래키 + table.foreign('reporter_id') + .references('user_id') + .inTable('users') + .onDelete('RESTRICT') + .onUpdate('CASCADE'); + + table.foreign('factory_category_id') + .references('category_id') + .inTable('workplace_categories') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + table.foreign('workplace_id') + .references('workplace_id') + .inTable('workplaces') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + table.foreign('issue_category_id') + .references('category_id') + .inTable('issue_report_categories') + .onDelete('RESTRICT') + .onUpdate('CASCADE'); + + table.foreign('issue_item_id') + .references('item_id') + .inTable('issue_report_items') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + table.foreign('assigned_user_id') + .references('user_id') + .inTable('users') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + table.foreign('assigned_by') + .references('user_id') + .inTable('users') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + table.foreign('resolved_by') + .references('user_id') + .inTable('users') + .onDelete('SET NULL') + .onUpdate('CASCADE'); + + // 인덱스 + table.index('reporter_id', 'idx_wir_reporter_id'); + table.index('status', 'idx_wir_status'); + table.index('report_date', 'idx_wir_report_date'); + table.index(['factory_category_id', 'workplace_id'], 'idx_wir_workplace'); + table.index('issue_category_id', 'idx_wir_issue_category'); + table.index('assigned_user_id', 'idx_wir_assigned_user'); + }); + + // 4. 상태 변경 이력 테이블 생성 + await knex.schema.createTable('work_issue_status_logs', function(table) { + table.increments('log_id').primary().comment('로그 ID'); + table.integer('report_id').unsigned().notNullable().comment('신고 ID'); + table.string('previous_status', 50).nullable().comment('이전 상태'); + table.string('new_status', 50).notNullable().comment('새 상태'); + table.integer('changed_by').notNullable().comment('변경자 user_id'); + table.text('change_reason').nullable().comment('변경 사유'); + table.timestamp('changed_at').defaultTo(knex.fn.now()); + + table.foreign('report_id') + .references('report_id') + .inTable('work_issue_reports') + .onDelete('CASCADE') + .onUpdate('CASCADE'); + + table.foreign('changed_by') + .references('user_id') + .inTable('users') + .onDelete('RESTRICT') + .onUpdate('CASCADE'); + + table.index('report_id', 'idx_wisl_report_id'); + table.index('changed_at', 'idx_wisl_changed_at'); + }); + + console.log('작업 중 문제 신고 시스템 테이블 생성 완료'); +}; + +exports.down = async function(knex) { + // 역순으로 테이블 삭제 + await knex.schema.dropTableIfExists('work_issue_status_logs'); + await knex.schema.dropTableIfExists('work_issue_reports'); + await knex.schema.dropTableIfExists('issue_report_items'); + await knex.schema.dropTableIfExists('issue_report_categories'); + + console.log('작업 중 문제 신고 시스템 테이블 삭제 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260130000002_register_issue_pages.js b/api.hyungi.net/db/migrations/20260130000002_register_issue_pages.js new file mode 100644 index 0000000..602e1be --- /dev/null +++ b/api.hyungi.net/db/migrations/20260130000002_register_issue_pages.js @@ -0,0 +1,50 @@ +/** + * 마이그레이션: 문제 신고 관련 페이지 등록 + */ + +exports.up = async function(knex) { + // 문제 신고 등록 페이지 + await knex('pages').insert({ + page_key: 'issue-report', + page_name: '문제 신고', + page_path: '/pages/work/issue-report.html', + category: 'work', + description: '작업 중 문제(부적합/안전) 신고 등록', + is_admin_only: 0, + display_order: 16 + }); + + // 신고 목록 페이지 + await knex('pages').insert({ + page_key: 'issue-list', + page_name: '신고 목록', + page_path: '/pages/work/issue-list.html', + category: 'work', + description: '문제 신고 목록 조회 및 관리', + is_admin_only: 0, + display_order: 17 + }); + + // 신고 상세 페이지 + await knex('pages').insert({ + page_key: 'issue-detail', + page_name: '신고 상세', + page_path: '/pages/work/issue-detail.html', + category: 'work', + description: '문제 신고 상세 조회', + is_admin_only: 0, + display_order: 18 + }); + + console.log('✅ 문제 신고 페이지 등록 완료'); +}; + +exports.down = async function(knex) { + await knex('pages').whereIn('page_key', [ + 'issue-report', + 'issue-list', + 'issue-detail' + ]).delete(); + + console.log('✅ 문제 신고 페이지 삭제 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260202000001_create_work_report_defects.js b/api.hyungi.net/db/migrations/20260202000001_create_work_report_defects.js new file mode 100644 index 0000000..15d4d84 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260202000001_create_work_report_defects.js @@ -0,0 +1,47 @@ +/** + * 작업보고서 부적합 상세 테이블 마이그레이션 + * + * 기존: error_hours, error_type_id (단일 값) + * 변경: 여러 부적합 원인 + 각 원인별 시간 저장 가능 + */ + +exports.up = function(knex) { + return knex.schema + // 1. work_report_defects 테이블 생성 + .createTable('work_report_defects', function(table) { + table.increments('defect_id').primary(); + table.integer('report_id').notNullable() + .comment('daily_work_reports의 id'); + table.integer('error_type_id').notNullable() + .comment('error_types의 id (부적합 원인)'); + table.decimal('defect_hours', 4, 1).notNullable().defaultTo(0) + .comment('해당 원인의 부적합 시간'); + table.text('note').nullable() + .comment('추가 메모'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + // 외래키 + table.foreign('report_id').references('id').inTable('daily_work_reports').onDelete('CASCADE'); + table.foreign('error_type_id').references('id').inTable('error_types'); + + // 인덱스 + table.index('report_id'); + table.index('error_type_id'); + + // 같은 보고서에 같은 원인이 중복되지 않도록 + table.unique(['report_id', 'error_type_id']); + }) + // 2. 기존 데이터 마이그레이션 (error_hours > 0인 경우) + .then(function() { + return knex.raw(` + INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, created_at) + SELECT id, error_type_id, error_hours, created_at + FROM daily_work_reports + WHERE error_hours > 0 AND error_type_id IS NOT NULL + `); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('work_report_defects'); +}; diff --git a/api.hyungi.net/db/migrations/20260202100000_expand_safety_checklist.js b/api.hyungi.net/db/migrations/20260202100000_expand_safety_checklist.js new file mode 100644 index 0000000..62d303d --- /dev/null +++ b/api.hyungi.net/db/migrations/20260202100000_expand_safety_checklist.js @@ -0,0 +1,141 @@ +/** + * 안전 체크리스트 확장 마이그레이션 + * + * 1. tbm_safety_checks 테이블 확장 (check_type, weather_condition, task_id) + * 2. weather_conditions 테이블 생성 (날씨 조건 코드) + * 3. tbm_weather_records 테이블 생성 (세션별 날씨 기록) + * 4. 초기 날씨별 체크항목 데이터 + * + * @since 2026-02-02 + */ + +exports.up = function(knex) { + return knex.schema + // 1. tbm_safety_checks 테이블 확장 + .alterTable('tbm_safety_checks', function(table) { + table.enum('check_type', ['basic', 'weather', 'task']).defaultTo('basic').after('check_category'); + table.string('weather_condition', 50).nullable().after('check_type'); + table.integer('task_id').unsigned().nullable().after('weather_condition'); + + // 인덱스 추가 + table.index('check_type'); + table.index('weather_condition'); + table.index('task_id'); + }) + + // 2. weather_conditions 테이블 생성 + .createTable('weather_conditions', function(table) { + table.string('condition_code', 50).primary(); + table.string('condition_name', 100).notNullable(); + table.text('description').nullable(); + table.string('icon', 50).nullable(); + table.decimal('temp_threshold_min', 4, 1).nullable(); // 최소 기온 기준 + table.decimal('temp_threshold_max', 4, 1).nullable(); // 최대 기온 기준 + table.decimal('wind_threshold', 4, 1).nullable(); // 풍속 기준 (m/s) + table.decimal('precip_threshold', 5, 1).nullable(); // 강수량 기준 (mm) + table.boolean('is_active').defaultTo(true); + table.integer('display_order').defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + }) + + // 3. tbm_weather_records 테이블 생성 + .createTable('tbm_weather_records', function(table) { + table.increments('record_id').primary(); + table.integer('session_id').unsigned().notNullable(); + table.date('weather_date').notNullable(); + table.decimal('temperature', 4, 1).nullable(); // 기온 (섭씨) + table.integer('humidity').nullable(); // 습도 (%) + table.decimal('wind_speed', 4, 1).nullable(); // 풍속 (m/s) + table.decimal('precipitation', 5, 1).nullable(); // 강수량 (mm) + table.string('sky_condition', 50).nullable(); // 하늘 상태 + table.string('weather_condition', 50).nullable(); // 주요 날씨 상태 + table.json('weather_conditions').nullable(); // 복수 조건 ['rain', 'wind'] + table.string('data_source', 50).defaultTo('api'); // 데이터 출처 + table.timestamp('fetched_at').nullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + // 외래키 + table.foreign('session_id').references('session_id').inTable('tbm_sessions').onDelete('CASCADE'); + + // 인덱스 + table.index('weather_date'); + table.unique(['session_id']); + }) + + // 4. 초기 데이터 삽입 + .then(function() { + // 기존 체크항목을 'basic' 유형으로 업데이트 + return knex('tbm_safety_checks').update({ check_type: 'basic' }); + }) + .then(function() { + // 날씨 조건 코드 삽입 + return knex('weather_conditions').insert([ + { condition_code: 'clear', condition_name: '맑음', description: '맑은 날씨', icon: 'sunny', display_order: 1 }, + { condition_code: 'rain', condition_name: '비', description: '비 오는 날씨', icon: 'rainy', precip_threshold: 0.1, display_order: 2 }, + { condition_code: 'snow', condition_name: '눈', description: '눈 오는 날씨', icon: 'snowy', display_order: 3 }, + { condition_code: 'heat', condition_name: '폭염', description: '기온 35도 이상', icon: 'hot', temp_threshold_min: 35, display_order: 4 }, + { condition_code: 'cold', condition_name: '한파', description: '기온 영하 10도 이하', icon: 'cold', temp_threshold_max: -10, display_order: 5 }, + { condition_code: 'wind', condition_name: '강풍', description: '풍속 10m/s 이상', icon: 'windy', wind_threshold: 10, display_order: 6 }, + { condition_code: 'fog', condition_name: '안개', description: '시정 1km 미만', icon: 'foggy', display_order: 7 }, + { condition_code: 'dust', condition_name: '미세먼지', description: '미세먼지 나쁨 이상', icon: 'dusty', display_order: 8 } + ]); + }) + .then(function() { + // 날씨별 안전 체크항목 삽입 + return knex('tbm_safety_checks').insert([ + // 비 (rain) + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '우의/우산 준비 확인', description: '비 오는 날 우의 또는 우산 준비 여부', is_required: true, display_order: 1 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '미끄럼 방지 조치 확인', description: '빗물로 인한 미끄러움 방지 조치', is_required: true, display_order: 2 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '전기 작업 중단 여부 확인', description: '우천 시 전기 작업 중단 필요성 확인', is_required: true, display_order: 3 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '배수 상태 확인', description: '작업장 배수 상태 점검', is_required: false, display_order: 4 }, + + // 눈 (snow) + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '제설 작업 완료 확인', description: '작업장 주변 제설 작업 완료 여부', is_required: true, display_order: 1 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '동파 방지 조치 확인', description: '배관 및 설비 동파 방지 조치', is_required: true, display_order: 2 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '미끄럼 방지 모래/염화칼슘 비치', description: '미끄럼 방지를 위한 모래 또는 염화칼슘 비치', is_required: true, display_order: 3 }, + + // 폭염 (heat) + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '그늘막/휴게소 확보', description: '무더위 휴식을 위한 그늘막 또는 휴게소 확보', is_required: true, display_order: 1 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '음료수/식염 포도당 비치', description: '열사병 예방을 위한 음료수 및 염분 보충제 비치', is_required: true, display_order: 2 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '무더위 휴식 시간 확보', description: '10~15시 사이 충분한 휴식 시간 확보', is_required: true, display_order: 3 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '작업자 건강 상태 확인', description: '열사병 증상 체크 및 건강 상태 확인', is_required: true, display_order: 4 }, + + // 한파 (cold) + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '방한복/방한장갑 착용 확인', description: '동상 방지를 위한 방한복 및 방한장갑 착용', is_required: true, display_order: 1 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '난방시설 가동 확인', description: '휴게 공간 난방시설 가동 상태 확인', is_required: true, display_order: 2 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '온열 음료 비치', description: '체온 유지를 위한 따뜻한 음료 비치', is_required: false, display_order: 3 }, + + // 강풍 (wind) + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '고소 작업 중단 여부 확인', description: '강풍 시 고소 작업 중단 필요성 확인', is_required: true, display_order: 1 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '자재/장비 결박 확인', description: '바람에 날릴 수 있는 자재 및 장비 고정', is_required: true, display_order: 2 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '가설물 안전 점검', description: '가설 구조물 및 비계 안전 상태 점검', is_required: true, display_order: 3 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '크레인 작업 중단 여부 확인', description: '강풍 시 크레인 작업 중단 필요성 확인', is_required: true, display_order: 4 }, + + // 안개 (fog) + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '경광등/조명 확보', description: '시정 확보를 위한 경광등 및 조명 설치', is_required: true, display_order: 1 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '차량 운행 주의 안내', description: '안개로 인한 차량 운행 주의 안내', is_required: true, display_order: 2 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '작업 구역 표시 강화', description: '시인성 확보를 위한 작업 구역 표시 강화', is_required: false, display_order: 3 }, + + // 미세먼지 (dust) + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '보호 마스크 착용 확인', description: 'KF94 이상 마스크 착용 여부 확인', is_required: true, display_order: 1 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '실외 작업 시간 조정', description: '미세먼지 농도에 따른 실외 작업 시간 조정', is_required: true, display_order: 2 }, + { check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '호흡기 질환자 실내 배치', description: '호흡기 질환 작업자 실내 작업 배치', is_required: false, display_order: 3 } + ]); + }); +}; + +exports.down = function(knex) { + return knex.schema + .dropTableIfExists('tbm_weather_records') + .dropTableIfExists('weather_conditions') + .then(function() { + return knex.schema.alterTable('tbm_safety_checks', function(table) { + table.dropIndex('check_type'); + table.dropIndex('weather_condition'); + table.dropIndex('task_id'); + table.dropColumn('check_type'); + table.dropColumn('weather_condition'); + table.dropColumn('task_id'); + }); + }); +}; diff --git a/api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js b/api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js new file mode 100644 index 0000000..df21ca4 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js @@ -0,0 +1,319 @@ +/** + * 페이지 구조 재구성 마이그레이션 + * - 페이지 경로 업데이트 (safety/, attendance/ 폴더로 이동) + * - 카테고리 재분류 + * - 역할별 기본 페이지 권한 테이블 생성 + */ + +exports.up = async function(knex) { + // 1. 페이지 경로 업데이트 - safety 폴더로 이동된 페이지들 + const safetyPageUpdates = [ + { + old_key: 'issue-report', + new_key: 'safety.issue_report', + new_path: '/pages/safety/issue-report.html', + new_category: 'safety', + new_name: '이슈 신고' + }, + { + old_key: 'issue-list', + new_key: 'safety.issue_list', + new_path: '/pages/safety/issue-list.html', + new_category: 'safety', + new_name: '이슈 목록' + }, + { + old_key: 'issue-detail', + new_key: 'safety.issue_detail', + new_path: '/pages/safety/issue-detail.html', + new_category: 'safety', + new_name: '이슈 상세' + }, + { + old_key: 'visit-request', + new_key: 'safety.visit_request', + new_path: '/pages/safety/visit-request.html', + new_category: 'safety', + new_name: '방문 요청' + }, + { + old_key: 'safety-management', + new_key: 'safety.management', + new_path: '/pages/safety/management.html', + new_category: 'safety', + new_name: '안전 관리' + }, + { + old_key: 'safety-training-conduct', + new_key: 'safety.training_conduct', + new_path: '/pages/safety/training-conduct.html', + new_category: 'safety', + new_name: '안전교육 진행' + } + ]; + + // 2. 페이지 경로 업데이트 - attendance 폴더로 이동된 페이지들 + const attendancePageUpdates = [ + { + old_key: 'daily-attendance', + new_key: 'attendance.daily', + new_path: '/pages/attendance/daily.html', + new_category: 'attendance', + new_name: '일일 출퇴근' + }, + { + old_key: 'monthly-attendance', + new_key: 'attendance.monthly', + new_path: '/pages/attendance/monthly.html', + new_category: 'attendance', + new_name: '월간 근태' + }, + { + old_key: 'annual-vacation-overview', + new_key: 'attendance.annual_overview', + new_path: '/pages/attendance/annual-overview.html', + new_category: 'attendance', + new_name: '연간 휴가 현황' + }, + { + old_key: 'vacation-request', + new_key: 'attendance.vacation_request', + new_path: '/pages/attendance/vacation-request.html', + new_category: 'attendance', + new_name: '휴가 신청' + }, + { + old_key: 'vacation-management', + new_key: 'attendance.vacation_management', + new_path: '/pages/attendance/vacation-management.html', + new_category: 'attendance', + new_name: '휴가 관리' + }, + { + old_key: 'vacation-allocation', + new_key: 'attendance.vacation_allocation', + new_path: '/pages/attendance/vacation-allocation.html', + new_category: 'attendance', + new_name: '휴가 발생 입력' + } + ]; + + // 3. admin 폴더 내 파일명 변경 + const adminPageUpdates = [ + { + old_key: 'attendance-report-comparison', + new_key: 'admin.attendance_report', + new_path: '/pages/admin/attendance-report.html', + new_category: 'admin', + new_name: '출퇴근-보고서 대조' + } + ]; + + // 모든 업데이트 실행 + const allUpdates = [...safetyPageUpdates, ...attendancePageUpdates, ...adminPageUpdates]; + + for (const update of allUpdates) { + await knex('pages') + .where('page_key', update.old_key) + .update({ + page_key: update.new_key, + page_path: update.new_path, + category: update.new_category, + page_name: update.new_name + }); + } + + // 4. 안전 체크리스트 관리 페이지 추가 (새로 생성된 페이지) + const existingChecklistPage = await knex('pages') + .where('page_key', 'safety.checklist_manage') + .orWhere('page_key', 'safety-checklist-manage') + .first(); + + if (!existingChecklistPage) { + await knex('pages').insert({ + page_key: 'safety.checklist_manage', + page_name: '안전 체크리스트 관리', + page_path: '/pages/safety/checklist-manage.html', + category: 'safety', + description: '안전 체크리스트 항목 관리', + is_admin_only: 1, + display_order: 50 + }); + } + + // 5. 휴가 승인/직접입력 페이지 추가 (새로 생성된 페이지인 경우) + const vacationPages = [ + { + page_key: 'attendance.vacation_approval', + page_name: '휴가 승인 관리', + page_path: '/pages/attendance/vacation-approval.html', + category: 'attendance', + description: '휴가 신청 승인/거부', + is_admin_only: 1, + display_order: 65 + }, + { + page_key: 'attendance.vacation_input', + page_name: '휴가 직접 입력', + page_path: '/pages/attendance/vacation-input.html', + category: 'attendance', + description: '관리자 휴가 직접 입력', + is_admin_only: 1, + display_order: 66 + } + ]; + + for (const page of vacationPages) { + const existing = await knex('pages').where('page_key', page.page_key).first(); + if (!existing) { + await knex('pages').insert(page); + } + } + + // 6. role_default_pages 테이블 생성 (역할별 기본 페이지 권한) + const tableExists = await knex.schema.hasTable('role_default_pages'); + if (!tableExists) { + await knex.schema.createTable('role_default_pages', (table) => { + table.integer('role_id').unsigned().notNullable() + .references('id').inTable('roles').onDelete('CASCADE'); + table.integer('page_id').unsigned().notNullable() + .references('id').inTable('pages').onDelete('CASCADE'); + table.primary(['role_id', 'page_id']); + table.timestamps(true, true); + }); + } + + // 7. 기본 역할-페이지 매핑 데이터 삽입 + // 역할 조회 + const roles = await knex('roles').select('id', 'name'); + const pages = await knex('pages').select('id', 'page_key', 'category'); + + const roleMap = {}; + roles.forEach(r => { roleMap[r.name] = r.id; }); + + const pageMap = {}; + pages.forEach(p => { pageMap[p.page_key] = p.id; }); + + // Worker 역할 기본 페이지 (대시보드, 작업보고서, 휴가신청) + const workerPages = [ + 'dashboard', + 'work.report_create', + 'work.report_view', + 'attendance.vacation_request' + ]; + + // Leader 역할 기본 페이지 (Worker + TBM, 안전, 근태 일부) + const leaderPages = [ + ...workerPages, + 'work.tbm', + 'work.analysis', + 'safety.issue_report', + 'safety.issue_list', + 'attendance.daily', + 'attendance.monthly' + ]; + + // SafetyManager 역할 기본 페이지 (Leader + 안전 전체) + const safetyManagerPages = [ + ...leaderPages, + 'safety.issue_detail', + 'safety.visit_request', + 'safety.management', + 'safety.training_conduct', + 'safety.checklist_manage' + ]; + + // 역할별 페이지 매핑 삽입 + const rolePageMappings = []; + + if (roleMap['Worker']) { + workerPages.forEach(pageKey => { + if (pageMap[pageKey]) { + rolePageMappings.push({ role_id: roleMap['Worker'], page_id: pageMap[pageKey] }); + } + }); + } + + if (roleMap['Leader']) { + leaderPages.forEach(pageKey => { + if (pageMap[pageKey]) { + rolePageMappings.push({ role_id: roleMap['Leader'], page_id: pageMap[pageKey] }); + } + }); + } + + if (roleMap['SafetyManager']) { + safetyManagerPages.forEach(pageKey => { + if (pageMap[pageKey]) { + rolePageMappings.push({ role_id: roleMap['SafetyManager'], page_id: pageMap[pageKey] }); + } + }); + } + + // 중복 제거 후 삽입 + for (const mapping of rolePageMappings) { + const existing = await knex('role_default_pages') + .where('role_id', mapping.role_id) + .where('page_id', mapping.page_id) + .first(); + + if (!existing) { + await knex('role_default_pages').insert(mapping); + } + } + + console.log('페이지 구조 재구성 완료'); + console.log(`- 업데이트된 페이지: ${allUpdates.length}개`); + console.log(`- 역할별 기본 페이지 매핑: ${rolePageMappings.length}개`); +}; + +exports.down = async function(knex) { + // 1. role_default_pages 테이블 삭제 + await knex.schema.dropTableIfExists('role_default_pages'); + + // 2. 페이지 경로 원복 - safety → work/admin + const safetyRevert = [ + { new_key: 'safety.issue_report', old_key: 'issue-report', old_path: '/pages/work/issue-report.html', old_category: 'work' }, + { new_key: 'safety.issue_list', old_key: 'issue-list', old_path: '/pages/work/issue-list.html', old_category: 'work' }, + { new_key: 'safety.issue_detail', old_key: 'issue-detail', old_path: '/pages/work/issue-detail.html', old_category: 'work' }, + { new_key: 'safety.visit_request', old_key: 'visit-request', old_path: '/pages/work/visit-request.html', old_category: 'work' }, + { new_key: 'safety.management', old_key: 'safety-management', old_path: '/pages/admin/safety-management.html', old_category: 'admin' }, + { new_key: 'safety.training_conduct', old_key: 'safety-training-conduct', old_path: '/pages/admin/safety-training-conduct.html', old_category: 'admin' }, + ]; + + // 3. 페이지 경로 원복 - attendance → common + const attendanceRevert = [ + { new_key: 'attendance.daily', old_key: 'daily-attendance', old_path: '/pages/common/daily-attendance.html', old_category: 'common' }, + { new_key: 'attendance.monthly', old_key: 'monthly-attendance', old_path: '/pages/common/monthly-attendance.html', old_category: 'common' }, + { new_key: 'attendance.annual_overview', old_key: 'annual-vacation-overview', old_path: '/pages/common/annual-vacation-overview.html', old_category: 'common' }, + { new_key: 'attendance.vacation_request', old_key: 'vacation-request', old_path: '/pages/common/vacation-request.html', old_category: 'common' }, + { new_key: 'attendance.vacation_management', old_key: 'vacation-management', old_path: '/pages/common/vacation-management.html', old_category: 'common' }, + { new_key: 'attendance.vacation_allocation', old_key: 'vacation-allocation', old_path: '/pages/common/vacation-allocation.html', old_category: 'common' }, + ]; + + // 4. admin 파일명 원복 + const adminRevert = [ + { new_key: 'admin.attendance_report', old_key: 'attendance-report-comparison', old_path: '/pages/admin/attendance-report-comparison.html', old_category: 'admin' } + ]; + + const allReverts = [...safetyRevert, ...attendanceRevert, ...adminRevert]; + + for (const revert of allReverts) { + await knex('pages') + .where('page_key', revert.new_key) + .update({ + page_key: revert.old_key, + page_path: revert.old_path, + category: revert.old_category + }); + } + + // 5. 새로 추가된 페이지 삭제 + await knex('pages').whereIn('page_key', [ + 'safety.checklist_manage', + 'attendance.vacation_approval', + 'attendance.vacation_input' + ]).del(); + + console.log('페이지 구조 재구성 롤백 완료'); +}; diff --git a/api.hyungi.net/models/tbmModel.js b/api.hyungi.net/models/tbmModel.js index df5ec0c..a191243 100644 --- a/api.hyungi.net/models/tbmModel.js +++ b/api.hyungi.net/models/tbmModel.js @@ -698,6 +698,296 @@ const TbmModel = { } catch (err) { callback(err); } + }, + + // ========== 안전 체크리스트 확장 메서드 ========== + + /** + * 유형별 안전 체크 항목 조회 + * @param {string} checkType - 체크 유형 (basic, weather, task) + * @param {Object} options - 추가 옵션 (weatherCondition, taskId) + */ + getSafetyChecksByType: async (checkType, options = {}, callback) => { + try { + const db = await getDb(); + let sql = ` + SELECT sc.*, + wc.condition_name as weather_condition_name, + wc.icon as weather_icon, + t.task_name + FROM tbm_safety_checks sc + LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code + LEFT JOIN tasks t ON sc.task_id = t.task_id + WHERE sc.is_active = 1 AND sc.check_type = ? + `; + const params = [checkType]; + + if (checkType === 'weather' && options.weatherCondition) { + sql += ' AND sc.weather_condition = ?'; + params.push(options.weatherCondition); + } + + if (checkType === 'task' && options.taskId) { + sql += ' AND sc.task_id = ?'; + params.push(options.taskId); + } + + sql += ' ORDER BY sc.check_category, sc.display_order'; + + const [rows] = await db.query(sql, params); + callback(null, rows); + } catch (err) { + callback(err); + } + }, + + /** + * 날씨 조건별 안전 체크 항목 조회 (복수 조건) + * @param {string[]} conditions - 날씨 조건 배열 ['rain', 'wind'] + */ + getSafetyChecksByWeather: async (conditions, callback) => { + try { + const db = await getDb(); + + if (!conditions || conditions.length === 0) { + return callback(null, []); + } + + const placeholders = conditions.map(() => '?').join(','); + const sql = ` + SELECT sc.*, + wc.condition_name as weather_condition_name, + wc.icon as weather_icon + FROM tbm_safety_checks sc + LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code + WHERE sc.is_active = 1 + AND sc.check_type = 'weather' + AND sc.weather_condition IN (${placeholders}) + ORDER BY sc.weather_condition, sc.display_order + `; + + const [rows] = await db.query(sql, conditions); + callback(null, rows); + } catch (err) { + callback(err); + } + }, + + /** + * 작업별 안전 체크 항목 조회 (복수 작업) + * @param {number[]} taskIds - 작업 ID 배열 + */ + getSafetyChecksByTasks: async (taskIds, callback) => { + try { + const db = await getDb(); + + if (!taskIds || taskIds.length === 0) { + return callback(null, []); + } + + const placeholders = taskIds.map(() => '?').join(','); + const sql = ` + SELECT sc.*, + t.task_name, + wt.name as work_type_name + FROM tbm_safety_checks sc + LEFT JOIN tasks t ON sc.task_id = t.task_id + LEFT JOIN work_types wt ON t.work_type_id = wt.id + WHERE sc.is_active = 1 + AND sc.check_type = 'task' + AND sc.task_id IN (${placeholders}) + ORDER BY sc.task_id, sc.display_order + `; + + const [rows] = await db.query(sql, taskIds); + callback(null, rows); + } catch (err) { + callback(err); + } + }, + + /** + * TBM 세션에 맞는 필터링된 안전 체크 항목 조회 + * 기본 + 날씨 + 작업별 체크항목 통합 조회 + * @param {number} sessionId - TBM 세션 ID + * @param {string[]} weatherConditions - 날씨 조건 배열 (optional) + */ + getFilteredSafetyChecks: async (sessionId, weatherConditions = [], callback) => { + try { + const db = await getDb(); + + // 1. 세션 정보에서 작업 ID 목록 조회 + const [assignments] = await db.query(` + SELECT DISTINCT task_id + FROM tbm_team_assignments + WHERE session_id = ? AND task_id IS NOT NULL + `, [sessionId]); + + const taskIds = assignments.map(a => a.task_id); + + // 2. 기본 체크항목 조회 + const [basicChecks] = await db.query(` + SELECT sc.*, 'basic' as section_type + FROM tbm_safety_checks sc + WHERE sc.is_active = 1 AND sc.check_type = 'basic' + ORDER BY sc.check_category, sc.display_order + `); + + // 3. 날씨별 체크항목 조회 + let weatherChecks = []; + if (weatherConditions && weatherConditions.length > 0) { + const wcPlaceholders = weatherConditions.map(() => '?').join(','); + const [rows] = await db.query(` + SELECT sc.*, wc.condition_name as weather_condition_name, wc.icon as weather_icon, + 'weather' as section_type + FROM tbm_safety_checks sc + LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code + WHERE sc.is_active = 1 + AND sc.check_type = 'weather' + AND sc.weather_condition IN (${wcPlaceholders}) + ORDER BY sc.weather_condition, sc.display_order + `, weatherConditions); + weatherChecks = rows; + } + + // 4. 작업별 체크항목 조회 + let taskChecks = []; + if (taskIds.length > 0) { + const taskPlaceholders = taskIds.map(() => '?').join(','); + const [rows] = await db.query(` + SELECT sc.*, t.task_name, wt.name as work_type_name, + 'task' as section_type + FROM tbm_safety_checks sc + LEFT JOIN tasks t ON sc.task_id = t.task_id + LEFT JOIN work_types wt ON t.work_type_id = wt.id + WHERE sc.is_active = 1 + AND sc.check_type = 'task' + AND sc.task_id IN (${taskPlaceholders}) + ORDER BY sc.task_id, sc.display_order + `, taskIds); + taskChecks = rows; + } + + // 5. 기존 체크 기록 조회 + const [existingRecords] = await db.query(` + SELECT check_id, is_checked, notes + FROM tbm_safety_records + WHERE session_id = ? + `, [sessionId]); + + const recordMap = {}; + existingRecords.forEach(r => { + recordMap[r.check_id] = { is_checked: r.is_checked, notes: r.notes }; + }); + + // 6. 기록과 병합 + const mergeWithRecords = (checks) => { + return checks.map(check => ({ + ...check, + is_checked: recordMap[check.check_id]?.is_checked || false, + notes: recordMap[check.check_id]?.notes || null + })); + }; + + const result = { + basic: mergeWithRecords(basicChecks), + weather: mergeWithRecords(weatherChecks), + task: mergeWithRecords(taskChecks), + totalCount: basicChecks.length + weatherChecks.length + taskChecks.length, + weatherConditions: weatherConditions + }; + + callback(null, result); + } catch (err) { + callback(err); + } + }, + + /** + * 안전 체크 항목 생성 (관리자용) + */ + createSafetyCheck: async (checkData, callback) => { + try { + const db = await getDb(); + const sql = ` + INSERT INTO tbm_safety_checks + (check_category, check_type, weather_condition, task_id, check_item, description, is_required, display_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + + const values = [ + checkData.check_category, + checkData.check_type || 'basic', + checkData.weather_condition || null, + checkData.task_id || null, + checkData.check_item, + checkData.description || null, + checkData.is_required !== false, + checkData.display_order || 0 + ]; + + const [result] = await db.query(sql, values); + callback(null, { insertId: result.insertId }); + } catch (err) { + callback(err); + } + }, + + /** + * 안전 체크 항목 수정 (관리자용) + */ + updateSafetyCheck: async (checkId, checkData, callback) => { + try { + const db = await getDb(); + const sql = ` + UPDATE tbm_safety_checks + SET check_category = ?, + check_type = ?, + weather_condition = ?, + task_id = ?, + check_item = ?, + description = ?, + is_required = ?, + display_order = ?, + is_active = ?, + updated_at = NOW() + WHERE check_id = ? + `; + + const values = [ + checkData.check_category, + checkData.check_type || 'basic', + checkData.weather_condition || null, + checkData.task_id || null, + checkData.check_item, + checkData.description || null, + checkData.is_required !== false, + checkData.display_order || 0, + checkData.is_active !== false, + checkId + ]; + + const [result] = await db.query(sql, values); + callback(null, { affectedRows: result.affectedRows }); + } catch (err) { + callback(err); + } + }, + + /** + * 안전 체크 항목 삭제 (비활성화) + */ + deleteSafetyCheck: async (checkId, callback) => { + try { + const db = await getDb(); + // 실제 삭제 대신 비활성화 + const sql = `UPDATE tbm_safety_checks SET is_active = 0 WHERE check_id = ?`; + + const [result] = await db.query(sql, [checkId]); + callback(null, { affectedRows: result.affectedRows }); + } catch (err) { + callback(err); + } } }; diff --git a/api.hyungi.net/models/workIssueModel.js b/api.hyungi.net/models/workIssueModel.js new file mode 100644 index 0000000..90e943e --- /dev/null +++ b/api.hyungi.net/models/workIssueModel.js @@ -0,0 +1,881 @@ +/** + * 작업 중 문제 신고 모델 + * 부적합/안전 신고 관련 DB 쿼리 + */ + +const { getDb } = require('../dbPool'); + +// ==================== 신고 카테고리 관리 ==================== + +/** + * 모든 신고 카테고리 조회 + */ +const getAllCategories = async (callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT category_id, category_type, category_name, description, display_order, is_active, created_at + FROM issue_report_categories + ORDER BY category_type, display_order, category_id` + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 타입별 활성 카테고리 조회 (nonconformity/safety) + */ +const getCategoriesByType = async (categoryType, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT category_id, category_type, category_name, description, display_order + FROM issue_report_categories + WHERE category_type = ? AND is_active = TRUE + ORDER BY display_order, category_id`, + [categoryType] + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 카테고리 생성 + */ +const createCategory = async (categoryData, callback) => { + try { + const db = await getDb(); + const { category_type, category_name, description = null, display_order = 0 } = categoryData; + + const [result] = await db.query( + `INSERT INTO issue_report_categories (category_type, category_name, description, display_order) + VALUES (?, ?, ?, ?)`, + [category_type, category_name, description, display_order] + ); + + callback(null, result.insertId); + } catch (err) { + callback(err); + } +}; + +/** + * 카테고리 수정 + */ +const updateCategory = async (categoryId, categoryData, callback) => { + try { + const db = await getDb(); + const { category_name, description, display_order, is_active } = categoryData; + + const [result] = await db.query( + `UPDATE issue_report_categories + SET category_name = ?, description = ?, display_order = ?, is_active = ? + WHERE category_id = ?`, + [category_name, description, display_order, is_active, categoryId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 카테고리 삭제 + */ +const deleteCategory = async (categoryId, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `DELETE FROM issue_report_categories WHERE category_id = ?`, + [categoryId] + ); + callback(null, result); + } catch (err) { + callback(err); + } +}; + +// ==================== 사전 정의 신고 항목 관리 ==================== + +/** + * 카테고리별 활성 항목 조회 + */ +const getItemsByCategory = async (categoryId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT item_id, category_id, item_name, description, severity, display_order + FROM issue_report_items + WHERE category_id = ? AND is_active = TRUE + ORDER BY display_order, item_id`, + [categoryId] + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 모든 항목 조회 (관리용) + */ +const getAllItems = async (callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT iri.item_id, iri.category_id, iri.item_name, iri.description, + iri.severity, iri.display_order, iri.is_active, iri.created_at, + irc.category_name, irc.category_type + FROM issue_report_items iri + INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id + ORDER BY irc.category_type, irc.display_order, iri.display_order, iri.item_id` + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 항목 생성 + */ +const createItem = async (itemData, callback) => { + try { + const db = await getDb(); + const { category_id, item_name, description = null, severity = 'medium', display_order = 0 } = itemData; + + const [result] = await db.query( + `INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order) + VALUES (?, ?, ?, ?, ?)`, + [category_id, item_name, description, severity, display_order] + ); + + callback(null, result.insertId); + } catch (err) { + callback(err); + } +}; + +/** + * 항목 수정 + */ +const updateItem = async (itemId, itemData, callback) => { + try { + const db = await getDb(); + const { item_name, description, severity, display_order, is_active } = itemData; + + const [result] = await db.query( + `UPDATE issue_report_items + SET item_name = ?, description = ?, severity = ?, display_order = ?, is_active = ? + WHERE item_id = ?`, + [item_name, description, severity, display_order, is_active, itemId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 항목 삭제 + */ +const deleteItem = async (itemId, callback) => { + try { + const db = await getDb(); + const [result] = await db.query( + `DELETE FROM issue_report_items WHERE item_id = ?`, + [itemId] + ); + callback(null, result); + } catch (err) { + callback(err); + } +}; + +// ==================== 문제 신고 관리 ==================== + +/** + * 신고 생성 + */ +const createReport = async (reportData, callback) => { + try { + const db = await getDb(); + const { + reporter_id, + factory_category_id = null, + workplace_id = null, + custom_location = null, + tbm_session_id = null, + visit_request_id = null, + issue_category_id, + issue_item_id = null, + additional_description = null, + photo_path1 = null, + photo_path2 = null, + photo_path3 = null, + photo_path4 = null, + photo_path5 = null + } = reportData; + + const [result] = await db.query( + `INSERT INTO work_issue_reports + (reporter_id, factory_category_id, workplace_id, custom_location, + tbm_session_id, visit_request_id, issue_category_id, issue_item_id, + additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [reporter_id, factory_category_id, workplace_id, custom_location, + tbm_session_id, visit_request_id, issue_category_id, issue_item_id, + additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5] + ); + + // 상태 변경 로그 기록 + await db.query( + `INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by) + VALUES (?, NULL, 'reported', ?)`, + [result.insertId, reporter_id] + ); + + callback(null, result.insertId); + } catch (err) { + callback(err); + } +}; + +/** + * 신고 목록 조회 (필터 옵션 포함) + */ +const getAllReports = async (filters = {}, callback) => { + try { + const db = await getDb(); + let query = ` + SELECT + wir.report_id, wir.reporter_id, wir.report_date, + wir.factory_category_id, wir.workplace_id, wir.custom_location, + wir.tbm_session_id, wir.visit_request_id, + wir.issue_category_id, wir.issue_item_id, wir.additional_description, + wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5, + wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at, + wir.resolution_notes, wir.resolved_at, + wir.created_at, wir.updated_at, + u.username as reporter_name, u.name as reporter_full_name, + wc.category_name as factory_name, + w.workplace_name, + irc.category_type, irc.category_name as issue_category_name, + iri.item_name as issue_item_name, iri.severity, + assignee.username as assigned_user_name, assignee.name as assigned_full_name + FROM work_issue_reports wir + INNER JOIN users u ON wir.reporter_id = u.user_id + LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id + LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id + INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id + LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id + LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id + WHERE 1=1 + `; + + const params = []; + + // 필터 적용 + if (filters.status) { + query += ` AND wir.status = ?`; + params.push(filters.status); + } + + if (filters.category_type) { + query += ` AND irc.category_type = ?`; + params.push(filters.category_type); + } + + if (filters.issue_category_id) { + query += ` AND wir.issue_category_id = ?`; + params.push(filters.issue_category_id); + } + + if (filters.factory_category_id) { + query += ` AND wir.factory_category_id = ?`; + params.push(filters.factory_category_id); + } + + if (filters.workplace_id) { + query += ` AND wir.workplace_id = ?`; + params.push(filters.workplace_id); + } + + if (filters.reporter_id) { + query += ` AND wir.reporter_id = ?`; + params.push(filters.reporter_id); + } + + if (filters.assigned_user_id) { + query += ` AND wir.assigned_user_id = ?`; + params.push(filters.assigned_user_id); + } + + if (filters.start_date && filters.end_date) { + query += ` AND DATE(wir.report_date) BETWEEN ? AND ?`; + params.push(filters.start_date, filters.end_date); + } + + if (filters.search) { + query += ` AND (wir.additional_description LIKE ? OR iri.item_name LIKE ? OR wir.custom_location LIKE ?)`; + const searchTerm = `%${filters.search}%`; + params.push(searchTerm, searchTerm, searchTerm); + } + + query += ` ORDER BY wir.report_date DESC, wir.report_id DESC`; + + // 페이지네이션 + if (filters.limit) { + query += ` LIMIT ?`; + params.push(parseInt(filters.limit)); + + if (filters.offset) { + query += ` OFFSET ?`; + params.push(parseInt(filters.offset)); + } + } + + const [rows] = await db.query(query, params); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 신고 상세 조회 + */ +const getReportById = async (reportId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT + wir.report_id, wir.reporter_id, wir.report_date, + wir.factory_category_id, wir.workplace_id, wir.custom_location, + wir.tbm_session_id, wir.visit_request_id, + wir.issue_category_id, wir.issue_item_id, wir.additional_description, + wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5, + wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at, wir.assigned_by, + wir.resolution_notes, wir.resolution_photo_path1, wir.resolution_photo_path2, + wir.resolved_at, wir.resolved_by, + wir.modification_history, + wir.created_at, wir.updated_at, + u.username as reporter_name, u.name as reporter_full_name, + wc.category_name as factory_name, + w.workplace_name, + irc.category_type, irc.category_name as issue_category_name, + iri.item_name as issue_item_name, iri.severity, + assignee.username as assigned_user_name, assignee.name as assigned_full_name, + assigner.username as assigned_by_name, + resolver.username as resolved_by_name + FROM work_issue_reports wir + INNER JOIN users u ON wir.reporter_id = u.user_id + LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id + LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id + INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id + LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id + LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id + LEFT JOIN users assigner ON wir.assigned_by = assigner.user_id + LEFT JOIN users resolver ON wir.resolved_by = resolver.user_id + WHERE wir.report_id = ?`, + [reportId] + ); + + callback(null, rows[0]); + } catch (err) { + callback(err); + } +}; + +/** + * 신고 수정 + */ +const updateReport = async (reportId, reportData, userId, callback) => { + try { + const db = await getDb(); + + // 기존 데이터 조회 + const [existing] = await db.query( + `SELECT * FROM work_issue_reports WHERE report_id = ?`, + [reportId] + ); + + if (existing.length === 0) { + return callback(new Error('신고를 찾을 수 없습니다.')); + } + + const current = existing[0]; + + // 수정 이력 생성 + const modifications = []; + const now = new Date().toISOString(); + + for (const key of Object.keys(reportData)) { + if (current[key] !== reportData[key] && reportData[key] !== undefined) { + modifications.push({ + field: key, + old_value: current[key], + new_value: reportData[key], + modified_at: now, + modified_by: userId + }); + } + } + + // 기존 이력과 병합 + const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : []; + const newHistory = [...existingHistory, ...modifications]; + + const { + factory_category_id, + workplace_id, + custom_location, + issue_category_id, + issue_item_id, + additional_description, + photo_path1, + photo_path2, + photo_path3, + photo_path4, + photo_path5 + } = reportData; + + const [result] = await db.query( + `UPDATE work_issue_reports + SET factory_category_id = COALESCE(?, factory_category_id), + workplace_id = COALESCE(?, workplace_id), + custom_location = COALESCE(?, custom_location), + issue_category_id = COALESCE(?, issue_category_id), + issue_item_id = COALESCE(?, issue_item_id), + additional_description = COALESCE(?, additional_description), + photo_path1 = COALESCE(?, photo_path1), + photo_path2 = COALESCE(?, photo_path2), + photo_path3 = COALESCE(?, photo_path3), + photo_path4 = COALESCE(?, photo_path4), + photo_path5 = COALESCE(?, photo_path5), + modification_history = ?, + updated_at = NOW() + WHERE report_id = ?`, + [factory_category_id, workplace_id, custom_location, + issue_category_id, issue_item_id, additional_description, + photo_path1, photo_path2, photo_path3, photo_path4, photo_path5, + JSON.stringify(newHistory), reportId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 신고 삭제 + */ +const deleteReport = async (reportId, callback) => { + try { + const db = await getDb(); + + // 먼저 사진 경로 조회 (삭제용) + const [photos] = await db.query( + `SELECT photo_path1, photo_path2, photo_path3, photo_path4, photo_path5, + resolution_photo_path1, resolution_photo_path2 + FROM work_issue_reports WHERE report_id = ?`, + [reportId] + ); + + const [result] = await db.query( + `DELETE FROM work_issue_reports WHERE report_id = ?`, + [reportId] + ); + + // 삭제할 사진 경로 반환 + callback(null, { result, photos: photos[0] }); + } catch (err) { + callback(err); + } +}; + +// ==================== 상태 관리 ==================== + +/** + * 신고 접수 (reported → received) + */ +const receiveReport = async (reportId, userId, callback) => { + try { + const db = await getDb(); + + // 현재 상태 확인 + const [current] = await db.query( + `SELECT status FROM work_issue_reports WHERE report_id = ?`, + [reportId] + ); + + if (current.length === 0) { + return callback(new Error('신고를 찾을 수 없습니다.')); + } + + if (current[0].status !== 'reported') { + return callback(new Error('접수 대기 상태가 아닙니다.')); + } + + const [result] = await db.query( + `UPDATE work_issue_reports + SET status = 'received', updated_at = NOW() + WHERE report_id = ?`, + [reportId] + ); + + // 상태 변경 로그 + await db.query( + `INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by) + VALUES (?, 'reported', 'received', ?)`, + [reportId, userId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 담당자 배정 + */ +const assignReport = async (reportId, assignData, callback) => { + try { + const db = await getDb(); + const { assigned_department, assigned_user_id, assigned_by } = assignData; + + // 현재 상태 확인 + const [current] = await db.query( + `SELECT status FROM work_issue_reports WHERE report_id = ?`, + [reportId] + ); + + if (current.length === 0) { + return callback(new Error('신고를 찾을 수 없습니다.')); + } + + // 접수 상태 이상이어야 배정 가능 + const validStatuses = ['received', 'in_progress']; + if (!validStatuses.includes(current[0].status)) { + return callback(new Error('접수된 상태에서만 담당자 배정이 가능합니다.')); + } + + const [result] = await db.query( + `UPDATE work_issue_reports + SET assigned_department = ?, assigned_user_id = ?, + assigned_at = NOW(), assigned_by = ?, updated_at = NOW() + WHERE report_id = ?`, + [assigned_department, assigned_user_id, assigned_by, reportId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 처리 시작 (received → in_progress) + */ +const startProcessing = async (reportId, userId, callback) => { + try { + const db = await getDb(); + + // 현재 상태 확인 + const [current] = await db.query( + `SELECT status FROM work_issue_reports WHERE report_id = ?`, + [reportId] + ); + + if (current.length === 0) { + return callback(new Error('신고를 찾을 수 없습니다.')); + } + + if (current[0].status !== 'received') { + return callback(new Error('접수된 상태에서만 처리를 시작할 수 있습니다.')); + } + + const [result] = await db.query( + `UPDATE work_issue_reports + SET status = 'in_progress', updated_at = NOW() + WHERE report_id = ?`, + [reportId] + ); + + // 상태 변경 로그 + await db.query( + `INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by) + VALUES (?, 'received', 'in_progress', ?)`, + [reportId, userId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 처리 완료 (in_progress → completed) + */ +const completeReport = async (reportId, completionData, callback) => { + try { + const db = await getDb(); + const { resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by } = completionData; + + // 현재 상태 확인 + const [current] = await db.query( + `SELECT status FROM work_issue_reports WHERE report_id = ?`, + [reportId] + ); + + if (current.length === 0) { + return callback(new Error('신고를 찾을 수 없습니다.')); + } + + if (current[0].status !== 'in_progress') { + return callback(new Error('처리 중 상태에서만 완료할 수 있습니다.')); + } + + const [result] = await db.query( + `UPDATE work_issue_reports + SET status = 'completed', resolution_notes = ?, + resolution_photo_path1 = ?, resolution_photo_path2 = ?, + resolved_at = NOW(), resolved_by = ?, updated_at = NOW() + WHERE report_id = ?`, + [resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by, reportId] + ); + + // 상태 변경 로그 + await db.query( + `INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by, change_reason) + VALUES (?, 'in_progress', 'completed', ?, ?)`, + [reportId, resolved_by, resolution_notes] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 신고 종료 (completed → closed) + */ +const closeReport = async (reportId, userId, callback) => { + try { + const db = await getDb(); + + // 현재 상태 확인 + const [current] = await db.query( + `SELECT status FROM work_issue_reports WHERE report_id = ?`, + [reportId] + ); + + if (current.length === 0) { + return callback(new Error('신고를 찾을 수 없습니다.')); + } + + if (current[0].status !== 'completed') { + return callback(new Error('완료된 상태에서만 종료할 수 있습니다.')); + } + + const [result] = await db.query( + `UPDATE work_issue_reports + SET status = 'closed', updated_at = NOW() + WHERE report_id = ?`, + [reportId] + ); + + // 상태 변경 로그 + await db.query( + `INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by) + VALUES (?, 'completed', 'closed', ?)`, + [reportId, userId] + ); + + callback(null, result); + } catch (err) { + callback(err); + } +}; + +/** + * 상태 변경 이력 조회 + */ +const getStatusLogs = async (reportId, callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT wisl.log_id, wisl.report_id, wisl.previous_status, wisl.new_status, + wisl.changed_by, wisl.change_reason, wisl.changed_at, + u.username as changed_by_name, u.name as changed_by_full_name + FROM work_issue_status_logs wisl + INNER JOIN users u ON wisl.changed_by = u.user_id + WHERE wisl.report_id = ? + ORDER BY wisl.changed_at ASC`, + [reportId] + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +// ==================== 통계 ==================== + +/** + * 신고 통계 요약 + */ +const getStatsSummary = async (filters = {}, callback) => { + try { + const db = await getDb(); + + let whereClause = '1=1'; + const params = []; + + if (filters.start_date && filters.end_date) { + whereClause += ` AND DATE(report_date) BETWEEN ? AND ?`; + params.push(filters.start_date, filters.end_date); + } + + if (filters.factory_category_id) { + whereClause += ` AND factory_category_id = ?`; + params.push(filters.factory_category_id); + } + + const [rows] = await db.query( + `SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'reported' THEN 1 ELSE 0 END) as reported, + SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) as received, + SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed + FROM work_issue_reports + WHERE ${whereClause}`, + params + ); + + callback(null, rows[0]); + } catch (err) { + callback(err); + } +}; + +/** + * 카테고리별 통계 + */ +const getStatsByCategory = async (filters = {}, callback) => { + try { + const db = await getDb(); + + let whereClause = '1=1'; + const params = []; + + if (filters.start_date && filters.end_date) { + whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`; + params.push(filters.start_date, filters.end_date); + } + + const [rows] = await db.query( + `SELECT + irc.category_type, irc.category_name, + COUNT(*) as count + FROM work_issue_reports wir + INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id + WHERE ${whereClause} + GROUP BY irc.category_id + ORDER BY irc.category_type, count DESC`, + params + ); + + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +/** + * 작업장별 통계 + */ +const getStatsByWorkplace = async (filters = {}, callback) => { + try { + const db = await getDb(); + + let whereClause = 'wir.workplace_id IS NOT NULL'; + const params = []; + + if (filters.start_date && filters.end_date) { + whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`; + params.push(filters.start_date, filters.end_date); + } + + if (filters.factory_category_id) { + whereClause += ` AND wir.factory_category_id = ?`; + params.push(filters.factory_category_id); + } + + const [rows] = await db.query( + `SELECT + wir.factory_category_id, wc.category_name as factory_name, + wir.workplace_id, w.workplace_name, + COUNT(*) as count + FROM work_issue_reports wir + INNER JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id + INNER JOIN workplaces w ON wir.workplace_id = w.workplace_id + WHERE ${whereClause} + GROUP BY wir.factory_category_id, wir.workplace_id + ORDER BY count DESC`, + params + ); + + callback(null, rows); + } catch (err) { + callback(err); + } +}; + +module.exports = { + // 카테고리 + getAllCategories, + getCategoriesByType, + createCategory, + updateCategory, + deleteCategory, + + // 항목 + getItemsByCategory, + getAllItems, + createItem, + updateItem, + deleteItem, + + // 신고 + createReport, + getAllReports, + getReportById, + updateReport, + deleteReport, + + // 상태 관리 + receiveReport, + assignReport, + startProcessing, + completeReport, + closeReport, + getStatusLogs, + + // 통계 + getStatsSummary, + getStatsByCategory, + getStatsByWorkplace +}; diff --git a/api.hyungi.net/package-lock.json b/api.hyungi.net/package-lock.json index 153deba..40600a7 100644 --- a/api.hyungi.net/package-lock.json +++ b/api.hyungi.net/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@simplewebauthn/server": "^13.1.1", "async-retry": "^1.3.3", + "axios": "^1.6.7", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", "compression": "^1.8.1", @@ -1956,7 +1957,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/aws-ssl-profiles": { @@ -1968,6 +1968,17 @@ "node": ">= 6.0.0" } }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2700,7 +2711,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3043,7 +3053,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3320,7 +3329,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3691,7 +3699,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4004,7 +4011,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" diff --git a/api.hyungi.net/package.json b/api.hyungi.net/package.json index 3be2186..ff0bf1d 100644 --- a/api.hyungi.net/package.json +++ b/api.hyungi.net/package.json @@ -21,6 +21,7 @@ "dependencies": { "@simplewebauthn/server": "^13.1.1", "async-retry": "^1.3.3", + "axios": "^1.6.7", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", "compression": "^1.8.1", diff --git a/api.hyungi.net/routes/tbmRoutes.js b/api.hyungi.net/routes/tbmRoutes.js index 8e433c0..5e22a7a 100644 --- a/api.hyungi.net/routes/tbmRoutes.js +++ b/api.hyungi.net/routes/tbmRoutes.js @@ -46,12 +46,38 @@ router.delete('/sessions/:sessionId/team/:workerId', requireAuth, TbmController. // 모든 안전 체크 항목 조회 router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks); +// 안전 체크 항목 생성 (관리자용) +router.post('/safety-checks', requireAuth, TbmController.createSafetyCheck); + +// 안전 체크 항목 수정 (관리자용) +router.put('/safety-checks/:checkId', requireAuth, TbmController.updateSafetyCheck); + +// 안전 체크 항목 삭제 (관리자용) +router.delete('/safety-checks/:checkId', requireAuth, TbmController.deleteSafetyCheck); + // TBM 세션의 안전 체크 기록 조회 router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords); // 안전 체크 일괄 저장 router.post('/sessions/:sessionId/safety', requireAuth, TbmController.saveSafetyRecords); +// 필터링된 안전 체크리스트 조회 (기본 + 날씨 + 작업별) +router.get('/sessions/:sessionId/safety-checks/filtered', requireAuth, TbmController.getFilteredSafetyChecks); + +// ==================== 날씨 관련 ==================== + +// 현재 날씨 조회 +router.get('/weather/current', requireAuth, TbmController.getCurrentWeather); + +// 날씨 조건 목록 조회 +router.get('/weather/conditions', requireAuth, TbmController.getWeatherConditions); + +// 세션 날씨 정보 조회 +router.get('/sessions/:sessionId/weather', requireAuth, TbmController.getSessionWeather); + +// 세션 날씨 정보 저장 +router.post('/sessions/:sessionId/weather', requireAuth, TbmController.saveSessionWeather); + // ==================== 작업 인계 관련 ==================== // 작업 인계 생성 diff --git a/api.hyungi.net/routes/userRoutes.js b/api.hyungi.net/routes/userRoutes.js index daf9c8d..da1bcb5 100644 --- a/api.hyungi.net/routes/userRoutes.js +++ b/api.hyungi.net/routes/userRoutes.js @@ -104,6 +104,24 @@ router.get('/me/monthly-stats', async (req, res) => { } }); +// ========== 자신의 페이지 권한 조회 (Admin 불필요) ========== +// 📄 사용자 페이지 접근 권한 조회 (자신 또는 Admin) +router.get('/:id/page-access', (req, res, next) => { + const requestedId = parseInt(req.params.id); + const currentUserId = req.user?.user_id; + const userRole = req.user?.role?.toLowerCase(); + + // 자신의 권한 조회이거나 Admin인 경우 허용 + if (requestedId === currentUserId || userRole === 'admin' || userRole === 'system admin') { + return userController.getUserPageAccess(req, res, next); + } + + return res.status(403).json({ + success: false, + message: '자신의 페이지 권한만 조회할 수 있습니다' + }); +}); + // ========== 관리자 전용 API ========== /** * 모든 라우트에 관리자 권한 적용 @@ -125,13 +143,13 @@ router.put('/:id', userController.updateUser); // 🔄 사용자 상태 변경 router.put('/:id/status', userController.updateUserStatus); +// 🔑 사용자 비밀번호 초기화 (000000) +router.post('/:id/reset-password', userController.resetUserPassword); + // 🗑️ 사용자 삭제 router.delete('/:id', userController.deleteUser); -// 📄 사용자 페이지 접근 권한 조회 -router.get('/:id/page-access', userController.getUserPageAccess); - -// 🔐 사용자 페이지 접근 권한 업데이트 +// 🔐 사용자 페이지 접근 권한 업데이트 (Admin만) router.put('/:id/page-access', userController.updateUserPageAccess); module.exports = router; diff --git a/api.hyungi.net/routes/workIssueRoutes.js b/api.hyungi.net/routes/workIssueRoutes.js new file mode 100644 index 0000000..31f5fda --- /dev/null +++ b/api.hyungi.net/routes/workIssueRoutes.js @@ -0,0 +1,92 @@ +/** + * 작업 중 문제 신고 라우터 + */ + +const express = require('express'); +const router = express.Router(); +const workIssueController = require('../controllers/workIssueController'); +const { requireMinLevel } = require('../middlewares/auth'); + +// ==================== 카테고리 관리 ==================== + +// 모든 카테고리 조회 +router.get('/categories', workIssueController.getAllCategories); + +// 타입별 카테고리 조회 (nonconformity/safety) +router.get('/categories/type/:type', workIssueController.getCategoriesByType); + +// 카테고리 생성 (admin 이상) +router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory); + +// 카테고리 수정 (admin 이상) +router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory); + +// 카테고리 삭제 (admin 이상) +router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory); + +// ==================== 사전 정의 항목 관리 ==================== + +// 모든 항목 조회 +router.get('/items', workIssueController.getAllItems); + +// 카테고리별 항목 조회 +router.get('/items/category/:categoryId', workIssueController.getItemsByCategory); + +// 항목 생성 (admin 이상) +router.post('/items', requireMinLevel('admin'), workIssueController.createItem); + +// 항목 수정 (admin 이상) +router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem); + +// 항목 삭제 (admin 이상) +router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem); + +// ==================== 통계 ==================== + +// 통계 요약 (support_team 이상) +router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary); + +// 카테고리별 통계 (support_team 이상) +router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory); + +// 작업장별 통계 (support_team 이상) +router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace); + +// ==================== 문제 신고 관리 ==================== + +// 신고 목록 조회 +router.get('/', workIssueController.getAllReports); + +// 신고 생성 +router.post('/', workIssueController.createReport); + +// 신고 상세 조회 +router.get('/:id', workIssueController.getReportById); + +// 신고 수정 +router.put('/:id', workIssueController.updateReport); + +// 신고 삭제 +router.delete('/:id', workIssueController.deleteReport); + +// ==================== 상태 관리 ==================== + +// 신고 접수 (support_team 이상) +router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport); + +// 담당자 배정 (support_team 이상) +router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport); + +// 처리 시작 +router.put('/:id/start', workIssueController.startProcessing); + +// 처리 완료 +router.put('/:id/complete', workIssueController.completeReport); + +// 신고 종료 (admin 이상) +router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport); + +// 상태 변경 이력 조회 +router.get('/:id/logs', workIssueController.getStatusLogs); + +module.exports = router; diff --git a/api.hyungi.net/routes/workReportRoutes.js b/api.hyungi.net/routes/workReportRoutes.js index 5d608c5..a320ae9 100644 --- a/api.hyungi.net/routes/workReportRoutes.js +++ b/api.hyungi.net/routes/workReportRoutes.js @@ -23,4 +23,17 @@ router.put('/:id', workReportController.updateWorkReport); // DELETE router.delete('/:id', workReportController.removeWorkReport); +// ========== 부적합 원인 관리 ========== +// 작업 보고서의 부적합 원인 목록 조회 +router.get('/:reportId/defects', workReportController.getReportDefects); + +// 부적합 원인 저장 (전체 교체) +router.put('/:reportId/defects', workReportController.saveReportDefects); + +// 부적합 원인 추가 (단일) +router.post('/:reportId/defects', workReportController.addReportDefect); + +// 부적합 원인 삭제 +router.delete('/defects/:defectId', workReportController.removeReportDefect); + module.exports = router; \ No newline at end of file diff --git a/api.hyungi.net/services/imageUploadService.js b/api.hyungi.net/services/imageUploadService.js new file mode 100644 index 0000000..00136ee --- /dev/null +++ b/api.hyungi.net/services/imageUploadService.js @@ -0,0 +1,209 @@ +/** + * 이미지 업로드 서비스 + * Base64 인코딩된 이미지를 파일로 저장 + * + * 사용 전 sharp 패키지 설치 필요: + * npm install sharp + */ + +const path = require('path'); +const fs = require('fs').promises; +const crypto = require('crypto'); + +// sharp는 선택적으로 사용 (설치되어 있지 않으면 리사이징 없이 저장) +let sharp; +try { + sharp = require('sharp'); +} catch (e) { + console.warn('sharp 패키지가 설치되어 있지 않습니다. 이미지 리사이징이 비활성화됩니다.'); + console.warn('이미지 최적화를 위해 npm install sharp 를 실행하세요.'); +} + +// 업로드 디렉토리 설정 +const UPLOAD_DIR = path.join(__dirname, '../public/uploads/issues'); +const MAX_SIZE = { width: 1920, height: 1920 }; +const QUALITY = 85; + +/** + * 업로드 디렉토리 확인 및 생성 + */ +async function ensureUploadDir() { + try { + await fs.access(UPLOAD_DIR); + } catch { + await fs.mkdir(UPLOAD_DIR, { recursive: true }); + } +} + +/** + * UUID 생성 (간단한 버전) + */ +function generateId() { + return crypto.randomBytes(4).toString('hex'); +} + +/** + * 타임스탬프 문자열 생성 + */ +function getTimestamp() { + const now = new Date(); + return now.toISOString().replace(/[-:T]/g, '').slice(0, 14); +} + +/** + * Base64 문자열에서 이미지 형식 추출 + * @param {string} base64String - Base64 인코딩된 이미지 + * @returns {string} 이미지 확장자 (jpg, png, etc) + */ +function getImageExtension(base64String) { + const match = base64String.match(/^data:image\/(\w+);base64,/); + if (match) { + const format = match[1].toLowerCase(); + // jpeg를 jpg로 변환 + return format === 'jpeg' ? 'jpg' : format; + } + return 'jpg'; // 기본값 +} + +/** + * Base64 이미지를 파일로 저장 + * @param {string} base64String - Base64 인코딩된 이미지 (data:image/...;base64,... 형식) + * @param {string} prefix - 파일명 접두사 (예: 'issue', 'resolution') + * @returns {Promise} 저장된 파일의 웹 경로 또는 null + */ +async function saveBase64Image(base64String, prefix = 'issue') { + try { + if (!base64String || typeof base64String !== 'string') { + return null; + } + + // Base64 헤더가 없는 경우 처리 + let base64Data = base64String; + if (base64String.includes('base64,')) { + base64Data = base64String.split('base64,')[1]; + } + + // Base64 디코딩 + const buffer = Buffer.from(base64Data, 'base64'); + + if (buffer.length === 0) { + console.error('이미지 데이터가 비어있습니다.'); + return null; + } + + // 디렉토리 확인 + await ensureUploadDir(); + + // 파일명 생성 + const timestamp = getTimestamp(); + const uniqueId = generateId(); + const extension = 'jpg'; // 모든 이미지를 JPEG로 저장 + const filename = `${prefix}_${timestamp}_${uniqueId}.${extension}`; + const filepath = path.join(UPLOAD_DIR, filename); + + // sharp가 설치되어 있으면 리사이징 및 최적화 + if (sharp) { + try { + await sharp(buffer) + .resize(MAX_SIZE.width, MAX_SIZE.height, { + fit: 'inside', + withoutEnlargement: true + }) + .jpeg({ quality: QUALITY }) + .toFile(filepath); + } catch (sharpError) { + console.error('sharp 처리 실패, 원본 저장:', sharpError.message); + // sharp 실패 시 원본 저장 + await fs.writeFile(filepath, buffer); + } + } else { + // sharp가 없으면 원본 그대로 저장 + await fs.writeFile(filepath, buffer); + } + + // 웹 접근 경로 반환 + return `/uploads/issues/${filename}`; + } catch (error) { + console.error('이미지 저장 실패:', error); + return null; + } +} + +/** + * 여러 Base64 이미지를 한번에 저장 + * @param {string[]} base64Images - Base64 이미지 배열 + * @param {string} prefix - 파일명 접두사 + * @returns {Promise} 저장된 파일 경로 배열 + */ +async function saveMultipleImages(base64Images, prefix = 'issue') { + const paths = []; + + for (const base64 of base64Images) { + if (base64) { + const savedPath = await saveBase64Image(base64, prefix); + if (savedPath) { + paths.push(savedPath); + } + } + } + + return paths; +} + +/** + * 파일 삭제 + * @param {string} webPath - 웹 경로 (예: /uploads/issues/filename.jpg) + * @returns {Promise} 삭제 성공 여부 + */ +async function deleteFile(webPath) { + try { + if (!webPath || typeof webPath !== 'string') { + return false; + } + + // 보안: uploads 경로만 삭제 허용 + if (!webPath.startsWith('/uploads/')) { + console.error('삭제 불가: uploads 외부 경로', webPath); + return false; + } + + const filename = path.basename(webPath); + const fullPath = path.join(UPLOAD_DIR, filename); + + try { + await fs.access(fullPath); + await fs.unlink(fullPath); + return true; + } catch (accessError) { + // 파일이 없으면 성공으로 처리 + if (accessError.code === 'ENOENT') { + return true; + } + throw accessError; + } + } catch (error) { + console.error('파일 삭제 실패:', error); + return false; + } +} + +/** + * 여러 파일 삭제 + * @param {string[]} webPaths - 웹 경로 배열 + * @returns {Promise} + */ +async function deleteMultipleFiles(webPaths) { + for (const webPath of webPaths) { + if (webPath) { + await deleteFile(webPath); + } + } +} + +module.exports = { + saveBase64Image, + saveMultipleImages, + deleteFile, + deleteMultipleFiles, + UPLOAD_DIR +}; diff --git a/api.hyungi.net/services/weatherService.js b/api.hyungi.net/services/weatherService.js new file mode 100644 index 0000000..d56a6f2 --- /dev/null +++ b/api.hyungi.net/services/weatherService.js @@ -0,0 +1,401 @@ +/** + * 날씨 API 서비스 + * + * 기상청 단기예보 API를 사용하여 현재 날씨 정보를 조회 + * 날씨 조건에 따른 안전 체크리스트 필터링 지원 + * + * @since 2026-02-02 + */ + +const axios = require('axios'); +const logger = require('../utils/logger'); +const { getDb } = require('../dbPool'); + +// 기상청 API 설정 +const WEATHER_BASE_URL = process.env.WEATHER_API_URL || 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0'; +const WEATHER_API = { + baseUrl: WEATHER_BASE_URL, + ultraShortUrl: `${WEATHER_BASE_URL}/getUltraSrtNcst`, + shortForecastUrl: `${WEATHER_BASE_URL}/getVilageFcst`, + apiKey: process.env.WEATHER_API_KEY || '', + // 화성시 남양읍 좌표 (격자 좌표) + // 위도: 37.2072, 경도: 126.8232 + defaultLocation: { + nx: 57, // 화성시 남양읍 X 좌표 + ny: 119 // 화성시 남양읍 Y 좌표 + } +}; + +// PTY (강수형태) 코드 +const PTY_CODES = { + 0: 'none', // 없음 + 1: 'rain', // 비 + 2: 'rain', // 비/눈 (혼합) + 3: 'snow', // 눈 + 4: 'rain', // 소나기 + 5: 'rain', // 빗방울 + 6: 'rain', // 빗방울/눈날림 + 7: 'snow' // 눈날림 +}; + +// SKY (하늘상태) 코드 +const SKY_CODES = { + 1: 'clear', // 맑음 + 3: 'cloudy', // 구름많음 + 4: 'overcast' // 흐림 +}; + +/** + * 현재 날씨 정보 조회 (초단기실황) + * @param {number} nx - 격자 X 좌표 (optional) + * @param {number} ny - 격자 Y 좌표 (optional) + * @returns {Promise} 날씨 데이터 + */ +async function getCurrentWeather(nx = WEATHER_API.defaultLocation.nx, ny = WEATHER_API.defaultLocation.ny) { + if (!WEATHER_API.apiKey) { + logger.warn('날씨 API 키가 설정되지 않음. 기본값 반환'); + return getDefaultWeatherData(); + } + + try { + // 현재 시간 기준으로 base_date, base_time 계산 + const now = new Date(); + const baseDate = formatDate(now); + const baseTime = getBaseTime(now); + + logger.info('날씨 API 호출', { baseDate, baseTime, nx, ny }); + + // Encoding 키는 이미 URL 인코딩되어 있으므로 직접 URL에 추가 (이중 인코딩 방지) + const url = `${WEATHER_API.ultraShortUrl}?serviceKey=${WEATHER_API.apiKey}` + + `&pageNo=1&numOfRows=10&dataType=JSON` + + `&base_date=${baseDate}&base_time=${baseTime}` + + `&nx=${nx}&ny=${ny}`; + + const response = await axios.get(url, { timeout: 5000 }); + + if (response.data?.response?.header?.resultCode !== '00') { + throw new Error(`API 오류: ${response.data?.response?.header?.resultMsg}`); + } + + const items = response.data.response.body.items.item; + const weatherData = parseWeatherItems(items); + + logger.info('날씨 데이터 파싱 완료', weatherData); + + return weatherData; + } catch (error) { + logger.error('날씨 API 호출 실패', { error: error.message }); + return getDefaultWeatherData(); + } +} + +/** + * 날씨 API 응답 파싱 + */ +function parseWeatherItems(items) { + const data = { + temperature: null, + humidity: null, + windSpeed: null, + precipitation: null, + precipitationType: null, + skyCondition: null + }; + + if (!items || !Array.isArray(items)) { + return data; + } + + items.forEach(item => { + switch (item.category) { + case 'T1H': // 기온 + data.temperature = parseFloat(item.obsrValue); + break; + case 'REH': // 습도 + data.humidity = parseInt(item.obsrValue); + break; + case 'WSD': // 풍속 + data.windSpeed = parseFloat(item.obsrValue); + break; + case 'RN1': // 1시간 강수량 + data.precipitation = parseFloat(item.obsrValue) || 0; + break; + case 'PTY': // 강수형태 + data.precipitationType = parseInt(item.obsrValue); + break; + } + }); + + return data; +} + +/** + * 날씨 데이터를 기반으로 조건 판단 + * @param {Object} weatherData - 날씨 데이터 + * @returns {Promise} 해당하는 날씨 조건 코드 배열 + */ +async function determineWeatherConditions(weatherData) { + const conditions = []; + + // DB에서 날씨 조건 기준 조회 + const db = await getDb(); + const [thresholds] = await db.execute(` + SELECT condition_code, temp_threshold_min, temp_threshold_max, + wind_threshold, precip_threshold + FROM weather_conditions + WHERE is_active = TRUE + `); + + // 조건 판단 + thresholds.forEach(threshold => { + let matches = false; + + switch (threshold.condition_code) { + case 'rain': + // 강수형태가 비(1,2,4,5,6) 또는 강수량 > 0 + if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'rain') { + matches = true; + } else if (weatherData.precipitation > 0 && threshold.precip_threshold !== null) { + matches = weatherData.precipitation >= threshold.precip_threshold; + } + break; + + case 'snow': + // 강수형태가 눈(3,7) + if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'snow') { + matches = true; + } + break; + + case 'heat': + // 기온이 폭염 기준 이상 + if (weatherData.temperature !== null && threshold.temp_threshold_min !== null) { + matches = weatherData.temperature >= threshold.temp_threshold_min; + } + break; + + case 'cold': + // 기온이 한파 기준 이하 + if (weatherData.temperature !== null && threshold.temp_threshold_max !== null) { + matches = weatherData.temperature <= threshold.temp_threshold_max; + } + break; + + case 'wind': + // 풍속이 강풍 기준 이상 + if (weatherData.windSpeed !== null && threshold.wind_threshold !== null) { + matches = weatherData.windSpeed >= threshold.wind_threshold; + } + break; + + case 'clear': + // 강수 없고 기온이 정상 범위 + if (!weatherData.precipitationType || weatherData.precipitationType === 0) { + if (weatherData.temperature !== null && + weatherData.temperature > -10 && weatherData.temperature < 35) { + matches = true; + } + } + break; + } + + if (matches) { + conditions.push(threshold.condition_code); + } + }); + + // 조건이 없으면 기본으로 'clear' 추가 + if (conditions.length === 0) { + conditions.push('clear'); + } + + logger.info('날씨 조건 판단 완료', { weatherData, conditions }); + + return conditions; +} + +/** + * TBM 세션에 날씨 정보 저장 + * @param {number} sessionId - TBM 세션 ID + * @param {Object} weatherData - 날씨 데이터 + * @param {string[]} conditions - 날씨 조건 배열 + */ +async function saveWeatherRecord(sessionId, weatherData, conditions) { + const db = await getDb(); + + try { + const weatherDate = new Date().toISOString().split('T')[0]; + + await db.execute(` + INSERT INTO tbm_weather_records + (session_id, weather_date, temperature, humidity, wind_speed, precipitation, + weather_condition, weather_conditions, data_source, fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'api', NOW()) + ON DUPLICATE KEY UPDATE + temperature = VALUES(temperature), + humidity = VALUES(humidity), + wind_speed = VALUES(wind_speed), + precipitation = VALUES(precipitation), + weather_condition = VALUES(weather_condition), + weather_conditions = VALUES(weather_conditions), + fetched_at = NOW() + `, [ + sessionId, + weatherDate, + weatherData.temperature, + weatherData.humidity, + weatherData.windSpeed, + weatherData.precipitation, + conditions[0] || 'clear', // 주요 조건 + JSON.stringify(conditions) // 모든 조건 + ]); + + logger.info('날씨 기록 저장 완료', { sessionId, conditions }); + return { success: true }; + } catch (error) { + logger.error('날씨 기록 저장 실패', { sessionId, error: error.message }); + throw error; + } +} + +/** + * TBM 세션의 날씨 기록 조회 + * @param {number} sessionId - TBM 세션 ID + */ +async function getWeatherRecord(sessionId) { + const db = await getDb(); + + const [rows] = await db.execute(` + SELECT wr.*, wc.condition_name, wc.icon + FROM tbm_weather_records wr + LEFT JOIN weather_conditions wc ON wr.weather_condition = wc.condition_code + WHERE wr.session_id = ? + `, [sessionId]); + + if (rows.length === 0) { + return null; + } + + const record = rows[0]; + // JSON 문자열 파싱 + if (record.weather_conditions && typeof record.weather_conditions === 'string') { + try { + record.weather_conditions = JSON.parse(record.weather_conditions); + } catch (e) { + record.weather_conditions = []; + } + } + + return record; +} + +/** + * 날씨 조건 코드 목록 조회 + */ +async function getWeatherConditionList() { + const db = await getDb(); + + const [rows] = await db.execute(` + SELECT condition_code, condition_name, description, icon, + temp_threshold_min, temp_threshold_max, wind_threshold, precip_threshold + FROM weather_conditions + WHERE is_active = TRUE + ORDER BY display_order + `); + + return rows; +} + +/** + * 기본 날씨 데이터 반환 (API 실패 시) + */ +function getDefaultWeatherData() { + return { + temperature: 20, + humidity: 50, + windSpeed: 2, + precipitation: 0, + precipitationType: 0, + skyCondition: 'clear', + isDefault: true + }; +} + +/** + * 날짜 포맷 (YYYYMMDD) + */ +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +/** + * 초단기실황 API용 기준시간 계산 + * 매시간 정각에 생성되고 10분 후에 제공됨 + */ +function getBaseTime(date) { + let hours = date.getHours(); + let minutes = date.getMinutes(); + + // 10분 이전이면 이전 시간 데이터 사용 + if (minutes < 10) { + hours = hours - 1; + if (hours < 0) hours = 23; + } + + return String(hours).padStart(2, '0') + '00'; +} + +/** + * 위경도를 기상청 격자 좌표로 변환 + * LCC (Lambert Conformal Conic) 투영법 사용 + */ +function convertToGrid(lat, lon) { + const RE = 6371.00877; // 지구 반경(km) + const GRID = 5.0; // 격자 간격(km) + const SLAT1 = 30.0; // 투영 위도1(degree) + const SLAT2 = 60.0; // 투영 위도2(degree) + const OLON = 126.0; // 기준점 경도(degree) + const OLAT = 38.0; // 기준점 위도(degree) + const XO = 43; // 기준점 X좌표(GRID) + const YO = 136; // 기준점 Y좌표(GRID) + + const DEGRAD = Math.PI / 180.0; + + const re = RE / GRID; + const slat1 = SLAT1 * DEGRAD; + const slat2 = SLAT2 * DEGRAD; + const olon = OLON * DEGRAD; + const olat = OLAT * DEGRAD; + + let sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5); + sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); + let sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); + sf = Math.pow(sf, sn) * Math.cos(slat1) / sn; + let ro = Math.tan(Math.PI * 0.25 + olat * 0.5); + ro = re * sf / Math.pow(ro, sn); + + let ra = Math.tan(Math.PI * 0.25 + lat * DEGRAD * 0.5); + ra = re * sf / Math.pow(ra, sn); + let theta = lon * DEGRAD - olon; + if (theta > Math.PI) theta -= 2.0 * Math.PI; + if (theta < -Math.PI) theta += 2.0 * Math.PI; + theta *= sn; + + const x = Math.floor(ra * Math.sin(theta) + XO + 0.5); + const y = Math.floor(ro - ra * Math.cos(theta) + YO + 0.5); + + return { nx: x, ny: y }; +} + +module.exports = { + getCurrentWeather, + determineWeatherConditions, + saveWeatherRecord, + getWeatherRecord, + getWeatherConditionList, + convertToGrid, + getDefaultWeatherData +}; diff --git a/api.hyungi.net/services/workReportService.js b/api.hyungi.net/services/workReportService.js index 17b260d..29f4d2c 100644 --- a/api.hyungi.net/services/workReportService.js +++ b/api.hyungi.net/services/workReportService.js @@ -10,6 +10,7 @@ const workReportModel = require('../models/workReportModel'); const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors'); const logger = require('../utils/logger'); +const { getDb } = require('../dbPool'); /** * 작업 보고서 생성 (단일 또는 다중) @@ -269,6 +270,170 @@ const getSummaryService = async (year, month) => { } }; +// ========== 부적합 원인 관리 서비스 ========== + +/** + * 작업 보고서의 부적합 원인 목록 조회 + */ +const getReportDefectsService = async (reportId) => { + const db = await getDb(); + try { + const [rows] = await db.execute(` + SELECT + d.defect_id, + d.report_id, + d.error_type_id, + d.defect_hours, + d.note, + d.created_at, + et.name as error_type_name, + et.severity + FROM work_report_defects d + JOIN error_types et ON d.error_type_id = et.id + WHERE d.report_id = ? + ORDER BY d.created_at + `, [reportId]); + + return rows; + } catch (error) { + logger.error('부적합 원인 조회 실패', { reportId, error: error.message }); + throw new DatabaseError('부적합 원인 조회 중 오류가 발생했습니다'); + } +}; + +/** + * 부적합 원인 저장 (전체 교체) + */ +const saveReportDefectsService = async (reportId, defects) => { + const db = await getDb(); + try { + await db.query('START TRANSACTION'); + + // 기존 부적합 원인 삭제 + await db.execute('DELETE FROM work_report_defects WHERE report_id = ?', [reportId]); + + // 새 부적합 원인 추가 + if (defects && defects.length > 0) { + for (const defect of defects) { + await db.execute(` + INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note) + VALUES (?, ?, ?, ?) + `, [reportId, defect.error_type_id, defect.defect_hours || 0, defect.note || null]); + } + } + + // 총 부적합 시간 계산 및 daily_work_reports 업데이트 + const totalErrorHours = defects + ? defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0) + : 0; + + await db.execute(` + UPDATE daily_work_reports + SET error_hours = ?, + error_type_id = ?, + work_status_id = ? + WHERE id = ? + `, [ + totalErrorHours, + defects && defects.length > 0 ? defects[0].error_type_id : null, + totalErrorHours > 0 ? 2 : 1, + reportId + ]); + + await db.query('COMMIT'); + + logger.info('부적합 원인 저장 성공', { reportId, count: defects?.length || 0 }); + return { success: true, count: defects?.length || 0, totalErrorHours }; + } catch (error) { + await db.query('ROLLBACK'); + logger.error('부적합 원인 저장 실패', { reportId, error: error.message }); + throw new DatabaseError('부적합 원인 저장 중 오류가 발생했습니다'); + } +}; + +/** + * 부적합 원인 추가 (단일) + */ +const addReportDefectService = async (reportId, defectData) => { + const db = await getDb(); + try { + const [result] = await db.execute(` + INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note) + VALUES (?, ?, ?, ?) + `, [reportId, defectData.error_type_id, defectData.defect_hours || 0, defectData.note || null]); + + // 총 부적합 시간 업데이트 + await updateTotalErrorHours(reportId); + + logger.info('부적합 원인 추가 성공', { reportId, defectId: result.insertId }); + return { success: true, defect_id: result.insertId }; + } catch (error) { + logger.error('부적합 원인 추가 실패', { reportId, error: error.message }); + throw new DatabaseError('부적합 원인 추가 중 오류가 발생했습니다'); + } +}; + +/** + * 부적합 원인 삭제 + */ +const removeReportDefectService = async (defectId) => { + const db = await getDb(); + try { + // report_id 먼저 조회 + const [defect] = await db.execute('SELECT report_id FROM work_report_defects WHERE defect_id = ?', [defectId]); + if (defect.length === 0) { + throw new NotFoundError('부적합 원인을 찾을 수 없습니다'); + } + + const reportId = defect[0].report_id; + + // 삭제 + await db.execute('DELETE FROM work_report_defects WHERE defect_id = ?', [defectId]); + + // 총 부적합 시간 업데이트 + await updateTotalErrorHours(reportId); + + logger.info('부적합 원인 삭제 성공', { defectId, reportId }); + return { success: true }; + } catch (error) { + if (error instanceof NotFoundError) throw error; + logger.error('부적합 원인 삭제 실패', { defectId, error: error.message }); + throw new DatabaseError('부적합 원인 삭제 중 오류가 발생했습니다'); + } +}; + +/** + * 총 부적합 시간 업데이트 헬퍼 + */ +const updateTotalErrorHours = async (reportId) => { + const db = await getDb(); + const [result] = await db.execute(` + SELECT COALESCE(SUM(defect_hours), 0) as total + FROM work_report_defects + WHERE report_id = ? + `, [reportId]); + + const totalErrorHours = result[0].total || 0; + + // 첫 번째 부적합 원인의 error_type_id를 대표값으로 사용 + const [firstDefect] = await db.execute(` + SELECT error_type_id FROM work_report_defects WHERE report_id = ? ORDER BY created_at LIMIT 1 + `, [reportId]); + + await db.execute(` + UPDATE daily_work_reports + SET error_hours = ?, + error_type_id = ?, + work_status_id = ? + WHERE id = ? + `, [ + totalErrorHours, + firstDefect.length > 0 ? firstDefect[0].error_type_id : null, + totalErrorHours > 0 ? 2 : 1, + reportId + ]); +}; + module.exports = { createWorkReportService, getWorkReportsByDateService, @@ -276,5 +441,10 @@ module.exports = { getWorkReportByIdService, updateWorkReportService, removeWorkReportService, - getSummaryService + getSummaryService, + // 부적합 원인 관리 + getReportDefectsService, + saveReportDefectsService, + addReportDefectService, + removeReportDefectService }; diff --git a/docker-compose.yml b/docker-compose.yml index ada56b8..dbe2120 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,8 @@ services: - JWT_REFRESH_EXPIRES_IN=${JWT_REFRESH_EXPIRES_IN:-30d} - REDIS_HOST=redis # New Redis host - REDIS_PORT=6379 # New Redis port + - WEATHER_API_URL=${WEATHER_API_URL:-https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0} + - WEATHER_API_KEY=${WEATHER_API_KEY:-} # 기상청 API 키 volumes: - ./api.hyungi.net/public/img:/usr/src/app/public/img:ro - ./api.hyungi.net/uploads:/usr/src/app/uploads diff --git a/web-ui/components/navbar.html b/web-ui/components/navbar.html index 99b51d2..a0be968 100644 --- a/web-ui/components/navbar.html +++ b/web-ui/components/navbar.html @@ -13,9 +13,16 @@
-
- 현재 시각 - --:--:-- +
+
+ --월 --일 (--) + --:--:-- +
+
+ 🌤️ + --°C + 날씨 로딩중 +
@@ -109,29 +116,58 @@ font-weight: var(--font-normal); } -.header-center .current-time { +/* 날짜/시간/날씨 박스 */ +.datetime-weather-box { + display: flex; + align-items: center; + gap: var(--space-4); background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: var(--radius-full); - padding: var(--space-3) var(--space-4); - text-align: center; + border-radius: var(--radius-xl); + padding: var(--space-2) var(--space-5); } -.time-label { - display: block; - font-size: var(--text-xs); - opacity: 0.8; - margin-bottom: var(--space-1); +.date-time-section { + display: flex; + flex-direction: column; + align-items: center; + padding-right: var(--space-4); + border-right: 1px solid rgba(255, 255, 255, 0.2); +} + +.date-value { + font-size: var(--text-sm); + opacity: 0.9; + margin-bottom: 2px; } .time-value { - display: block; - font-size: var(--text-lg); + font-size: var(--text-xl); font-weight: var(--font-bold); font-family: 'Courier New', monospace; } +.weather-section { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.weather-icon { + font-size: 1.75rem; +} + +.weather-temp { + font-size: var(--text-lg); + font-weight: var(--font-bold); +} + +.weather-desc { + font-size: var(--text-sm); + opacity: 0.9; +} + .header-right .user-profile { position: relative; display: flex; diff --git a/web-ui/components/sidebar-nav.html b/web-ui/components/sidebar-nav.html new file mode 100644 index 0000000..bad083f --- /dev/null +++ b/web-ui/components/sidebar-nav.html @@ -0,0 +1,337 @@ + + + + + diff --git a/web-ui/css/admin-settings.css b/web-ui/css/admin-settings.css index 5b20ed3..f778586 100644 --- a/web-ui/css/admin-settings.css +++ b/web-ui/css/admin-settings.css @@ -749,6 +749,18 @@ box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); } +/* 비밀번호 초기화 버튼 스타일 */ +.action-btn.reset-pw { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; +} + +.action-btn.reset-pw:hover { + background: linear-gradient(135deg, #d97706 0%, #b45309 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3); +} + /* 페이지 권한 모달 사용자 정보 */ .page-access-user-info { display: flex; diff --git a/web-ui/css/daily-work-report.css b/web-ui/css/daily-work-report.css index 656af04..d714477 100644 --- a/web-ui/css/daily-work-report.css +++ b/web-ui/css/daily-work-report.css @@ -1008,3 +1008,178 @@ .tbm-session-group:not(.manual-input-section) { margin-bottom: 1.5rem; } + +/* 부적합 원인 관리 버튼 */ +.btn-defect-manage { + background: #f3f4f6; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 0.5rem 0.75rem; + cursor: pointer; + font-size: 0.875rem; + min-width: 80px; + text-align: center; + transition: all 0.2s ease; +} + +.btn-defect-manage:hover { + background: #e5e7eb; + border-color: #9ca3af; +} + +.btn-defect-manage span { + color: #9ca3af; +} + +.btn-defect-manage span[style*="color: #dc2626"] { + font-weight: 500; +} + +/* 부적합 토글 버튼 (인라인 방식) */ +.btn-defect-toggle { + background: #f9fafb; + border: 2px solid #e5e7eb; + border-radius: 6px; + padding: 0.5rem 0.75rem; + cursor: pointer; + font-size: 0.875rem; + min-width: 70px; + text-align: center; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + min-height: 44px; +} + +.btn-defect-toggle:hover { + background: #f3f4f6; + border-color: #d97706; +} + +.btn-defect-toggle.has-defect { + background: #fef3c7; + border-color: #f59e0b; +} + +.btn-defect-toggle span { + color: #6b7280; + font-weight: 500; +} + +.btn-defect-toggle.has-defect span { + color: #dc2626; + font-weight: 600; +} + +/* 부적합 인라인 영역 */ +.defect-row td { + border-bottom: 1px solid #fcd34d !important; +} + +.defect-inline-area { + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.defect-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.defect-inline-item { + display: flex; + align-items: center; + gap: 0.5rem; + background: white; + padding: 0.5rem; + border-radius: 6px; + border: 1px solid #e5e7eb; +} + +.defect-select { + flex: 1; + min-width: 120px; + max-width: 200px; + padding: 0.5rem; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.875rem; + background: white; +} + +.defect-select:focus { + outline: none; + border-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.1); +} + +.defect-time-input { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + background: #f9fafb; + border: 2px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + min-width: 80px; + justify-content: center; + transition: all 0.2s ease; +} + +.defect-time-input:hover { + background: #f3f4f6; + border-color: #f59e0b; +} + +.defect-time-value { + font-weight: 600; + color: #374151; + font-size: 0.875rem; +} + +.defect-time-unit { + font-size: 0.75rem; + color: #6b7280; +} + +.btn-remove-defect { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: #fee2e2; + color: #dc2626; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 1.25rem; + font-weight: bold; + transition: all 0.2s ease; +} + +.btn-remove-defect:hover { + background: #fecaca; +} + +.btn-add-defect-inline { + align-self: flex-start; + padding: 0.5rem 1rem; + background: #f59e0b; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.btn-add-defect-inline:hover { + background: #d97706; +} diff --git a/web-ui/js/admin-settings.js b/web-ui/js/admin-settings.js index 9caa11e..0b97f53 100644 --- a/web-ui/js/admin-settings.js +++ b/web-ui/js/admin-settings.js @@ -246,6 +246,9 @@ function renderUsersTable() { 권한 ` : ''} + @@ -410,6 +413,27 @@ function closeDeleteModal() { currentEditingUser = null; } +// ========== 비밀번호 초기화 ========== // +async function resetPassword(userId, username) { + if (!confirm(`${username} 사용자의 비밀번호를 000000으로 초기화하시겠습니까?`)) { + return; + } + + try { + const response = await window.apiCall(`/users/${userId}/reset-password`, 'POST'); + + if (response.success) { + showToast(`${username}의 비밀번호가 000000으로 초기화되었습니다.`, 'success'); + } else { + showToast(response.message || '비밀번호 초기화에 실패했습니다.', 'error'); + } + } catch (error) { + console.error('비밀번호 초기화 오류:', error); + showToast('비밀번호 초기화 중 오류가 발생했습니다.', 'error'); + } +} +window.resetPassword = resetPassword; + // ========== 사용자 CRUD ========== // async function saveUser() { try { @@ -417,8 +441,7 @@ async function saveUser() { name: elements.userNameInput?.value, username: elements.userIdInput?.value, role: elements.userRoleSelect?.value, // HTML select value는 이미 'admin' 또는 'user' - email: elements.userEmailInput?.value, - phone: elements.userPhoneInput?.value + email: elements.userEmailInput?.value }; console.log('저장할 데이터:', formData); @@ -647,24 +670,30 @@ function renderPageAccessList(userRole) { } // 페이지 권한 저장 -async function savePageAccess(userId) { +async function savePageAccess(userId, containerId = null) { try { - const checkboxes = document.querySelectorAll('.page-access-checkbox:not([disabled])'); - const pageAccessData = []; - + // 특정 컨테이너가 지정되면 그 안에서만 체크박스 선택 + const container = containerId ? document.getElementById(containerId) : document; + const checkboxes = container.querySelectorAll('.page-access-checkbox:not([disabled])'); + + // 중복 page_id 제거 (Map 사용) + const pageAccessMap = new Map(); checkboxes.forEach(checkbox => { - pageAccessData.push({ - page_id: parseInt(checkbox.dataset.pageId), + const pageId = parseInt(checkbox.dataset.pageId); + pageAccessMap.set(pageId, { + page_id: pageId, can_access: checkbox.checked ? 1 : 0 }); }); - + + const pageAccessData = Array.from(pageAccessMap.values()); + console.log('📤 페이지 권한 저장:', userId, pageAccessData); - + await apiCall(`/users/${userId}/page-access`, 'PUT', { pageAccess: pageAccessData }); - + console.log('✅ 페이지 권한 저장 완료'); } catch (error) { console.error('❌ 페이지 권한 저장 오류:', error); @@ -845,12 +874,13 @@ async function savePageAccessFromModal() { } try { - await savePageAccess(currentPageAccessUser.user_id); + // 모달 컨테이너 지정 + await savePageAccess(currentPageAccessUser.user_id, 'pageAccessModalList'); showToast('페이지 권한이 저장되었습니다.', 'success'); - + // 캐시 삭제 (사용자가 다시 로그인하거나 페이지 새로고침 필요) localStorage.removeItem('userPageAccess'); - + closePageAccessModal(); } catch (error) { console.error('❌ 페이지 권한 저장 오류:', error); diff --git a/web-ui/js/api-config.js b/web-ui/js/api-config.js index 95b4ce2..a80acf1 100644 --- a/web-ui/js/api-config.js +++ b/web-ui/js/api-config.js @@ -246,4 +246,4 @@ setInterval(() => { }, config.app.tokenRefreshInterval); // 5분마다 확인 // ES6 모듈 export -export { API_URL as API_BASE_URL }; \ No newline at end of file +export { API_URL as API_BASE_URL, API_URL as API, apiCall, getAuthHeaders }; \ No newline at end of file diff --git a/web-ui/js/auth-check.js b/web-ui/js/auth-check.js index 8b146f1..de15476 100644 --- a/web-ui/js/auth-check.js +++ b/web-ui/js/auth-check.js @@ -79,7 +79,7 @@ async function checkPageAccess(pageKey) { // 캐시가 없으면 API 호출 if (!accessiblePages) { - const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, { + const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/web-ui/js/config.js b/web-ui/js/config.js index 009e3bb..0b551a5 100644 --- a/web-ui/js/config.js +++ b/web-ui/js/config.js @@ -26,10 +26,12 @@ export const config = { // 공용 컴포넌트 경로 설정 components: { - // 사이드바 HTML 파일 경로 + // 사이드바 HTML 파일 경로 (구버전) sidebar: '/components/sidebar.html', - // 네비게이션 바 HTML 파일 경로 (예상) - navbar: '/components/navbar.html', + // 새 사이드바 네비게이션 (카테고리별) + 'sidebar-nav': '/components/sidebar-nav.html', + // 네비게이션 바 HTML 파일 경로 + navbar: '/components/navbar.html', }, // 애플리케이션 관련 기타 설정 diff --git a/web-ui/js/daily-work-report.js b/web-ui/js/daily-work-report.js index 5884ab1..93d405c 100644 --- a/web-ui/js/daily-work-report.js +++ b/web-ui/js/daily-work-report.js @@ -18,6 +18,10 @@ let editingWorkId = null; // 수정 중인 작업 ID let incompleteTbms = []; // 미완료 TBM 작업 목록 let currentTab = 'tbm'; // 현재 활성 탭 +// 부적합 원인 관리 +let currentDefectIndex = null; // 현재 편집 중인 행 인덱스 +let tempDefects = {}; // 임시 부적합 원인 저장 { index: [{ error_type_id, defect_hours, note }] } + // 작업장소 지도 관련 변수 let mapCanvas = null; let mapCtx = null; @@ -156,9 +160,8 @@ function renderTbmWorkList() { 공정 작업 작업장소 - 작업시간
(시간) - 부적합
(시간) - 부적합 원인 + 작업시간 + 부적합 제출 @@ -190,9 +193,8 @@ function renderTbmWorkList() { 공정 작업 작업장소 - 작업시간
(시간) - 부적합
(시간) - 부적합 원인 + 작업시간 + 부적합 제출 @@ -226,18 +228,13 @@ function renderTbmWorkList() { -
- 0시간 -
- - - - - + + +
+ + `; }).join('')} @@ -293,8 +302,11 @@ window.submitTbmWorkReport = async function(index) { const tbm = incompleteTbms[index]; const totalHours = parseFloat(document.getElementById(`totalHours_${index}`).value); - const errorHours = parseFloat(document.getElementById(`errorHours_${index}`).value) || 0; - const errorTypeId = document.getElementById(`errorType_${index}`).value; + const defects = tempDefects[index] || []; + + // 총 부적합 시간 계산 + const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0); + const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null; // 필수 필드 검증 if (!totalHours || totalHours <= 0) { @@ -307,8 +319,10 @@ window.submitTbmWorkReport = async function(index) { return; } - if (errorHours > 0 && !errorTypeId) { - showMessage('부적합 처리 시간이 있는 경우 원인을 선택해주세요.', 'error'); + // 부적합 원인 유효성 검사 + const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id); + if (invalidDefects.length > 0) { + showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error'); return; } @@ -330,12 +344,12 @@ window.submitTbmWorkReport = async function(index) { end_time: null, total_hours: totalHours, error_hours: errorHours, - error_type_id: errorTypeId || null, + error_type_id: errorTypeId, work_status_id: errorHours > 0 ? 2 : 1 }; console.log('🔍 TBM 제출 데이터:', JSON.stringify(reportData, null, 2)); - console.log('🔍 tbm 객체:', tbm); + console.log('🔍 부적합 원인:', defects); try { const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData); @@ -344,6 +358,16 @@ window.submitTbmWorkReport = async function(index) { throw new Error(response.message || '작업보고서 제출 실패'); } + // 부적합 원인이 있으면 저장 + if (defects.length > 0 && response.data?.report_id) { + const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0); + if (validDefects.length > 0) { + await window.apiCall(`/daily-work-reports/${response.data.report_id}/defects`, 'PUT', { + defects: validDefects + }); + } + } + showSaveResultModal( 'success', '작업보고서 제출 완료', @@ -353,6 +377,9 @@ window.submitTbmWorkReport = async function(index) { response.data.completion_status ); + // 임시 부적합 데이터 삭제 + delete tempDefects[index]; + // 목록 새로고침 await loadIncompleteTbms(); } catch (error) { @@ -576,18 +603,13 @@ window.addManualWorkRow = function() { -
- 0시간 -
- - - - - + + + + + `; + tbody.appendChild(defectRow); + showMessage('새 작업 행이 추가되었습니다. 정보를 입력하고 제출하세요.', 'info'); }; @@ -608,9 +650,15 @@ window.addManualWorkRow = function() { */ window.removeManualWorkRow = function(manualIndex) { const row = document.querySelector(`tr[data-index="${manualIndex}"]`); + const defectRow = document.getElementById(`defectRow_${manualIndex}`); if (row) { row.remove(); } + if (defectRow) { + defectRow.remove(); + } + // 임시 부적합 데이터도 삭제 + delete tempDefects[manualIndex]; }; /** @@ -976,8 +1024,11 @@ window.submitManualWorkReport = async function(manualIndex) { const workplaceCategoryId = document.getElementById(`workplaceCategory_${manualIndex}`).value; const workplaceId = document.getElementById(`workplace_${manualIndex}`).value; const totalHours = parseFloat(document.getElementById(`totalHours_${manualIndex}`).value); - const errorHours = parseFloat(document.getElementById(`errorHours_${manualIndex}`).value) || 0; - const errorTypeId = document.getElementById(`errorType_${manualIndex}`).value; + + // 부적합 원인 가져오기 + const defects = tempDefects[manualIndex] || []; + const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0); + const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null; // 필수 필드 검증 if (!workerId) { @@ -1014,8 +1065,10 @@ window.submitManualWorkReport = async function(manualIndex) { return; } - if (errorHours > 0 && !errorTypeId) { - showMessage('부적합 처리 시간이 있는 경우 원인을 선택해주세요.', 'error'); + // 부적합 원인 유효성 검사 + const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id); + if (invalidDefects.length > 0) { + showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error'); return; } @@ -1042,13 +1095,23 @@ window.submitManualWorkReport = async function(manualIndex) { throw new Error(response.message || '작업보고서 제출 실패'); } + // 부적합 원인이 있으면 저장 + if (defects.length > 0 && response.data?.workReport_ids?.[0]) { + const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0); + if (validDefects.length > 0) { + await window.apiCall(`/daily-work-reports/${response.data.workReport_ids[0]}/defects`, 'PUT', { + defects: validDefects + }); + } + } + showSaveResultModal( 'success', '작업보고서 제출 완료', '작업보고서가 성공적으로 제출되었습니다.' ); - // 행 제거 + // 행 제거 (부적합 임시 데이터도 함께 삭제됨) removeManualWorkRow(manualIndex); // 목록 새로고침 @@ -2438,17 +2501,37 @@ function updateTimeDisplay() { */ window.confirmTimeSelection = function() { if (!currentEditingField) return; - - const { index, type } = currentEditingField; + + const { index, type, defectIndex } = currentEditingField; + + // 부적합 시간 선택인 경우 + if (type === 'defect') { + if (tempDefects[index] && tempDefects[index][defectIndex] !== undefined) { + tempDefects[index][defectIndex].defect_hours = currentTimeValue; + + // 시간 표시 업데이트 + const timeDisplay = document.getElementById(`defectTime_${index}_${defectIndex}`); + if (timeDisplay) { + timeDisplay.textContent = currentTimeValue; + } + + // 요약 및 hidden 필드 업데이트 + updateDefectSummary(index); + } + closeTimePicker(); + return; + } + + // 기존 total/error 시간 선택 const inputId = type === 'total' ? `totalHours_${index}` : `errorHours_${index}`; const displayId = type === 'total' ? `totalHoursDisplay_${index}` : `errorHoursDisplay_${index}`; - + // hidden input 값 설정 const hiddenInput = document.getElementById(inputId); if (hiddenInput) { hiddenInput.value = currentTimeValue; } - + // 표시 영역 업데이트 const displayDiv = document.getElementById(displayId); if (displayDiv) { @@ -2456,8 +2539,8 @@ window.confirmTimeSelection = function() { displayDiv.classList.remove('placeholder'); displayDiv.classList.add('has-value'); } - - // 부적합 시간 입력 시 에러 타입 토글 + + // 부적합 시간 입력 시 에러 타입 토글 (기존 방식 - 이제 사용안함) if (type === 'error') { if (index.toString().startsWith('manual_')) { toggleManualErrorType(index); @@ -2465,7 +2548,7 @@ window.confirmTimeSelection = function() { calculateRegularHours(index); } } - + closeTimePicker(); }; @@ -2477,10 +2560,200 @@ window.closeTimePicker = function() { if (overlay) { overlay.style.display = 'none'; } - + currentEditingField = null; currentTimeValue = 0; - + // ESC 키 리스너 제거 document.removeEventListener('keydown', handleEscapeKey); }; + +// ================================================================= +// 부적합 원인 관리 (인라인 방식) +// ================================================================= + +/** + * 부적합 영역 토글 + */ +window.toggleDefectArea = function(index) { + const defectRow = document.getElementById(`defectRow_${index}`); + if (!defectRow) return; + + const isVisible = defectRow.style.display !== 'none'; + + if (isVisible) { + // 숨기기 + defectRow.style.display = 'none'; + } else { + // 보이기 - 부적합 원인이 없으면 자동으로 하나 추가 + if (!tempDefects[index] || tempDefects[index].length === 0) { + tempDefects[index] = [{ + error_type_id: '', + defect_hours: 0, + note: '' + }]; + } + renderInlineDefectList(index); + defectRow.style.display = ''; + } +}; + +/** + * 인라인 부적합 목록 렌더링 + */ +function renderInlineDefectList(index) { + const listContainer = document.getElementById(`defectList_${index}`); + if (!listContainer) return; + + const defects = tempDefects[index] || []; + + listContainer.innerHTML = defects.map((defect, i) => ` +
+ +
+ ${defect.defect_hours || 0} + 시간 +
+ +
+ `).join(''); + + updateDefectSummary(index); +} + +/** + * 인라인 부적합 추가 + */ +window.addInlineDefect = function(index) { + if (!tempDefects[index]) { + tempDefects[index] = []; + } + + tempDefects[index].push({ + error_type_id: '', + defect_hours: 0, + note: '' + }); + + renderInlineDefectList(index); +}; + +/** + * 인라인 부적합 수정 + */ +window.updateInlineDefect = function(index, defectIndex, field, value) { + if (tempDefects[index] && tempDefects[index][defectIndex]) { + if (field === 'defect_hours') { + tempDefects[index][defectIndex][field] = parseFloat(value) || 0; + } else { + tempDefects[index][defectIndex][field] = value; + } + updateDefectSummary(index); + updateHiddenDefectFields(index); + } +}; + +/** + * 인라인 부적합 삭제 + */ +window.removeInlineDefect = function(index, defectIndex) { + if (tempDefects[index]) { + tempDefects[index].splice(defectIndex, 1); + + // 모든 부적합이 삭제되면 영역 숨기기 + if (tempDefects[index].length === 0) { + const defectRow = document.getElementById(`defectRow_${index}`); + if (defectRow) { + defectRow.style.display = 'none'; + } + } else { + renderInlineDefectList(index); + } + + updateDefectSummary(index); + updateHiddenDefectFields(index); + } +}; + +/** + * 부적합 시간 선택기 열기 (시간 선택 팝오버 재사용) + */ +window.openDefectTimePicker = function(index, defectIndex) { + currentEditingField = { index, type: 'defect', defectIndex }; + + // 현재 값 가져오기 + const defects = tempDefects[index] || []; + currentTimeValue = defects[defectIndex]?.defect_hours || 0; + + // 팝오버 표시 + const overlay = document.getElementById('timePickerOverlay'); + const title = document.getElementById('timePickerTitle'); + + title.textContent = '부적합 시간 선택'; + updateTimeDisplay(); + + overlay.style.display = 'flex'; + + // ESC 키로 닫기 + document.addEventListener('keydown', handleEscapeKey); +}; + +/** + * hidden input 필드 업데이트 + */ +function updateHiddenDefectFields(index) { + const defects = tempDefects[index] || []; + + // 총 부적합 시간 계산 + const totalErrorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0); + + // hidden input에 대표 error_type_id 저장 (첫 번째 값) + const errorTypeInput = document.getElementById(`errorType_${index}`); + if (errorTypeInput && defects.length > 0 && defects[0].error_type_id) { + errorTypeInput.value = defects[0].error_type_id; + } else if (errorTypeInput) { + errorTypeInput.value = ''; + } + + // 부적합 시간 input 업데이트 + const errorHoursInput = document.getElementById(`errorHours_${index}`); + if (errorHoursInput) { + errorHoursInput.value = totalErrorHours; + } +} + +/** + * 부적합 요약 텍스트 업데이트 + */ +function updateDefectSummary(index) { + const summaryEl = document.getElementById(`defectSummary_${index}`); + const toggleBtn = document.getElementById(`defectToggle_${index}`); + if (!summaryEl) return; + + const defects = tempDefects[index] || []; + const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0); + + if (validDefects.length === 0) { + summaryEl.textContent = '없음'; + summaryEl.style.color = '#6b7280'; + if (toggleBtn) toggleBtn.classList.remove('has-defect'); + } else { + const totalHours = validDefects.reduce((sum, d) => sum + d.defect_hours, 0); + if (validDefects.length === 1) { + const typeName = errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합'; + summaryEl.textContent = `${typeName} ${totalHours}h`; + } else { + summaryEl.textContent = `${validDefects.length}건 ${totalHours}h`; + } + summaryEl.style.color = '#dc2626'; + if (toggleBtn) toggleBtn.classList.add('has-defect'); + } + + // hidden 필드도 업데이트 + updateHiddenDefectFields(index); +} diff --git a/web-ui/js/load-navbar.js b/web-ui/js/load-navbar.js index f15358d..59f1fe5 100644 --- a/web-ui/js/load-navbar.js +++ b/web-ui/js/load-navbar.js @@ -36,8 +36,9 @@ async function processNavbarDom(doc) { async function filterMenuByPageAccess(doc, currentUser) { const userRole = (currentUser.role || '').toLowerCase(); - // Admin은 모든 메뉴 표시 - if (userRole === 'admin' || userRole === 'system') { + // Admin은 모든 메뉴 표시 + .admin-only 요소 활성화 + if (userRole === 'admin' || userRole === 'system admin') { + doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible')); return; } @@ -56,7 +57,7 @@ async function filterMenuByPageAccess(doc, currentUser) { // 캐시가 없으면 API 호출 if (!accessiblePages) { - const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, { + const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -153,14 +154,118 @@ function setupNavbarEvents() { } /** - * 현재 시간을 업데이트하는 함수 + * 현재 날짜와 시간을 업데이트하는 함수 */ -function updateTime() { - const timeElement = document.getElementById('timeValue'); - if (timeElement) { - const now = new Date(); - timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false }); +function updateDateTime() { + const now = new Date(); + + // 시간 업데이트 + const timeElement = document.getElementById('timeValue'); + if (timeElement) { + timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false }); + } + + // 날짜 업데이트 + const dateElement = document.getElementById('dateValue'); + if (dateElement) { + const days = ['일', '월', '화', '수', '목', '금', '토']; + const month = now.getMonth() + 1; + const date = now.getDate(); + const day = days[now.getDay()]; + dateElement.textContent = `${month}월 ${date}일 (${day})`; + } +} + +// 날씨 아이콘 매핑 +const WEATHER_ICONS = { + clear: '☀️', + rain: '🌧️', + snow: '❄️', + heat: '🔥', + cold: '🥶', + wind: '💨', + fog: '🌫️', + dust: '😷', + cloudy: '⛅', + overcast: '☁️' +}; + +// 날씨 조건명 +const WEATHER_NAMES = { + clear: '맑음', + rain: '비', + snow: '눈', + heat: '폭염', + cold: '한파', + wind: '강풍', + fog: '안개', + dust: '미세먼지', + cloudy: '구름많음', + overcast: '흐림' +}; + +/** + * 날씨 정보를 가져와서 업데이트하는 함수 + */ +async function updateWeather() { + try { + const token = localStorage.getItem('token'); + if (!token) return; + + const response = await fetch(`${window.API_BASE_URL}/tbm/weather/current`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error('날씨 API 호출 실패'); } + + const result = await response.json(); + + if (result.success && result.data) { + const { temperature, conditions, weatherData } = result.data; + + // 온도 표시 + const tempElement = document.getElementById('weatherTemp'); + if (tempElement && temperature !== null && temperature !== undefined) { + tempElement.textContent = `${Math.round(temperature)}°C`; + } + + // 날씨 아이콘 및 설명 + const iconElement = document.getElementById('weatherIcon'); + const descElement = document.getElementById('weatherDesc'); + + if (conditions && conditions.length > 0) { + const primaryCondition = conditions[0]; + if (iconElement) { + iconElement.textContent = WEATHER_ICONS[primaryCondition] || '🌤️'; + } + if (descElement) { + descElement.textContent = WEATHER_NAMES[primaryCondition] || '맑음'; + } + } else { + if (iconElement) iconElement.textContent = '☀️'; + if (descElement) descElement.textContent = '맑음'; + } + + // 날씨 섹션 표시 + const weatherSection = document.getElementById('weatherSection'); + if (weatherSection) { + weatherSection.style.opacity = '1'; + } + } + } catch (error) { + console.warn('날씨 정보 로드 실패:', error.message); + // 실패해도 기본값 표시 + const descElement = document.getElementById('weatherDesc'); + if (descElement) { + descElement.textContent = '날씨 정보 없음'; + } + } } // 메인 로직: DOMContentLoaded 시 실행 @@ -168,12 +273,16 @@ document.addEventListener('DOMContentLoaded', async () => { if (getUser()) { // 1. 컴포넌트 로드 및 DOM 수정 await loadComponent('navbar', '#navbar-container', processNavbarDom); - + // 2. DOM에 삽입된 후에 이벤트 리스너 설정 setupNavbarEvents(); - // 3. 실시간 시간 업데이트 시작 - updateTime(); - setInterval(updateTime, 1000); + // 3. 실시간 날짜/시간 업데이트 시작 + updateDateTime(); + setInterval(updateDateTime, 1000); + + // 4. 날씨 정보 로드 (10분마다 갱신) + updateWeather(); + setInterval(updateWeather, 10 * 60 * 1000); } }); \ No newline at end of file diff --git a/web-ui/js/load-sidebar.js b/web-ui/js/load-sidebar.js index 1ca087f..b55ce71 100644 --- a/web-ui/js/load-sidebar.js +++ b/web-ui/js/load-sidebar.js @@ -1,47 +1,191 @@ // /js/load-sidebar.js +// 사이드바 네비게이션 로더 및 컨트롤러 + import { getUser } from './auth.js'; import { loadComponent } from './component-loader.js'; /** - * 사용자 역할에 따라 사이드바 메뉴 항목을 필터링하는 DOM 프로세서입니다. - * @param {Document} doc - 파싱된 HTML 문서 객체 + * 사이드바 DOM을 사용자 권한에 맞게 처리 */ -function filterSidebarByRole(doc) { +async function processSidebarDom(doc) { const currentUser = getUser(); - if (!currentUser) return; // 비로그인 상태면 필터링하지 않음 + if (!currentUser) return; - const userRole = currentUser.role; + const userRole = (currentUser.role || '').toLowerCase(); + const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system'; - // 'system' 역할은 모든 메뉴를 볼 수 있으므로 필터링하지 않음 - if (userRole === 'system') { - return; + // 1. 관리자 전용 메뉴 표시/숨김 + if (isAdmin) { + doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible')); + } else { + // 비관리자: 페이지 접근 권한에 따라 메뉴 필터링 + await filterMenuByPageAccess(doc, currentUser); } - - // 역할과 그에 해당하는 클래스 선택자 매핑 - const roleClassMap = { - admin: '.admin-only', - leader: '.leader-only', - user: '.user-only', - support: '.support-only' - }; - // 모든 역할 기반 선택자를 가져옴 - const allRoleSelectors = Object.values(roleClassMap).join(', '); - const allRoleElements = doc.querySelectorAll(allRoleSelectors); + // 2. 현재 페이지 활성화 + highlightCurrentPage(doc); - allRoleElements.forEach(el => { - // 요소가 현재 사용자 역할에 해당하는 클래스를 가지고 있는지 확인 - const userRoleSelector = roleClassMap[userRole]; - if (!userRoleSelector || !el.matches(userRoleSelector)) { - el.remove(); + // 3. 저장된 상태 복원 + restoreSidebarState(doc); +} + +/** + * 사용자의 페이지 접근 권한에 따라 메뉴 필터링 + */ +async function filterMenuByPageAccess(doc, currentUser) { + try { + const cached = localStorage.getItem('userPageAccess'); + let accessiblePages = null; + + if (cached) { + const cacheData = JSON.parse(cached); + if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) { + accessiblePages = cacheData.pages; + } + } + + if (!accessiblePages) { + const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) return; + + const data = await response.json(); + accessiblePages = data.data.pageAccess || []; + + localStorage.setItem('userPageAccess', JSON.stringify({ + pages: accessiblePages, + timestamp: Date.now() + })); + } + + const accessiblePageKeys = accessiblePages + .filter(p => p.can_access === 1) + .map(p => p.page_key); + + // 메뉴 항목 필터링 + const menuItems = doc.querySelectorAll('[data-page-key]'); + menuItems.forEach(item => { + const pageKey = item.getAttribute('data-page-key'); + + // 대시보드와 프로필은 항상 표시 + if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) { + return; + } + + // 권한 없으면 숨김 + if (!accessiblePageKeys.includes(pageKey)) { + item.style.display = 'none'; + } + }); + + // 관리자 전용 카테고리 제거 + doc.querySelectorAll('.nav-category.admin-only').forEach(el => el.remove()); + + } catch (error) { + console.error('사이드바 메뉴 필터링 오류:', error); + } +} + +/** + * 현재 페이지 하이라이트 + */ +function highlightCurrentPage(doc) { + const currentPath = window.location.pathname; + + doc.querySelectorAll('.nav-item').forEach(item => { + const href = item.getAttribute('href'); + if (href && currentPath.includes(href.replace(/^\//, ''))) { + item.classList.add('active'); + + // 부모 카테고리 열기 + const category = item.closest('.nav-category'); + if (category) { + category.classList.add('expanded'); + } } }); } -// 페이지 로드 시 사이드바를 로드하고 역할에 따라 필터링합니다. -document.addEventListener('DOMContentLoaded', () => { - // 'getUser'를 통해 로그인 상태 확인. 비로그인 시 아무 작업도 하지 않음. - if (getUser()) { - loadComponent('sidebar', '#sidebar-container', filterSidebarByRole); +/** + * 사이드바 상태 복원 + */ +function restoreSidebarState(doc) { + const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true'; + const sidebar = doc.querySelector('.sidebar-nav'); + + if (isCollapsed && sidebar) { + sidebar.classList.add('collapsed'); + document.body.classList.add('sidebar-collapsed'); } -}); \ No newline at end of file + + // 확장된 카테고리 복원 + const expandedCategories = JSON.parse(localStorage.getItem('sidebarExpanded') || '[]'); + expandedCategories.forEach(category => { + const el = doc.querySelector(`[data-category="${category}"]`); + if (el) el.classList.add('expanded'); + }); +} + +/** + * 사이드바 이벤트 설정 + */ +function setupSidebarEvents() { + const sidebar = document.getElementById('sidebarNav'); + const toggle = document.getElementById('sidebarToggle'); + + if (!sidebar || !toggle) return; + + // 토글 버튼 클릭 + toggle.addEventListener('click', () => { + sidebar.classList.toggle('collapsed'); + document.body.classList.toggle('sidebar-collapsed'); + + localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed')); + }); + + // 카테고리 헤더 클릭 + sidebar.querySelectorAll('.nav-category-header').forEach(header => { + header.addEventListener('click', () => { + const category = header.closest('.nav-category'); + category.classList.toggle('expanded'); + + // 상태 저장 + const expanded = []; + sidebar.querySelectorAll('.nav-category.expanded').forEach(cat => { + const categoryName = cat.getAttribute('data-category'); + if (categoryName) expanded.push(categoryName); + }); + localStorage.setItem('sidebarExpanded', JSON.stringify(expanded)); + }); + }); +} + +/** + * 사이드바 초기화 + */ +async function initSidebar() { + // 사이드바 컨테이너가 없으면 생성 + let container = document.getElementById('sidebar-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'sidebar-container'; + document.body.prepend(container); + } + + if (getUser()) { + await loadComponent('sidebar-nav', '#sidebar-container', processSidebarDom); + document.body.classList.add('has-sidebar'); + setupSidebarEvents(); + } +} + +// DOMContentLoaded 시 초기화 +document.addEventListener('DOMContentLoaded', initSidebar); + +export { initSidebar }; \ No newline at end of file diff --git a/web-ui/js/safety-checklist-manage.js b/web-ui/js/safety-checklist-manage.js new file mode 100644 index 0000000..4529edb --- /dev/null +++ b/web-ui/js/safety-checklist-manage.js @@ -0,0 +1,718 @@ +/** + * 안전 체크리스트 관리 페이지 스크립트 + * + * 3가지 유형의 체크리스트 항목을 관리: + * 1. 기본 사항 - 항상 표시 + * 2. 날씨별 - 날씨 조건에 따라 표시 + * 3. 작업별 - 선택한 작업에 따라 표시 + * + * @since 2026-02-02 + */ + +import { apiCall } from './api-config.js'; + +// 전역 상태 +let allChecks = []; +let weatherConditions = []; +let workTypes = []; +let tasks = []; +let currentTab = 'basic'; +let editingCheckId = null; + +// 카테고리 정보 +const CATEGORIES = { + PPE: { name: 'PPE (개인보호장비)', icon: '🦺' }, + EQUIPMENT: { name: 'EQUIPMENT (장비점검)', icon: '🔧' }, + ENVIRONMENT: { name: 'ENVIRONMENT (작업환경)', icon: '🏗️' }, + EMERGENCY: { name: 'EMERGENCY (비상대응)', icon: '🚨' }, + WEATHER: { name: 'WEATHER (날씨)', icon: '🌤️' }, + TASK: { name: 'TASK (작업)', icon: '📋' } +}; + +// 날씨 아이콘 매핑 +const WEATHER_ICONS = { + clear: '☀️', + rain: '🌧️', + snow: '❄️', + heat: '🔥', + cold: '🥶', + wind: '💨', + fog: '🌫️', + dust: '😷' +}; + +/** + * 페이지 초기화 + */ +async function initPage() { + try { + console.log('📋 안전 체크리스트 관리 페이지 초기화...'); + + await Promise.all([ + loadAllChecks(), + loadWeatherConditions(), + loadWorkTypes() + ]); + + renderCurrentTab(); + console.log('✅ 초기화 완료. 체크항목:', allChecks.length, '개'); + } catch (error) { + console.error('초기화 실패:', error); + showToast('데이터를 불러오는데 실패했습니다.', 'error'); + } +} + +// DOMContentLoaded 이벤트 +document.addEventListener('DOMContentLoaded', initPage); + +/** + * 모든 안전 체크 항목 로드 + */ +async function loadAllChecks() { + try { + const response = await apiCall('/tbm/safety-checks'); + if (response && response.success) { + allChecks = response.data || []; + console.log('✅ 체크 항목 로드:', allChecks.length, '개'); + } else { + console.warn('체크 항목 응답 실패:', response); + allChecks = []; + } + } catch (error) { + console.error('체크 항목 로드 실패:', error); + allChecks = []; + } +} + +/** + * 날씨 조건 목록 로드 + */ +async function loadWeatherConditions() { + try { + const response = await apiCall('/tbm/weather/conditions'); + if (response && response.success) { + weatherConditions = response.data || []; + populateWeatherSelects(); + console.log('✅ 날씨 조건 로드:', weatherConditions.length, '개'); + } + } catch (error) { + console.error('날씨 조건 로드 실패:', error); + weatherConditions = []; + } +} + +/** + * 공정(작업 유형) 목록 로드 + */ +async function loadWorkTypes() { + try { + const response = await apiCall('/daily-work-reports/work-types'); + if (response && response.success) { + workTypes = response.data || []; + populateWorkTypeSelects(); + console.log('✅ 공정 목록 로드:', workTypes.length, '개'); + } + } catch (error) { + console.error('공정 목록 로드 실패:', error); + workTypes = []; + } +} + +/** + * 날씨 조건 셀렉트 박스 채우기 + */ +function populateWeatherSelects() { + const filterSelect = document.getElementById('weatherFilter'); + const modalSelect = document.getElementById('weatherCondition'); + + const options = weatherConditions.map(wc => + `` + ).join(''); + + if (filterSelect) { + filterSelect.innerHTML = `${options}`; + } + + if (modalSelect) { + modalSelect.innerHTML = options || ''; + } +} + +/** + * 공정 셀렉트 박스 채우기 + */ +function populateWorkTypeSelects() { + const filterSelect = document.getElementById('workTypeFilter'); + const modalSelect = document.getElementById('modalWorkType'); + + const options = workTypes.map(wt => + `` + ).join(''); + + if (filterSelect) { + filterSelect.innerHTML = `${options}`; + } + + if (modalSelect) { + modalSelect.innerHTML = `${options}`; + } +} + +/** + * 탭 전환 + */ +function switchTab(tabName) { + currentTab = tabName; + + // 탭 버튼 상태 업데이트 + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + + // 탭 콘텐츠 표시/숨김 + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.toggle('active', content.id === `${tabName}Tab`); + }); + + renderCurrentTab(); +} + +/** + * 현재 탭 렌더링 + */ +function renderCurrentTab() { + switch (currentTab) { + case 'basic': + renderBasicChecks(); + break; + case 'weather': + renderWeatherChecks(); + break; + case 'task': + renderTaskChecks(); + break; + } +} + +/** + * 기본 체크 항목 렌더링 + */ +function renderBasicChecks() { + const container = document.getElementById('basicChecklistContainer'); + const basicChecks = allChecks.filter(c => c.check_type === 'basic'); + + console.log('기본 체크항목:', basicChecks.length, '개'); + + if (basicChecks.length === 0) { + container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.'); + return; + } + + // 카테고리별로 그룹화 + const grouped = groupByCategory(basicChecks); + + container.innerHTML = Object.entries(grouped).map(([category, items]) => + renderChecklistGroup(category, items) + ).join(''); +} + +/** + * 날씨별 체크 항목 렌더링 + */ +function renderWeatherChecks() { + const container = document.getElementById('weatherChecklistContainer'); + const filterValue = document.getElementById('weatherFilter')?.value; + + let weatherChecks = allChecks.filter(c => c.check_type === 'weather'); + + if (filterValue) { + weatherChecks = weatherChecks.filter(c => c.weather_condition === filterValue); + } + + if (weatherChecks.length === 0) { + container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.'); + return; + } + + // 날씨 조건별로 그룹화 + const grouped = groupByWeather(weatherChecks); + + container.innerHTML = Object.entries(grouped).map(([condition, items]) => { + const conditionInfo = weatherConditions.find(wc => wc.condition_code === condition); + const icon = WEATHER_ICONS[condition] || '🌤️'; + const name = conditionInfo?.condition_name || condition; + + return renderChecklistGroup(`${icon} ${name}`, items, condition); + }).join(''); +} + +/** + * 작업별 체크 항목 렌더링 + */ +function renderTaskChecks() { + const container = document.getElementById('taskChecklistContainer'); + const workTypeId = document.getElementById('workTypeFilter')?.value; + const taskId = document.getElementById('taskFilter')?.value; + + let taskChecks = allChecks.filter(c => c.check_type === 'task'); + + if (taskId) { + taskChecks = taskChecks.filter(c => c.task_id == taskId); + } else if (workTypeId && tasks.length > 0) { + const workTypeTasks = tasks.filter(t => t.work_type_id == workTypeId); + const taskIds = workTypeTasks.map(t => t.task_id); + taskChecks = taskChecks.filter(c => taskIds.includes(c.task_id)); + } + + if (taskChecks.length === 0) { + container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.'); + return; + } + + // 작업별로 그룹화 + const grouped = groupByTask(taskChecks); + + container.innerHTML = Object.entries(grouped).map(([taskId, items]) => { + const task = tasks.find(t => t.task_id == taskId); + const taskName = task?.task_name || `작업 ${taskId}`; + + return renderChecklistGroup(`📋 ${taskName}`, items, null, taskId); + }).join(''); +} + +/** + * 카테고리별 그룹화 + */ +function groupByCategory(checks) { + return checks.reduce((acc, check) => { + const category = check.check_category || 'OTHER'; + if (!acc[category]) acc[category] = []; + acc[category].push(check); + return acc; + }, {}); +} + +/** + * 날씨 조건별 그룹화 + */ +function groupByWeather(checks) { + return checks.reduce((acc, check) => { + const condition = check.weather_condition || 'other'; + if (!acc[condition]) acc[condition] = []; + acc[condition].push(check); + return acc; + }, {}); +} + +/** + * 작업별 그룹화 + */ +function groupByTask(checks) { + return checks.reduce((acc, check) => { + const taskId = check.task_id || 0; + if (!acc[taskId]) acc[taskId] = []; + acc[taskId].push(check); + return acc; + }, {}); +} + +/** + * 체크리스트 그룹 렌더링 + */ +function renderChecklistGroup(title, items, weatherCondition = null, taskId = null) { + const categoryInfo = CATEGORIES[title] || { name: title, icon: '' }; + const displayTitle = categoryInfo.name !== title ? categoryInfo.name : title; + const icon = categoryInfo.icon || ''; + + // 표시 순서로 정렬 + items.sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + return ` +
+
+
+ ${icon} + ${displayTitle} +
+ ${items.length}개 +
+
+ ${items.map(item => renderChecklistItem(item)).join('')} +
+
+ `; +} + +/** + * 체크리스트 항목 렌더링 + */ +function renderChecklistItem(item) { + const requiredBadge = item.is_required + ? '필수' + : '선택'; + + return ` +
+
+
${item.check_item}
+
+ ${requiredBadge} + ${item.description ? `${item.description}` : ''} +
+
+
+ + +
+
+ `; +} + +/** + * 빈 상태 렌더링 + */ +function renderEmptyState(message) { + return ` +
+
📋
+

${message}

+
+ `; +} + +/** + * 날씨 필터 변경 + */ +function filterByWeather() { + renderWeatherChecks(); +} + +/** + * 공정 필터 변경 + */ +async function filterByWorkType() { + const workTypeId = document.getElementById('workTypeFilter')?.value; + const taskSelect = document.getElementById('taskFilter'); + + // workTypeId가 없거나 빈 문자열이면 early return + if (!workTypeId || workTypeId === '' || workTypeId === 'undefined') { + if (taskSelect) { + taskSelect.innerHTML = ''; + } + tasks = []; + renderTaskChecks(); + return; + } + + try { + const response = await apiCall(`/tasks/work-type/${workTypeId}`); + if (response && response.success) { + tasks = response.data || []; + taskSelect.innerHTML = '' + + tasks.map(t => ``).join(''); + } + } catch (error) { + console.error('작업 목록 로드 실패:', error); + tasks = []; + } + + renderTaskChecks(); +} + +/** + * 작업 필터 변경 + */ +function filterByTask() { + renderTaskChecks(); +} + +/** + * 모달의 작업 목록 로드 + */ +async function loadModalTasks() { + const workTypeId = document.getElementById('modalWorkType')?.value; + const taskSelect = document.getElementById('modalTask'); + + // workTypeId가 없거나 빈 문자열이면 early return + if (!workTypeId || workTypeId === '' || workTypeId === 'undefined') { + if (taskSelect) { + taskSelect.innerHTML = ''; + } + return; + } + + try { + const response = await apiCall(`/tasks/work-type/${workTypeId}`); + if (response && response.success) { + const modalTasks = response.data || []; + taskSelect.innerHTML = '' + + modalTasks.map(t => ``).join(''); + } + } catch (error) { + console.error('작업 목록 로드 실패:', error); + } +} + +/** + * 조건부 필드 토글 + */ +function toggleConditionalFields() { + const checkType = document.querySelector('input[name="checkType"]:checked')?.value; + + document.getElementById('basicFields').classList.toggle('show', checkType === 'basic'); + document.getElementById('weatherFields').classList.toggle('show', checkType === 'weather'); + document.getElementById('taskFields').classList.toggle('show', checkType === 'task'); +} + +/** + * 추가 모달 열기 + */ +function openAddModal() { + editingCheckId = null; + document.getElementById('modalTitle').textContent = '체크 항목 추가'; + + // 폼 초기화 + document.getElementById('checkForm').reset(); + document.getElementById('checkId').value = ''; + + // 현재 탭에 맞는 유형 선택 + const typeRadio = document.querySelector(`input[name="checkType"][value="${currentTab}"]`); + if (typeRadio) { + typeRadio.checked = true; + } + + toggleConditionalFields(); + showModal(); +} + +/** + * 수정 모달 열기 + */ +async function openEditModal(checkId) { + editingCheckId = checkId; + const check = allChecks.find(c => c.check_id === checkId); + + if (!check) { + showToast('항목을 찾을 수 없습니다.', 'error'); + return; + } + + document.getElementById('modalTitle').textContent = '체크 항목 수정'; + document.getElementById('checkId').value = checkId; + + // 유형 선택 + const typeRadio = document.querySelector(`input[name="checkType"][value="${check.check_type}"]`); + if (typeRadio) { + typeRadio.checked = true; + } + + toggleConditionalFields(); + + // 카테고리 + if (check.check_type === 'basic') { + document.getElementById('checkCategory').value = check.check_category || 'PPE'; + } + + // 날씨 조건 + if (check.check_type === 'weather') { + document.getElementById('weatherCondition').value = check.weather_condition || ''; + } + + // 작업 + if (check.check_type === 'task' && check.task_id) { + // 먼저 공정 찾기 (task를 통해) + const task = tasks.find(t => t.task_id === check.task_id); + if (task) { + document.getElementById('modalWorkType').value = task.work_type_id; + await loadModalTasks(); + document.getElementById('modalTask').value = check.task_id; + } + } + + // 공통 필드 + document.getElementById('checkItem').value = check.check_item || ''; + document.getElementById('checkDescription').value = check.description || ''; + document.getElementById('isRequired').checked = check.is_required === 1 || check.is_required === true; + document.getElementById('displayOrder').value = check.display_order || 0; + + showModal(); +} + +/** + * 모달 표시 + */ +function showModal() { + document.getElementById('checkModal').style.display = 'flex'; +} + +/** + * 모달 닫기 + */ +function closeModal() { + document.getElementById('checkModal').style.display = 'none'; + editingCheckId = null; +} + +/** + * 체크 항목 저장 + */ +async function saveCheck() { + const checkType = document.querySelector('input[name="checkType"]:checked')?.value; + const checkItem = document.getElementById('checkItem').value.trim(); + + if (!checkItem) { + showToast('체크 항목을 입력해주세요.', 'error'); + return; + } + + const data = { + check_type: checkType, + check_item: checkItem, + description: document.getElementById('checkDescription').value.trim() || null, + is_required: document.getElementById('isRequired').checked, + display_order: parseInt(document.getElementById('displayOrder').value) || 0 + }; + + // 유형별 추가 데이터 + switch (checkType) { + case 'basic': + data.check_category = document.getElementById('checkCategory').value; + break; + case 'weather': + data.check_category = 'WEATHER'; + data.weather_condition = document.getElementById('weatherCondition').value; + if (!data.weather_condition) { + showToast('날씨 조건을 선택해주세요.', 'error'); + return; + } + break; + case 'task': + data.check_category = 'TASK'; + data.task_id = document.getElementById('modalTask').value; + if (!data.task_id) { + showToast('작업을 선택해주세요.', 'error'); + return; + } + break; + } + + try { + let response; + if (editingCheckId) { + // 수정 + response = await apiCall(`/tbm/safety-checks/${editingCheckId}`, 'PUT', data); + } else { + // 추가 + response = await apiCall('/tbm/safety-checks', 'POST', data); + } + + if (response && response.success) { + showToast(editingCheckId ? '항목이 수정되었습니다.' : '항목이 추가되었습니다.', 'success'); + closeModal(); + await loadAllChecks(); + renderCurrentTab(); + } else { + showToast(response?.message || '저장에 실패했습니다.', 'error'); + } + } catch (error) { + console.error('저장 실패:', error); + showToast('저장 중 오류가 발생했습니다.', 'error'); + } +} + +/** + * 삭제 확인 + */ +function confirmDelete(checkId) { + const check = allChecks.find(c => c.check_id === checkId); + + if (!check) { + showToast('항목을 찾을 수 없습니다.', 'error'); + return; + } + + if (confirm(`"${check.check_item}" 항목을 삭제하시겠습니까?`)) { + deleteCheck(checkId); + } +} + +/** + * 체크 항목 삭제 + */ +async function deleteCheck(checkId) { + try { + const response = await apiCall(`/tbm/safety-checks/${checkId}`, 'DELETE'); + + if (response && response.success) { + showToast('항목이 삭제되었습니다.', 'success'); + await loadAllChecks(); + renderCurrentTab(); + } else { + showToast(response?.message || '삭제에 실패했습니다.', 'error'); + } + } catch (error) { + console.error('삭제 실패:', error); + showToast('삭제 중 오류가 발생했습니다.', 'error'); + } +} + +/** + * 토스트 메시지 표시 + */ +function showToast(message, type = 'info') { + // 기존 토스트 제거 + const existingToast = document.querySelector('.toast-message'); + if (existingToast) { + existingToast.remove(); + } + + const toast = document.createElement('div'); + toast.className = `toast-message toast-${type}`; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + z-index: 9999; + animation: fadeInUp 0.3s ease; + ${type === 'success' ? 'background: #10b981; color: white;' : ''} + ${type === 'error' ? 'background: #ef4444; color: white;' : ''} + ${type === 'info' ? 'background: #3b82f6; color: white;' : ''} + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'fadeOut 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +// 모달 외부 클릭 시 닫기 +document.getElementById('checkModal')?.addEventListener('click', function(e) { + if (e.target === this) { + closeModal(); + } +}); + +// HTML onclick에서 호출할 수 있도록 전역에 노출 +window.switchTab = switchTab; +window.openAddModal = openAddModal; +window.openEditModal = openEditModal; +window.closeModal = closeModal; +window.saveCheck = saveCheck; +window.confirmDelete = confirmDelete; +window.filterByWeather = filterByWeather; +window.filterByWorkType = filterByWorkType; +window.filterByTask = filterByTask; +window.loadModalTasks = loadModalTasks; +window.toggleConditionalFields = toggleConditionalFields; diff --git a/web-ui/js/safety-management.js b/web-ui/js/safety-management.js index fcd2ff3..a6fc975 100644 --- a/web-ui/js/safety-management.js +++ b/web-ui/js/safety-management.js @@ -432,7 +432,7 @@ async function confirmReject() { * 안전교육 진행 페이지로 이동 */ function startTraining(requestId) { - window.location.href = `/pages/admin/safety-training-conduct.html?request_id=${requestId}`; + window.location.href = `/pages/safety/training-conduct.html?request_id=${requestId}`; } // 전역 함수로 노출 diff --git a/web-ui/js/safety-training-conduct.js b/web-ui/js/safety-training-conduct.js index a7bd2d1..23f5e20 100644 --- a/web-ui/js/safety-training-conduct.js +++ b/web-ui/js/safety-training-conduct.js @@ -98,7 +98,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (!requestId) { showToast('출입 신청 ID가 없습니다.', 'error'); setTimeout(() => { - window.location.href = '/pages/admin/safety-management.html'; + window.location.href = '/pages/safety/management.html'; }, 2000); return; } @@ -130,7 +130,7 @@ async function loadRequestInfo() { if (requestData.status !== 'approved') { showToast('이미 처리되었거나 승인되지 않은 신청입니다.', 'error'); setTimeout(() => { - window.location.href = '/pages/admin/safety-management.html'; + window.location.href = '/pages/safety/management.html'; }, 2000); return; } @@ -518,7 +518,7 @@ async function completeTraining() { if (successCount === savedSignatures.length) { showToast(`${successCount}명의 안전교육이 완료되었습니다.`, 'success'); setTimeout(() => { - window.location.href = '/pages/admin/safety-management.html'; + window.location.href = '/pages/safety/management.html'; }, 1500); } else if (successCount > 0) { showToast(`${successCount}/${savedSignatures.length}명의 교육만 저장되었습니다.`, 'warning'); @@ -540,7 +540,7 @@ function goBack() { return; } } - window.location.href = '/pages/admin/safety-management.html'; + window.location.href = '/pages/safety/management.html'; } // 전역 함수로 노출 diff --git a/web-ui/js/tbm.js b/web-ui/js/tbm.js index 6e32ef0..9cd8168 100644 --- a/web-ui/js/tbm.js +++ b/web-ui/js/tbm.js @@ -26,6 +26,11 @@ let selectedWorkplaceName = ''; let isBulkMode = false; // 일괄 설정 모드인지 여부 let bulkSelectedWorkers = new Set(); // 일괄 설정에서 선택된 작업자 인덱스 +// TBM 관리 탭용 변수 +let loadedDaysCount = 7; // 처음에 로드할 일수 +let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션 +let allLoadedSessions = []; // 전체 로드된 세션 + // ==================== 유틸리티 함수 ==================== /** @@ -87,8 +92,10 @@ document.addEventListener('DOMContentLoaded', async () => { // 오늘 날짜 설정 (서울 시간대 기준) const today = getTodayKST(); - document.getElementById('tbmDate').value = today; - document.getElementById('sessionDate').value = today; + const tbmDateEl = document.getElementById('tbmDate'); + const sessionDateEl = document.getElementById('sessionDate'); + if (tbmDateEl) tbmDateEl.value = today; + if (sessionDateEl) sessionDateEl.value = today; // 이벤트 리스너 설정 setupEventListeners(); @@ -100,22 +107,16 @@ document.addEventListener('DOMContentLoaded', async () => { // 이벤트 리스너 설정 function setupEventListeners() { - const tbmDateInput = document.getElementById('tbmDate'); - if (tbmDateInput) { - tbmDateInput.addEventListener('change', () => { - const date = tbmDateInput.value; - loadTbmSessionsByDate(date); - }); - } + // 날짜 선택기 제거됨 - 날짜별 그룹 뷰 사용 } // 초기 데이터 로드 async function loadInitialData() { try { // 현재 로그인한 사용자 정보 가져오기 - const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}'); + const userInfo = JSON.parse(localStorage.getItem('user') || '{}'); currentUser = userInfo; - console.log('👤 로그인 사용자:', currentUser); + console.log('👤 로그인 사용자:', currentUser, 'worker_id:', currentUser?.worker_id); // 작업자 목록 로드 const workersResponse = await window.apiCall('/workers?limit=1000'); @@ -202,12 +203,7 @@ function switchTbmTab(tabName) { if (tabName === 'tbm-input') { loadTodayOnlyTbm(); } else if (tabName === 'tbm-manage') { - const tbmDate = document.getElementById('tbmDate'); - if (tbmDate && tbmDate.value) { - loadTbmSessionsByDate(tbmDate.value); - } else { - loadTodayTbm(); - } + loadRecentTbmGroupedByDate(); } } window.switchTbmTab = switchTbmTab; @@ -268,36 +264,175 @@ function displayTodayTbmSessions() { // ==================== TBM 관리 탭 ==================== -// 오늘 TBM 로드 (TBM 관리 탭용) +// 오늘 TBM 로드 (TBM 관리 탭용) - 레거시 호환 async function loadTodayTbm() { - const today = getTodayKST(); - document.getElementById('tbmDate').value = today; - await loadTbmSessionsByDate(today); + await loadRecentTbmGroupedByDate(); } window.loadTodayTbm = loadTodayTbm; -// 전체 TBM 로드 +// 전체 TBM 로드 - 레거시 호환 async function loadAllTbm() { - try { - const response = await window.apiCall('/tbm/sessions'); - - if (response && response.success) { - allSessions = response.data || []; - document.getElementById('tbmDate').value = ''; - displayTbmSessions(); - } else { - allSessions = []; - displayTbmSessions(); - } - } catch (error) { - console.error('❌ 전체 TBM 조회 오류:', error); - showToast('전체 TBM을 불러오는 중 오류가 발생했습니다.', 'error'); - allSessions = []; - displayTbmSessions(); - } + loadedDaysCount = 30; // 30일치 로드 + await loadRecentTbmGroupedByDate(); } window.loadAllTbm = loadAllTbm; +// ==================== 날짜별 그룹 TBM 로드 (새 기능) ==================== + +/** + * 사용자가 Admin인지 확인 + */ +function isAdminUser() { + if (!currentUser) return false; + return currentUser.role === 'Admin' || currentUser.role === 'System Admin'; +} + +/** + * 최근 TBM을 날짜별로 그룹화하여 로드 + */ +async function loadRecentTbmGroupedByDate() { + try { + const today = new Date(); + const dates = []; + + // 최근 N일의 날짜 생성 + for (let i = 0; i < loadedDaysCount; i++) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + dates.push(dateStr); + } + + // 각 날짜의 TBM 로드 + dateGroupedSessions = {}; + allLoadedSessions = []; + + const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`)); + const results = await Promise.all(promises); + + results.forEach((response, index) => { + const date = dates[index]; + if (response && response.success && response.data && response.data.length > 0) { + let sessions = response.data; + + // admin이 아니면 본인이 작성한 TBM만 필터링 + if (!isAdminUser()) { + const userId = currentUser?.user_id; + const workerId = currentUser?.worker_id; + sessions = sessions.filter(s => { + return s.created_by === userId || + s.leader_id === workerId || + s.created_by_name === currentUser?.name; + }); + } + + if (sessions.length > 0) { + dateGroupedSessions[date] = sessions; + allLoadedSessions = allLoadedSessions.concat(sessions); + } + } + }); + + // 날짜별 그룹 표시 + displayTbmGroupedByDate(); + + // 뷰 모드 표시 + updateViewModeIndicator(); + + } catch (error) { + console.error('❌ TBM 날짜별 로드 오류:', error); + showToast('TBM을 불러오는 중 오류가 발생했습니다.', 'error'); + dateGroupedSessions = {}; + displayTbmGroupedByDate(); + } +} +window.loadRecentTbmGroupedByDate = loadRecentTbmGroupedByDate; + +/** + * 뷰 모드 표시 업데이트 + */ +function updateViewModeIndicator() { + const indicator = document.getElementById('viewModeIndicator'); + const text = document.getElementById('viewModeText'); + + if (indicator && text) { + if (isAdminUser()) { + indicator.style.display = 'none'; // Admin은 표시 안 함 (전체가 기본) + } else { + indicator.style.display = 'inline-flex'; + text.textContent = '내 TBM'; + } + } +} + +/** + * 날짜별 그룹으로 TBM 표시 + */ +function displayTbmGroupedByDate() { + const container = document.getElementById('tbmDateGroupsContainer'); + const emptyState = document.getElementById('emptyState'); + const totalSessionsEl = document.getElementById('totalSessions'); + const completedSessionsEl = document.getElementById('completedSessions'); + + if (!container) return; + + // 날짜별로 정렬 (최신순) + const sortedDates = Object.keys(dateGroupedSessions).sort((a, b) => new Date(b) - new Date(a)); + + if (sortedDates.length === 0 || allLoadedSessions.length === 0) { + container.innerHTML = ''; + if (emptyState) emptyState.style.display = 'flex'; + if (totalSessionsEl) totalSessionsEl.textContent = '0'; + if (completedSessionsEl) completedSessionsEl.textContent = '0'; + return; + } + + if (emptyState) emptyState.style.display = 'none'; + + // 통계 업데이트 + const completedCount = allLoadedSessions.filter(s => s.status === 'completed').length; + if (totalSessionsEl) totalSessionsEl.textContent = allLoadedSessions.length; + if (completedSessionsEl) completedSessionsEl.textContent = completedCount; + + // 날짜별 그룹 HTML 생성 + const today = getTodayKST(); + const dayNames = ['일', '월', '화', '수', '목', '금', '토']; + + container.innerHTML = sortedDates.map(date => { + const sessions = dateGroupedSessions[date]; + const dateObj = new Date(date + 'T00:00:00'); + const dayName = dayNames[dateObj.getDay()]; + const isToday = date === today; + + // 날짜 포맷팅 (YYYY-MM-DD → MM월 DD일) + const [year, month, day] = date.split('-'); + const displayDate = `${parseInt(month)}월 ${parseInt(day)}일`; + + return ` +
+
+ ${displayDate} + ${dayName}요일${isToday ? ' (오늘)' : ''} + ${sessions.length}건 +
+
+ ${sessions.map(session => createSessionCard(session)).join('')} +
+
+ `; + }).join(''); +} + +/** + * 더 많은 날짜 로드 + */ +async function loadMoreTbmDays() { + loadedDaysCount += 7; // 7일씩 추가 + await loadRecentTbmGroupedByDate(); + showToast(`최근 ${loadedDaysCount}일의 TBM을 로드했습니다.`, 'success'); +} +window.loadMoreTbmDays = loadMoreTbmDays; + // 특정 날짜의 TBM 세션 목록 로드 async function loadTbmSessionsByDate(date) { try { @@ -318,28 +453,22 @@ async function loadTbmSessionsByDate(date) { } } -// TBM 세션 목록 표시 (관리 탭용) +// TBM 세션 목록 표시 (관리 탭용) - 레거시 호환 (날짜별 그룹 뷰 사용) function displayTbmSessions() { - const grid = document.getElementById('tbmSessionsGrid'); - const emptyState = document.getElementById('emptyState'); - const totalSessionsEl = document.getElementById('totalSessions'); - const completedSessionsEl = document.getElementById('completedSessions'); - - if (allSessions.length === 0) { - grid.innerHTML = ''; - emptyState.style.display = 'flex'; - totalSessionsEl.textContent = '0'; - completedSessionsEl.textContent = '0'; - return; + // 새 날짜별 그룹 뷰로 리다이렉트 + if (allSessions.length > 0) { + // allSessions를 날짜별로 그룹화 + dateGroupedSessions = {}; + allSessions.forEach(session => { + const date = formatDate(session.session_date); + if (!dateGroupedSessions[date]) { + dateGroupedSessions[date] = []; + } + dateGroupedSessions[date].push(session); + }); + allLoadedSessions = allSessions; } - - emptyState.style.display = 'none'; - - const completedCount = allSessions.filter(s => s.status === 'completed').length; - totalSessionsEl.textContent = allSessions.length; - completedSessionsEl.textContent = completedCount; - - grid.innerHTML = allSessions.map(session => createSessionCard(session)).join(''); + displayTbmGroupedByDate(); } // TBM 세션 카드 생성 (공통) @@ -432,12 +561,12 @@ function openNewTbmModal() { if (currentUser && currentUser.worker_id) { const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id); if (worker) { - document.getElementById('leaderName').value = `${worker.worker_name} (${worker.job_type || ''})`; + document.getElementById('leaderName').value = worker.worker_name; document.getElementById('leaderId').value = worker.worker_id; } } else if (currentUser && currentUser.name) { - // 관리자: 관리자로 표시 - document.getElementById('leaderName').value = `${currentUser.name} (관리자)`; + // 관리자: 이름만 표시 + document.getElementById('leaderName').value = currentUser.name; document.getElementById('leaderId').value = ''; } @@ -459,7 +588,8 @@ function populateLeaderSelect() { // 작업자와 연결된 경우: 자동으로 선택하고 비활성화 const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id); if (worker) { - leaderSelect.innerHTML = ``; + const jobTypeText = worker.job_type ? ` (${worker.job_type})` : ''; + leaderSelect.innerHTML = ``; leaderSelect.disabled = true; console.log('✅ 입력자 자동 설정:', worker.worker_name); } else { @@ -474,9 +604,10 @@ function populateLeaderSelect() { ); leaderSelect.innerHTML = '' + - leaders.map(w => ` - - `).join(''); + leaders.map(w => { + const jobTypeText = w.job_type ? ` (${w.job_type})` : ''; + return ``; + }).join(''); leaderSelect.disabled = false; console.log('✅ 관리자: 입력자 선택 가능'); } @@ -1856,65 +1987,92 @@ async function saveTeamComposition() { } window.saveTeamComposition = saveTeamComposition; -// 안전 체크 모달 열기 +// 안전 체크 모달 열기 (기본 + 날씨별 + 작업별) async function openSafetyCheckModal(sessionId) { currentSessionId = sessionId; - // 기존 안전 체크 기록 로드 try { - const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`); - const existingRecords = response && response.success ? response.data : []; + // 필터링된 체크리스트 조회 (기본 + 날씨 + 작업별) + const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`); - // 카테고리별로 그룹화 - const grouped = {}; - allSafetyChecks.forEach(check => { - if (!grouped[check.check_category]) { - grouped[check.check_category] = []; - } + if (!response || !response.success) { + throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.'); + } - const existingRecord = existingRecords.find(r => r.check_id === check.check_id); - grouped[check.check_category].push({ - ...check, - is_checked: existingRecord ? existingRecord.is_checked : false, - notes: existingRecord ? existingRecord.notes : '' - }); - }); + const { basic, weather, task, weatherInfo } = response.data; const categoryNames = { 'PPE': '개인 보호 장비', 'EQUIPMENT': '장비 점검', 'ENVIRONMENT': '작업 환경', - 'EMERGENCY': '비상 대응' + 'EMERGENCY': '비상 대응', + 'WEATHER': '날씨', + 'TASK': '작업' + }; + + const weatherIcons = { + clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥', + cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷' }; const container = document.getElementById('safetyChecklistContainer'); - container.innerHTML = Object.keys(grouped).map(category => ` -
-
- ${categoryNames[category] || category} -
- ${grouped[category].map(check => ` -
- -
- `).join('')} -
- `).join(''); + let html = ''; + // 1. 기본 사항 섹션 + if (basic && basic.length > 0) { + const basicGrouped = groupChecksByCategory(basic); + html += ` +
+
+ 📋 기본 안전 사항 (${basic.length}개) +
+ ${renderCategoryGroups(basicGrouped, categoryNames)} +
+ `; + } + + // 2. 날씨별 섹션 + if (weather && weather.length > 0) { + const weatherConditions = weatherInfo?.weather_conditions || []; + const conditionNames = weatherConditions.map(c => { + const icon = weatherIcons[c] || '🌤️'; + return `${icon} ${getWeatherConditionName(c)}`; + }).join(', ') || '맑음'; + + html += ` +
+
+ 🌤️ 오늘 날씨 관련 (${conditionNames}) - ${weather.length}개 +
+ ${renderCheckItems(weather)} +
+ `; + } + + // 3. 작업별 섹션 + if (task && task.length > 0) { + const taskGrouped = groupChecksByTask(task); + html += ` +
+
+ 🔧 작업별 안전 사항 - ${task.length}개 +
+ ${renderTaskGroups(taskGrouped)} +
+ `; + } + + // 체크리스트가 없는 경우 + if ((!basic || basic.length === 0) && (!weather || weather.length === 0) && (!task || task.length === 0)) { + html = ` +
+
📋
+

등록된 안전 체크 항목이 없습니다.

+
+ `; + } + + container.innerHTML = html; document.getElementById('safetyModal').style.display = 'flex'; document.body.style.overflow = 'hidden'; @@ -1925,6 +2083,83 @@ async function openSafetyCheckModal(sessionId) { } window.openSafetyCheckModal = openSafetyCheckModal; +// 카테고리별 그룹화 +function groupChecksByCategory(checks) { + return checks.reduce((acc, check) => { + const category = check.check_category || 'OTHER'; + if (!acc[category]) acc[category] = []; + acc[category].push(check); + return acc; + }, {}); +} + +// 작업별 그룹화 +function groupChecksByTask(checks) { + return checks.reduce((acc, check) => { + const taskId = check.task_id || 0; + const taskName = check.task_name || '기타 작업'; + if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] }; + acc[taskId].items.push(check); + return acc; + }, {}); +} + +// 날씨 조건명 반환 +function getWeatherConditionName(code) { + const names = { + clear: '맑음', rain: '비', snow: '눈', heat: '폭염', + cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지' + }; + return names[code] || code; +} + +// 카테고리 그룹 렌더링 +function renderCategoryGroups(grouped, categoryNames) { + return Object.keys(grouped).map(category => ` +
+
+ ${categoryNames[category] || category} +
+ ${renderCheckItems(grouped[category])} +
+ `).join(''); +} + +// 작업 그룹 렌더링 +function renderTaskGroups(grouped) { + return Object.values(grouped).map(group => ` +
+
+ 📋 ${group.name} +
+ ${renderCheckItems(group.items)} +
+ `).join(''); +} + +// 체크 항목 렌더링 +function renderCheckItems(items) { + return items.map(check => ` +
+ +
+ `).join(''); +} + // 안전 체크 모달 닫기 function closeSafetyModal() { document.getElementById('safetyModal').style.display = 'none'; diff --git a/web-ui/js/work-issue-list.js b/web-ui/js/work-issue-list.js new file mode 100644 index 0000000..f9f11c6 --- /dev/null +++ b/web-ui/js/work-issue-list.js @@ -0,0 +1,221 @@ +/** + * 문제 신고 목록 페이지 JavaScript + */ + +const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api'; + +// 상태 한글 변환 +const STATUS_LABELS = { + reported: '신고', + received: '접수', + in_progress: '처리중', + completed: '완료', + closed: '종료' +}; + +// 유형 한글 변환 +const TYPE_LABELS = { + nonconformity: '부적합', + safety: '안전' +}; + +// DOM 요소 +let issueList; +let filterStatus, filterType, filterStartDate, filterEndDate; + +// 초기화 +document.addEventListener('DOMContentLoaded', async () => { + issueList = document.getElementById('issueList'); + filterStatus = document.getElementById('filterStatus'); + filterType = document.getElementById('filterType'); + filterStartDate = document.getElementById('filterStartDate'); + filterEndDate = document.getElementById('filterEndDate'); + + // 필터 이벤트 리스너 + filterStatus.addEventListener('change', loadIssues); + filterType.addEventListener('change', loadIssues); + filterStartDate.addEventListener('change', loadIssues); + filterEndDate.addEventListener('change', loadIssues); + + // 데이터 로드 + await Promise.all([loadStats(), loadIssues()]); +}); + +/** + * 통계 로드 + */ +async function loadStats() { + try { + const response = await fetch(`${API_BASE}/work-issues/stats/summary`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (!response.ok) { + // 권한이 없는 경우 (일반 사용자) + document.getElementById('statsGrid').style.display = 'none'; + return; + } + + const data = await response.json(); + if (data.success && data.data) { + document.getElementById('statReported').textContent = data.data.reported || 0; + document.getElementById('statReceived').textContent = data.data.received || 0; + document.getElementById('statProgress').textContent = data.data.in_progress || 0; + document.getElementById('statCompleted').textContent = data.data.completed || 0; + } + } catch (error) { + console.error('통계 로드 실패:', error); + document.getElementById('statsGrid').style.display = 'none'; + } +} + +/** + * 신고 목록 로드 + */ +async function loadIssues() { + try { + // 필터 파라미터 구성 + const params = new URLSearchParams(); + + if (filterStatus.value) params.append('status', filterStatus.value); + if (filterType.value) params.append('category_type', filterType.value); + if (filterStartDate.value) params.append('start_date', filterStartDate.value); + if (filterEndDate.value) params.append('end_date', filterEndDate.value); + + const response = await fetch(`${API_BASE}/work-issues?${params.toString()}`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (!response.ok) throw new Error('목록 조회 실패'); + + const data = await response.json(); + if (data.success) { + renderIssues(data.data || []); + } + } catch (error) { + console.error('신고 목록 로드 실패:', error); + issueList.innerHTML = ` +
+
목록을 불러올 수 없습니다
+

잠시 후 다시 시도해주세요.

+
+ `; + } +} + +/** + * 신고 목록 렌더링 + */ +function renderIssues(issues) { + if (issues.length === 0) { + issueList.innerHTML = ` +
+
등록된 신고가 없습니다
+

새로운 문제를 신고하려면 '새 신고' 버튼을 클릭하세요.

+
+ `; + return; + } + + const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', ''); + + issueList.innerHTML = issues.map(issue => { + const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + // 위치 정보 + let location = issue.custom_location || ''; + if (issue.factory_name) { + location = issue.factory_name; + if (issue.workplace_name) { + location += ` - ${issue.workplace_name}`; + } + } + + // 신고 제목 (항목명 또는 카테고리명) + const title = issue.issue_item_name || issue.issue_category_name || '신고'; + + // 사진 목록 + const photos = [ + issue.photo_path1, + issue.photo_path2, + issue.photo_path3, + issue.photo_path4, + issue.photo_path5 + ].filter(Boolean); + + return ` +
+
+ #${issue.report_id} + ${STATUS_LABELS[issue.status] || issue.status} +
+ +
+ ${TYPE_LABELS[issue.category_type] || ''} + ${title} +
+ +
+ + + + + + ${issue.reporter_full_name || issue.reporter_name} + + + + + + + + + ${reportDate} + + ${location ? ` + + + + + + ${location} + + ` : ''} + ${issue.assigned_full_name ? ` + + + + + + + + 담당: ${issue.assigned_full_name} + + ` : ''} +
+ + ${photos.length > 0 ? ` +
+ ${photos.slice(0, 3).map(p => ` + 신고 사진 + `).join('')} + ${photos.length > 3 ? `+${photos.length - 3}` : ''} +
+ ` : ''} +
+ `; + }).join(''); +} + +/** + * 신고 상세 보기 + */ +function viewIssue(reportId) { + window.location.href = `/pages/safety/issue-detail.html?id=${reportId}`; +} diff --git a/web-ui/js/work-issue-report.js b/web-ui/js/work-issue-report.js new file mode 100644 index 0000000..1cb49bc --- /dev/null +++ b/web-ui/js/work-issue-report.js @@ -0,0 +1,740 @@ +/** + * 문제 신고 등록 페이지 JavaScript + */ + +// API 설정 +const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api'; + +// 상태 변수 +let selectedFactoryId = null; +let selectedWorkplaceId = null; +let selectedWorkplaceName = null; +let selectedType = null; // 'nonconformity' | 'safety' +let selectedCategoryId = null; +let selectedCategoryName = null; +let selectedItemId = null; +let selectedTbmSessionId = null; +let selectedVisitRequestId = null; +let photos = [null, null, null, null, null]; + +// 지도 관련 변수 +let canvas, ctx, canvasImage; +let mapRegions = []; +let todayWorkers = []; +let todayVisitors = []; + +// DOM 요소 +let factorySelect, issueMapCanvas; +let photoInput, currentPhotoIndex; + +// 초기화 +document.addEventListener('DOMContentLoaded', async () => { + factorySelect = document.getElementById('factorySelect'); + issueMapCanvas = document.getElementById('issueMapCanvas'); + photoInput = document.getElementById('photoInput'); + + canvas = issueMapCanvas; + ctx = canvas.getContext('2d'); + + // 이벤트 리스너 설정 + setupEventListeners(); + + // 공장 목록 로드 + await loadFactories(); +}); + +/** + * 이벤트 리스너 설정 + */ +function setupEventListeners() { + // 공장 선택 + factorySelect.addEventListener('change', onFactoryChange); + + // 지도 클릭 + canvas.addEventListener('click', onMapClick); + + // 기타 위치 토글 + document.getElementById('useCustomLocation').addEventListener('change', (e) => { + const customInput = document.getElementById('customLocationInput'); + customInput.classList.toggle('visible', e.target.checked); + + if (e.target.checked) { + // 지도 선택 초기화 + selectedWorkplaceId = null; + selectedWorkplaceName = null; + selectedTbmSessionId = null; + selectedVisitRequestId = null; + updateLocationInfo(); + } + }); + + // 유형 버튼 클릭 + document.querySelectorAll('.type-btn').forEach(btn => { + btn.addEventListener('click', () => onTypeSelect(btn.dataset.type)); + }); + + // 사진 슬롯 클릭 + document.querySelectorAll('.photo-slot').forEach(slot => { + slot.addEventListener('click', (e) => { + if (e.target.classList.contains('remove-btn')) return; + currentPhotoIndex = parseInt(slot.dataset.index); + photoInput.click(); + }); + }); + + // 사진 삭제 버튼 + document.querySelectorAll('.photo-slot .remove-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const slot = btn.closest('.photo-slot'); + const index = parseInt(slot.dataset.index); + removePhoto(index); + }); + }); + + // 사진 선택 + photoInput.addEventListener('change', onPhotoSelect); +} + +/** + * 공장 목록 로드 + */ +async function loadFactories() { + try { + const response = await fetch(`${API_BASE}/workplaces/categories/active/list`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (!response.ok) throw new Error('공장 목록 조회 실패'); + + const data = await response.json(); + if (data.success && data.data) { + data.data.forEach(factory => { + const option = document.createElement('option'); + option.value = factory.category_id; + option.textContent = factory.category_name; + factorySelect.appendChild(option); + }); + + // 첫 번째 공장 자동 선택 + if (data.data.length > 0) { + factorySelect.value = data.data[0].category_id; + onFactoryChange(); + } + } + } catch (error) { + console.error('공장 목록 로드 실패:', error); + } +} + +/** + * 공장 변경 시 + */ +async function onFactoryChange() { + selectedFactoryId = factorySelect.value; + if (!selectedFactoryId) return; + + // 위치 선택 초기화 + selectedWorkplaceId = null; + selectedWorkplaceName = null; + selectedTbmSessionId = null; + selectedVisitRequestId = null; + updateLocationInfo(); + + // 지도 데이터 로드 + await Promise.all([ + loadMapImage(), + loadMapRegions(), + loadTodayData() + ]); + + renderMap(); +} + +/** + * 배치도 이미지 로드 + */ +async function loadMapImage() { + try { + const response = await fetch(`${API_BASE}/workplaces/categories`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (!response.ok) return; + + const data = await response.json(); + if (data.success && data.data) { + const selectedCategory = data.data.find(c => c.category_id == selectedFactoryId); + if (selectedCategory && selectedCategory.layout_image) { + const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', ''); + const fullImageUrl = selectedCategory.layout_image.startsWith('http') + ? selectedCategory.layout_image + : `${baseUrl}${selectedCategory.layout_image}`; + + canvasImage = new Image(); + canvasImage.onload = () => renderMap(); + canvasImage.src = fullImageUrl; + } + } + } catch (error) { + console.error('배치도 이미지 로드 실패:', error); + } +} + +/** + * 지도 영역 로드 + */ +async function loadMapRegions() { + try { + const response = await fetch(`${API_BASE}/workplaces/categories/${selectedFactoryId}/map-regions`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (!response.ok) return; + + const data = await response.json(); + if (data.success) { + mapRegions = data.data || []; + } + } catch (error) { + console.error('지도 영역 로드 실패:', error); + } +} + +/** + * 오늘 TBM/출입신청 데이터 로드 + */ +async function loadTodayData() { + const today = new Date().toISOString().split('T')[0]; + + try { + // TBM 세션 로드 + const tbmResponse = await fetch(`${API_BASE}/tbm/sessions/date/${today}`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (tbmResponse.ok) { + const tbmData = await tbmResponse.json(); + todayWorkers = tbmData.data || []; + } + + // 출입 신청 로드 + const visitResponse = await fetch(`${API_BASE}/workplace-visits/requests`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (visitResponse.ok) { + const visitData = await visitResponse.json(); + todayVisitors = (visitData.data || []).filter(v => + v.visit_date === today && + (v.status === 'approved' || v.status === 'training_completed') + ); + } + } catch (error) { + console.error('오늘 데이터 로드 실패:', error); + } +} + +/** + * 지도 렌더링 + */ +function renderMap() { + if (!canvas || !ctx) return; + + // 캔버스 크기 설정 + const container = canvas.parentElement; + canvas.width = container.clientWidth; + canvas.height = 400; + + // 배경 그리기 + ctx.fillStyle = '#f3f4f6'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // 배치도 이미지 + if (canvasImage && canvasImage.complete) { + const scale = Math.min(canvas.width / canvasImage.width, canvas.height / canvasImage.height); + const x = (canvas.width - canvasImage.width * scale) / 2; + const y = (canvas.height - canvasImage.height * scale) / 2; + ctx.drawImage(canvasImage, x, y, canvasImage.width * scale, canvasImage.height * scale); + } + + // 작업장 영역 그리기 + mapRegions.forEach(region => { + const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id); + const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id); + + const workerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0); + const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0); + + drawWorkplaceRegion(region, workerCount, visitorCount); + }); +} + +/** + * 작업장 영역 그리기 + */ +function drawWorkplaceRegion(region, workerCount, visitorCount) { + const x1 = (region.x_start / 100) * canvas.width; + const y1 = (region.y_start / 100) * canvas.height; + const x2 = (region.x_end / 100) * canvas.width; + const y2 = (region.y_end / 100) * canvas.height; + const width = x2 - x1; + const height = y2 - y1; + + // 선택된 작업장 하이라이트 + const isSelected = region.workplace_id === selectedWorkplaceId; + + // 색상 결정 + let fillColor, strokeColor; + if (isSelected) { + fillColor = 'rgba(34, 197, 94, 0.3)'; // 초록색 + strokeColor = 'rgb(34, 197, 94)'; + } else if (workerCount > 0 && visitorCount > 0) { + fillColor = 'rgba(34, 197, 94, 0.2)'; // 초록색 (작업+방문) + strokeColor = 'rgb(34, 197, 94)'; + } else if (workerCount > 0) { + fillColor = 'rgba(59, 130, 246, 0.2)'; // 파란색 (작업만) + strokeColor = 'rgb(59, 130, 246)'; + } else if (visitorCount > 0) { + fillColor = 'rgba(168, 85, 247, 0.2)'; // 보라색 (방문만) + strokeColor = 'rgb(168, 85, 247)'; + } else { + fillColor = 'rgba(156, 163, 175, 0.2)'; // 회색 (없음) + strokeColor = 'rgb(156, 163, 175)'; + } + + ctx.fillStyle = fillColor; + ctx.strokeStyle = strokeColor; + ctx.lineWidth = isSelected ? 3 : 2; + + ctx.beginPath(); + ctx.rect(x1, y1, width, height); + ctx.fill(); + ctx.stroke(); + + // 작업장명 표시 + const centerX = x1 + width / 2; + const centerY = y1 + height / 2; + + ctx.fillStyle = '#374151'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(region.workplace_name, centerX, centerY); + + // 인원수 표시 + const total = workerCount + visitorCount; + if (total > 0) { + ctx.fillStyle = strokeColor; + ctx.font = 'bold 14px sans-serif'; + ctx.fillText(`(${total}명)`, centerX, centerY + 16); + } +} + +/** + * 지도 클릭 처리 + */ +function onMapClick(e) { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 클릭된 영역 찾기 + for (const region of mapRegions) { + const x1 = (region.x_start / 100) * canvas.width; + const y1 = (region.y_start / 100) * canvas.height; + const x2 = (region.x_end / 100) * canvas.width; + const y2 = (region.y_end / 100) * canvas.height; + + if (x >= x1 && x <= x2 && y >= y1 && y <= y2) { + selectWorkplace(region); + return; + } + } +} + +/** + * 작업장 선택 + */ +function selectWorkplace(region) { + // 기타 위치 체크박스 해제 + document.getElementById('useCustomLocation').checked = false; + document.getElementById('customLocationInput').classList.remove('visible'); + + selectedWorkplaceId = region.workplace_id; + selectedWorkplaceName = region.workplace_name; + + // 해당 작업장의 TBM/출입신청 확인 + const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id); + const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id); + + if (workers.length > 0 || visitors.length > 0) { + // 작업 선택 모달 표시 + showWorkSelectionModal(workers, visitors); + } else { + selectedTbmSessionId = null; + selectedVisitRequestId = null; + } + + updateLocationInfo(); + renderMap(); + updateStepStatus(); +} + +/** + * 작업 선택 모달 표시 + */ +function showWorkSelectionModal(workers, visitors) { + const modal = document.getElementById('workSelectionModal'); + const optionsList = document.getElementById('workOptionsList'); + + optionsList.innerHTML = ''; + + // TBM 작업 옵션 + workers.forEach(w => { + const option = document.createElement('div'); + option.className = 'work-option'; + option.innerHTML = ` +
TBM: ${w.task_name || '작업'}
+
${w.project_name || ''} - ${w.member_count || 0}명
+ `; + option.onclick = () => { + selectedTbmSessionId = w.session_id; + selectedVisitRequestId = null; + closeWorkModal(); + updateLocationInfo(); + }; + optionsList.appendChild(option); + }); + + // 출입신청 옵션 + visitors.forEach(v => { + const option = document.createElement('div'); + option.className = 'work-option'; + option.innerHTML = ` +
출입: ${v.visitor_company}
+
${v.purpose_name || '방문'} - ${v.visitor_count || 0}명
+ `; + option.onclick = () => { + selectedVisitRequestId = v.request_id; + selectedTbmSessionId = null; + closeWorkModal(); + updateLocationInfo(); + }; + optionsList.appendChild(option); + }); + + modal.classList.add('visible'); +} + +/** + * 작업 선택 모달 닫기 + */ +function closeWorkModal() { + document.getElementById('workSelectionModal').classList.remove('visible'); +} + +/** + * 선택된 위치 정보 업데이트 + */ +function updateLocationInfo() { + const infoBox = document.getElementById('selectedLocationInfo'); + const customLocation = document.getElementById('customLocation').value; + const useCustom = document.getElementById('useCustomLocation').checked; + + if (useCustom && customLocation) { + infoBox.classList.remove('empty'); + infoBox.innerHTML = `선택된 위치: ${customLocation}`; + } else if (selectedWorkplaceName) { + infoBox.classList.remove('empty'); + let html = `선택된 위치: ${selectedWorkplaceName}`; + + if (selectedTbmSessionId) { + const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId); + if (worker) { + html += `
연결 작업: ${worker.task_name} (TBM)`; + } + } else if (selectedVisitRequestId) { + const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId); + if (visitor) { + html += `
연결 작업: ${visitor.visitor_company} (출입)`; + } + } + + infoBox.innerHTML = html; + } else { + infoBox.classList.add('empty'); + infoBox.textContent = '지도에서 작업장을 클릭하여 위치를 선택하세요'; + } +} + +/** + * 유형 선택 + */ +function onTypeSelect(type) { + selectedType = type; + selectedCategoryId = null; + selectedCategoryName = null; + selectedItemId = null; + + // 버튼 상태 업데이트 + document.querySelectorAll('.type-btn').forEach(btn => { + btn.classList.toggle('selected', btn.dataset.type === type); + }); + + // 카테고리 로드 + loadCategories(type); + updateStepStatus(); +} + +/** + * 카테고리 로드 + */ +async function loadCategories(type) { + try { + const response = await fetch(`${API_BASE}/work-issues/categories/type/${type}`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (!response.ok) throw new Error('카테고리 조회 실패'); + + const data = await response.json(); + if (data.success && data.data) { + renderCategories(data.data); + } + } catch (error) { + console.error('카테고리 로드 실패:', error); + } +} + +/** + * 카테고리 렌더링 + */ +function renderCategories(categories) { + const container = document.getElementById('categoryContainer'); + const grid = document.getElementById('categoryGrid'); + + grid.innerHTML = ''; + + categories.forEach(cat => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'category-btn'; + btn.textContent = cat.category_name; + btn.onclick = () => onCategorySelect(cat); + grid.appendChild(btn); + }); + + container.style.display = 'block'; +} + +/** + * 카테고리 선택 + */ +function onCategorySelect(category) { + selectedCategoryId = category.category_id; + selectedCategoryName = category.category_name; + selectedItemId = null; + + // 버튼 상태 업데이트 + document.querySelectorAll('.category-btn').forEach(btn => { + btn.classList.toggle('selected', btn.textContent === category.category_name); + }); + + // 항목 로드 + loadItems(category.category_id); + updateStepStatus(); +} + +/** + * 항목 로드 + */ +async function loadItems(categoryId) { + try { + const response = await fetch(`${API_BASE}/work-issues/items/category/${categoryId}`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (!response.ok) throw new Error('항목 조회 실패'); + + const data = await response.json(); + if (data.success && data.data) { + renderItems(data.data); + } + } catch (error) { + console.error('항목 로드 실패:', error); + } +} + +/** + * 항목 렌더링 + */ +function renderItems(items) { + const grid = document.getElementById('itemGrid'); + grid.innerHTML = ''; + + if (items.length === 0) { + grid.innerHTML = '

등록된 항목이 없습니다

'; + return; + } + + items.forEach(item => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'item-btn'; + btn.textContent = item.item_name; + btn.dataset.severity = item.severity; + btn.onclick = () => onItemSelect(item, btn); + grid.appendChild(btn); + }); +} + +/** + * 항목 선택 + */ +function onItemSelect(item, btn) { + // 단일 선택 (기존 선택 해제) + document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + + selectedItemId = item.item_id; + updateStepStatus(); +} + +/** + * 사진 선택 + */ +function onPhotoSelect(e) { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + photos[currentPhotoIndex] = event.target.result; + updatePhotoSlot(currentPhotoIndex); + }; + reader.readAsDataURL(file); + + // 입력 초기화 + e.target.value = ''; +} + +/** + * 사진 슬롯 업데이트 + */ +function updatePhotoSlot(index) { + const slot = document.querySelector(`.photo-slot[data-index="${index}"]`); + + if (photos[index]) { + slot.classList.add('has-photo'); + let img = slot.querySelector('img'); + if (!img) { + img = document.createElement('img'); + slot.insertBefore(img, slot.firstChild); + } + img.src = photos[index]; + } else { + slot.classList.remove('has-photo'); + const img = slot.querySelector('img'); + if (img) img.remove(); + } +} + +/** + * 사진 삭제 + */ +function removePhoto(index) { + photos[index] = null; + updatePhotoSlot(index); +} + +/** + * 단계 상태 업데이트 + */ +function updateStepStatus() { + const steps = document.querySelectorAll('.step'); + const customLocation = document.getElementById('customLocation').value; + const useCustom = document.getElementById('useCustomLocation').checked; + + // Step 1: 위치 + const step1Complete = (useCustom && customLocation) || selectedWorkplaceId; + steps[0].classList.toggle('completed', step1Complete); + steps[1].classList.toggle('active', step1Complete); + + // Step 2: 유형 + const step2Complete = selectedType && selectedCategoryId; + steps[1].classList.toggle('completed', step2Complete); + steps[2].classList.toggle('active', step2Complete); + + // Step 3: 항목 + const step3Complete = selectedItemId; + steps[2].classList.toggle('completed', step3Complete); + steps[3].classList.toggle('active', step3Complete); + + // 제출 버튼 활성화 + const submitBtn = document.getElementById('submitBtn'); + const hasPhoto = photos.some(p => p !== null); + submitBtn.disabled = !(step1Complete && step2Complete && hasPhoto); +} + +/** + * 신고 제출 + */ +async function submitReport() { + const submitBtn = document.getElementById('submitBtn'); + submitBtn.disabled = true; + submitBtn.textContent = '제출 중...'; + + try { + const useCustom = document.getElementById('useCustomLocation').checked; + const customLocation = document.getElementById('customLocation').value; + const additionalDescription = document.getElementById('additionalDescription').value; + + const requestBody = { + factory_category_id: useCustom ? null : selectedFactoryId, + workplace_id: useCustom ? null : selectedWorkplaceId, + custom_location: useCustom ? customLocation : null, + tbm_session_id: selectedTbmSessionId, + visit_request_id: selectedVisitRequestId, + issue_category_id: selectedCategoryId, + issue_item_id: selectedItemId, + additional_description: additionalDescription || null, + photos: photos.filter(p => p !== null) + }; + + const response = await fetch(`${API_BASE}/work-issues`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + + if (data.success) { + alert('문제 신고가 등록되었습니다.'); + window.location.href = '/pages/safety/issue-list.html'; + } else { + throw new Error(data.error || '신고 등록 실패'); + } + } catch (error) { + console.error('신고 제출 실패:', error); + alert('신고 등록에 실패했습니다: ' + error.message); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = '신고 제출'; + } +} + +// 기타 위치 입력 시 위치 정보 업데이트 +document.addEventListener('DOMContentLoaded', () => { + const customLocationInput = document.getElementById('customLocation'); + if (customLocationInput) { + customLocationInput.addEventListener('input', () => { + updateLocationInfo(); + updateStepStatus(); + }); + } +}); diff --git a/web-ui/pages/.archived-admin/admin dashboard.html b/web-ui/pages.backup.20260202/.archived-admin/admin dashboard.html similarity index 100% rename from web-ui/pages/.archived-admin/admin dashboard.html rename to web-ui/pages.backup.20260202/.archived-admin/admin dashboard.html diff --git a/web-ui/pages/.archived-admin/dashboard.html b/web-ui/pages.backup.20260202/.archived-admin/dashboard.html similarity index 100% rename from web-ui/pages/.archived-admin/dashboard.html rename to web-ui/pages.backup.20260202/.archived-admin/dashboard.html diff --git a/web-ui/pages/.archived-admin/manage-daily-work.html b/web-ui/pages.backup.20260202/.archived-admin/manage-daily-work.html similarity index 100% rename from web-ui/pages/.archived-admin/manage-daily-work.html rename to web-ui/pages.backup.20260202/.archived-admin/manage-daily-work.html diff --git a/web-ui/pages/.archived-admin/manage-issue.html b/web-ui/pages.backup.20260202/.archived-admin/manage-issue.html similarity index 100% rename from web-ui/pages/.archived-admin/manage-issue.html rename to web-ui/pages.backup.20260202/.archived-admin/manage-issue.html diff --git a/web-ui/pages/.archived-admin/manage-project.html b/web-ui/pages.backup.20260202/.archived-admin/manage-project.html similarity index 100% rename from web-ui/pages/.archived-admin/manage-project.html rename to web-ui/pages.backup.20260202/.archived-admin/manage-project.html diff --git a/web-ui/pages/.archived-admin/manage-task.html b/web-ui/pages.backup.20260202/.archived-admin/manage-task.html similarity index 100% rename from web-ui/pages/.archived-admin/manage-task.html rename to web-ui/pages.backup.20260202/.archived-admin/manage-task.html diff --git a/web-ui/pages/.archived-admin/manage-user.html b/web-ui/pages.backup.20260202/.archived-admin/manage-user.html similarity index 100% rename from web-ui/pages/.archived-admin/manage-user.html rename to web-ui/pages.backup.20260202/.archived-admin/manage-user.html diff --git a/web-ui/pages/.archived-admin/manage-worker.html b/web-ui/pages.backup.20260202/.archived-admin/manage-worker.html similarity index 100% rename from web-ui/pages/.archived-admin/manage-worker.html rename to web-ui/pages.backup.20260202/.archived-admin/manage-worker.html diff --git a/web-ui/pages/.archived-analysis-legacy.html b/web-ui/pages.backup.20260202/.archived-analysis-legacy.html similarity index 100% rename from web-ui/pages/.archived-analysis-legacy.html rename to web-ui/pages.backup.20260202/.archived-analysis-legacy.html diff --git a/web-ui/pages/.archived-analysis-modular.html b/web-ui/pages.backup.20260202/.archived-analysis-modular.html similarity index 100% rename from web-ui/pages/.archived-analysis-modular.html rename to web-ui/pages.backup.20260202/.archived-analysis-modular.html diff --git a/web-ui/pages/.archived-daily-work-analysis.html b/web-ui/pages.backup.20260202/.archived-daily-work-analysis.html similarity index 100% rename from web-ui/pages/.archived-daily-work-analysis.html rename to web-ui/pages.backup.20260202/.archived-daily-work-analysis.html diff --git a/web-ui/pages/.archived-dashboard-system.html b/web-ui/pages.backup.20260202/.archived-dashboard-system.html similarity index 100% rename from web-ui/pages/.archived-dashboard-system.html rename to web-ui/pages.backup.20260202/.archived-dashboard-system.html diff --git a/web-ui/pages/.archived-dashboard-user.html b/web-ui/pages.backup.20260202/.archived-dashboard-user.html similarity index 100% rename from web-ui/pages/.archived-dashboard-user.html rename to web-ui/pages.backup.20260202/.archived-dashboard-user.html diff --git a/web-ui/pages/.archived-management-dashboard.html b/web-ui/pages.backup.20260202/.archived-management-dashboard.html similarity index 100% rename from web-ui/pages/.archived-management-dashboard.html rename to web-ui/pages.backup.20260202/.archived-management-dashboard.html diff --git a/web-ui/pages/.archived-my-attendance.html b/web-ui/pages.backup.20260202/.archived-my-attendance.html similarity index 100% rename from web-ui/pages/.archived-my-attendance.html rename to web-ui/pages.backup.20260202/.archived-my-attendance.html diff --git a/web-ui/pages/.archived-my-dashboard.html b/web-ui/pages.backup.20260202/.archived-my-dashboard.html similarity index 100% rename from web-ui/pages/.archived-my-dashboard.html rename to web-ui/pages.backup.20260202/.archived-my-dashboard.html diff --git a/web-ui/pages/.archived-project-analysis.html b/web-ui/pages.backup.20260202/.archived-project-analysis.html similarity index 100% rename from web-ui/pages/.archived-project-analysis.html rename to web-ui/pages.backup.20260202/.archived-project-analysis.html diff --git a/web-ui/pages/.archived-project-worktype-analysis.html b/web-ui/pages.backup.20260202/.archived-project-worktype-analysis.html similarity index 100% rename from web-ui/pages/.archived-project-worktype-analysis.html rename to web-ui/pages.backup.20260202/.archived-project-worktype-analysis.html diff --git a/web-ui/pages/.archived-work-report-analytics.html b/web-ui/pages.backup.20260202/.archived-work-report-analytics.html similarity index 100% rename from web-ui/pages/.archived-work-report-analytics.html rename to web-ui/pages.backup.20260202/.archived-work-report-analytics.html diff --git a/web-ui/pages/.archived-work-report-review.html b/web-ui/pages.backup.20260202/.archived-work-report-review.html similarity index 100% rename from web-ui/pages/.archived-work-report-review.html rename to web-ui/pages.backup.20260202/.archived-work-report-review.html diff --git a/web-ui/pages/.archived-work-report-validation.html b/web-ui/pages.backup.20260202/.archived-work-report-validation.html similarity index 100% rename from web-ui/pages/.archived-work-report-validation.html rename to web-ui/pages.backup.20260202/.archived-work-report-validation.html diff --git a/web-ui/pages/.archived-work-reports/work-report-create.html b/web-ui/pages.backup.20260202/.archived-work-reports/work-report-create.html similarity index 100% rename from web-ui/pages/.archived-work-reports/work-report-create.html rename to web-ui/pages.backup.20260202/.archived-work-reports/work-report-create.html diff --git a/web-ui/pages/.archived-work-reports/work-report-manage.html b/web-ui/pages.backup.20260202/.archived-work-reports/work-report-manage.html similarity index 100% rename from web-ui/pages/.archived-work-reports/work-report-manage.html rename to web-ui/pages.backup.20260202/.archived-work-reports/work-report-manage.html diff --git a/web-ui/pages/.archived-worker-individual-report.html b/web-ui/pages.backup.20260202/.archived-worker-individual-report.html similarity index 100% rename from web-ui/pages/.archived-worker-individual-report.html rename to web-ui/pages.backup.20260202/.archived-worker-individual-report.html diff --git a/web-ui/pages.backup.20260202/admin/.gitkeep b/web-ui/pages.backup.20260202/admin/.gitkeep new file mode 100644 index 0000000..cb80f3f --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/.gitkeep @@ -0,0 +1 @@ +# Placeholder file to create admin directory diff --git a/web-ui/pages.backup.20260202/admin/accounts.html b/web-ui/pages.backup.20260202/admin/accounts.html new file mode 100644 index 0000000..9b8189d --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/accounts.html @@ -0,0 +1,215 @@ + + + + + + 관리자 설정 | (주)테크니컬코리아 + + + + + + + +
+ + + + +
+
+ + + +
+
+

+ 👥 + 사용자 계정 관리 +

+ +
+ +
+
+ +
+ + + + +
+
+ +
+ + + + + + + + + + + + + + +
사용자명아이디역할상태최종 로그인관리
+
+ + +
+
+
+
+
+ + + + + + + + + + + +
+ + + + + + + diff --git a/web-ui/pages/admin/attendance-report-comparison.html b/web-ui/pages.backup.20260202/admin/attendance-report-comparison.html similarity index 100% rename from web-ui/pages/admin/attendance-report-comparison.html rename to web-ui/pages.backup.20260202/admin/attendance-report-comparison.html diff --git a/web-ui/pages.backup.20260202/admin/codes.html b/web-ui/pages.backup.20260202/admin/codes.html new file mode 100644 index 0000000..4fda212 --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/codes.html @@ -0,0 +1,302 @@ + + + + + + 코드 관리 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + + + +
+
+ + + +
+ + + +
+ + +
+
+
+

+ 📊 + 작업 상태 유형 관리 +

+
+ +
+
+ +
+ + 📊 + 총 0개 + + + + 정상 0개 + + + + 오류 0개 + +
+ +
+ +
+
+
+ + +
+
+
+

+ ⚠️ + 오류 유형 관리 +

+
+ +
+
+ +
+ + ⚠️ + 총 0개 + + + 🔴 + 심각 0개 + + + 🟠 + 높음 0개 + + + 🟡 + 보통 0개 + + + 🟢 + 낮음 0개 + +
+ +
+ +
+
+
+ + +
+
+
+

+ 🔧 + 작업 유형 관리 +

+
+ +
+
+ +
+ + 🔧 + 총 0개 + + + 📁 + 카테고리 0개 + +
+ +
+ +
+
+
+
+
+ + + +
+ + + + + diff --git a/web-ui/pages.backup.20260202/admin/equipments.html b/web-ui/pages.backup.20260202/admin/equipments.html new file mode 100644 index 0000000..505cb3a --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/equipments.html @@ -0,0 +1,272 @@ + + + + + + 설비 관리 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + + + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + + + + + + + + + + diff --git a/web-ui/pages.backup.20260202/admin/page-access.html b/web-ui/pages.backup.20260202/admin/page-access.html new file mode 100644 index 0000000..15430ed --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/page-access.html @@ -0,0 +1,140 @@ + + + + + + 페이지 권한 관리 | (주)테크니컬코리아 + + + + + + + +
+ + + + +
+
+ + + +
+
+

+ 👥 + 사용자 목록 +

+
+ + + +
+
+ +
+
+ + + + + + + + + + + + + + + + +
사용자명아이디역할작업자접근 가능 페이지관리
+
+

사용자 목록을 불러오는 중...

+
+
+ + +
+
+
+
+
+ + + + + +
+ + + + + + + diff --git a/web-ui/pages.backup.20260202/admin/projects.html b/web-ui/pages.backup.20260202/admin/projects.html new file mode 100644 index 0000000..e3c5e93 --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/projects.html @@ -0,0 +1,258 @@ + + + + + + 프로젝트 관리 | (주)테크니컬코리아 + + + + + + + + + + +
+ + + + +
+
+ + + + +
+ + +
+ + + +
+
+ + +
+
+

등록된 프로젝트

+
+ + 🟢 + 활성 0개 + + + 🔴 + 비활성 0개 + + + 📊 + 총 0개 + +
+
+ +
+ +
+ + +
+
+ + + + +
+ + + + + + + diff --git a/web-ui/pages.backup.20260202/admin/safety-checklist-manage.html b/web-ui/pages.backup.20260202/admin/safety-checklist-manage.html new file mode 100644 index 0000000..f64ea86 --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/safety-checklist-manage.html @@ -0,0 +1,596 @@ + + + + + + 안전 체크리스트 관리 - TK-FB + + + + + + + +
+ + + +
+ + + +
+ + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + diff --git a/web-ui/pages/admin/safety-management.html b/web-ui/pages.backup.20260202/admin/safety-management.html similarity index 100% rename from web-ui/pages/admin/safety-management.html rename to web-ui/pages.backup.20260202/admin/safety-management.html diff --git a/web-ui/pages/admin/safety-training-conduct.html b/web-ui/pages.backup.20260202/admin/safety-training-conduct.html similarity index 100% rename from web-ui/pages/admin/safety-training-conduct.html rename to web-ui/pages.backup.20260202/admin/safety-training-conduct.html diff --git a/web-ui/pages.backup.20260202/admin/tasks.html b/web-ui/pages.backup.20260202/admin/tasks.html new file mode 100644 index 0000000..6f0d2eb --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/tasks.html @@ -0,0 +1,236 @@ + + + + + + 작업 관리 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + + + +
+
+ + + +
+ + +
+ + +
+
+

+ 🔧 + 작업 목록 +

+
+ +
+ + 📋 + 전체 0개 + + + + 활성 0개 + +
+ +
+ +
+
+
+
+ + + + + + +
+ + + + + diff --git a/web-ui/pages.backup.20260202/admin/workers.html b/web-ui/pages.backup.20260202/admin/workers.html new file mode 100644 index 0000000..49b3d50 --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/workers.html @@ -0,0 +1,291 @@ + + + + + + 작업자 관리 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + + + +
+
+ + + +
+ + +
+ + + + + +
+
+ + +
+
+

등록된 작업자

+
+ + 🟢 + 활성 0명 + + + 🔴 + 비활성 0명 + + + 📊 + 총 0명 + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + +
상태이름직책전화번호이메일입사일부서계정현장직등록일관리
+
+ + + +
+
+
+ + + +
+ + + + + diff --git a/web-ui/pages.backup.20260202/admin/workplaces.html b/web-ui/pages.backup.20260202/admin/workplaces.html new file mode 100644 index 0000000..0eb0036 --- /dev/null +++ b/web-ui/pages.backup.20260202/admin/workplaces.html @@ -0,0 +1,414 @@ + + + + + + 작업장 관리 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + + + +
+
+ + + +
+ + +
+ + + + + +
+
+

+ 🏭 + 작업장 목록 +

+
+ +
+ + 🏗️ + 전체 0개 + + + + 활성 0개 + +
+ +
+ +
+
+
+
+ + + + + + + + + + + + +
+ + + + + + diff --git a/web-ui/pages/common/annual-vacation-overview.html b/web-ui/pages.backup.20260202/common/annual-vacation-overview.html similarity index 100% rename from web-ui/pages/common/annual-vacation-overview.html rename to web-ui/pages.backup.20260202/common/annual-vacation-overview.html diff --git a/web-ui/pages/common/daily-attendance.html b/web-ui/pages.backup.20260202/common/daily-attendance.html similarity index 100% rename from web-ui/pages/common/daily-attendance.html rename to web-ui/pages.backup.20260202/common/daily-attendance.html diff --git a/web-ui/pages/common/monthly-attendance.html b/web-ui/pages.backup.20260202/common/monthly-attendance.html similarity index 100% rename from web-ui/pages/common/monthly-attendance.html rename to web-ui/pages.backup.20260202/common/monthly-attendance.html diff --git a/web-ui/pages/common/vacation-allocation.html b/web-ui/pages.backup.20260202/common/vacation-allocation.html similarity index 100% rename from web-ui/pages/common/vacation-allocation.html rename to web-ui/pages.backup.20260202/common/vacation-allocation.html diff --git a/web-ui/pages/common/vacation-approval.html b/web-ui/pages.backup.20260202/common/vacation-approval.html similarity index 100% rename from web-ui/pages/common/vacation-approval.html rename to web-ui/pages.backup.20260202/common/vacation-approval.html diff --git a/web-ui/pages/common/vacation-input.html b/web-ui/pages.backup.20260202/common/vacation-input.html similarity index 100% rename from web-ui/pages/common/vacation-input.html rename to web-ui/pages.backup.20260202/common/vacation-input.html diff --git a/web-ui/pages/common/vacation-management.html b/web-ui/pages.backup.20260202/common/vacation-management.html similarity index 100% rename from web-ui/pages/common/vacation-management.html rename to web-ui/pages.backup.20260202/common/vacation-management.html diff --git a/web-ui/pages/common/vacation-request.html b/web-ui/pages.backup.20260202/common/vacation-request.html similarity index 100% rename from web-ui/pages/common/vacation-request.html rename to web-ui/pages.backup.20260202/common/vacation-request.html diff --git a/web-ui/pages.backup.20260202/dashboard.html b/web-ui/pages.backup.20260202/dashboard.html new file mode 100644 index 0000000..9a85eca --- /dev/null +++ b/web-ui/pages.backup.20260202/dashboard.html @@ -0,0 +1,277 @@ + + + + + + + 작업 현황판 | 테크니컬코리아 + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + +
+
+
+

빠른 작업

+
+
+
+ + + + +
+

🚪 출입 신청

+

작업장 출입 및 안전교육을 신청합니다

+
+
+
+ + +
+

🛡️ 안전관리

+

출입 신청 승인 및 안전교육 관리

+
+
+
+ + +
+

📋 안전 체크리스트 관리

+

TBM 안전 체크 항목 관리 (기본/날씨/작업별)

+
+
+
+ + +
+

⚠️ 문제 신고

+

작업 중 발생한 문제를 신고합니다

+
+
+
+ + +
+

📋 신고 현황

+

신고 목록 및 처리 현황을 확인합니다

+
+
+
+ + +
+

작업 보고서 작성

+

오늘의 작업 내용을 입력하고 관리합니다

+
+
+
+ + +
+

작업 현황 확인

+

팀원들의 작업 현황을 실시간으로 조회합니다

+
+
+
+ + +
+

작업 분석

+

작업 효율성 및 통계를 분석합니다

+
+
+
+ + +
+

기본 정보 관리

+

프로젝트, 작업자, 코드를 관리합니다

+
+
+
+ + +
+

📅 일일 출퇴근 입력

+

오늘의 출퇴근 기록을 입력합니다

+
+
+
+ + +
+

📆 월별 출퇴근 현황

+

이번 달 출퇴근 현황을 조회합니다

+
+
+
+ + +
+

📝 휴가 신청

+

휴가를 신청하고 신청 내역을 확인합니다

+
+
+
+ + +
+

🏖️ 휴가 관리

+

휴가 승인, 직접 입력, 전체 내역을 관리합니다

+
+
+
+ + +
+

📊 연간 연차 현황

+

모든 작업자의 연간 휴가 현황을 차트로 확인합니다

+
+
+
+ + +
+

➕ 휴가 발생 입력

+

작업자별 휴가를 입력하고 특별 휴가를 관리합니다

+
+
+
+ + +
+

🔍 출퇴근-작업보고서 대조

+

출퇴근 기록과 작업보고서를 비교 분석합니다

+
+
+
+
+
+
+
+ + +
+
+
+
+

작업장 현황

+
+ + +
+
+
+
+ + + + +
+
🏭
+

공장을 선택하세요

+

위에서 공장을 선택하면 작업장 현황을 확인할 수 있습니다.

+
+
+
+
+ + +
+ + + + +
+ + +
+ + + + + + + \ No newline at end of file diff --git a/web-ui/pages.backup.20260202/profile/info.html b/web-ui/pages.backup.20260202/profile/info.html new file mode 100644 index 0000000..dc7e6fd --- /dev/null +++ b/web-ui/pages.backup.20260202/profile/info.html @@ -0,0 +1,317 @@ + + + + + + 👤 내 프로필 | (주)테크니컬코리아 + + + + + + +
+ + +
+
+
👤
+

사용자

+

역할

+
+ +
+ +
+

+ 📋 + 기본 정보 +

+
+
+ 사용자 ID + - +
+
+ 사용자명 + - +
+
+ 이름 + - +
+
+ 권한 레벨 + - +
+
+ 작업자 ID + - +
+
+ 가입일 + - +
+
+
+ + +
+

+ 📊 + 활동 정보 +

+
+
+ 마지막 로그인 + - +
+
+ 이메일 + - +
+
+ + +
+
+ - + 작업 보고서 +
+
+ - + 이번 달 활동 +
+
+ - + 팀 기여도 +
+
+
+ + +
+

+ + 빠른 작업 +

+
+ + 🔐 + 비밀번호 변경 + + + + + + 돌아가기 + +
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/web-ui/pages.backup.20260202/profile/password.html b/web-ui/pages.backup.20260202/profile/password.html new file mode 100644 index 0000000..6fbd273 --- /dev/null +++ b/web-ui/pages.backup.20260202/profile/password.html @@ -0,0 +1,391 @@ + + + + + + 🔐 비밀번호 변경 | (주)테크니컬코리아 + + + + + + +
+ + +
+
+

🔐 비밀번호 변경

+

계정 보안을 위해 정기적으로 비밀번호를 변경해주세요

+
+ +
+
+

+ 🔑 + 새 비밀번호 설정 +

+
+ +
+ +
+ + +
+

+ ℹ️ + 비밀번호 요구사항 +

+
    +
  • 최소 6자 이상 입력해주세요
  • +
  • 영문 대/소문자, 숫자, 특수문자를 조합하면 더 안전합니다
  • +
  • 개인정보나 쉬운 단어는 피해주세요
  • +
  • 이전 비밀번호와 다르게 설정해주세요
  • +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+
+
+
+ + + + + \ No newline at end of file diff --git a/web-ui/pages.backup.20260202/work/.gitkeep b/web-ui/pages.backup.20260202/work/.gitkeep new file mode 100644 index 0000000..7765d68 --- /dev/null +++ b/web-ui/pages.backup.20260202/work/.gitkeep @@ -0,0 +1 @@ +# Placeholder file to create work directory diff --git a/web-ui/pages.backup.20260202/work/analysis.html b/web-ui/pages.backup.20260202/work/analysis.html new file mode 100644 index 0000000..5451006 --- /dev/null +++ b/web-ui/pages.backup.20260202/work/analysis.html @@ -0,0 +1,2900 @@ + + + + + + 작업 분석 | (주)테크니컬코리아 + + + + + + + + + + + + + + +
+ + + ← 뒤로가기 + + + + + + + + + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + +
+ + + + + + + + + + + + + + +
+
+ + + + + + + + + \ No newline at end of file diff --git a/web-ui/pages.backup.20260202/work/issue-detail.html b/web-ui/pages.backup.20260202/work/issue-detail.html new file mode 100644 index 0000000..fcb45fd --- /dev/null +++ b/web-ui/pages.backup.20260202/work/issue-detail.html @@ -0,0 +1,946 @@ + + + + + + 신고 상세 | (주)테크니컬코리아 + + + + + + + + + + + +
+
+ + ← 목록으로 + + +
+
+
+

로딩 중...

+
+ +
+ + +
+

신고 정보

+
+
+ + +
+

신고 내용

+
+
+ + + + + + + + +
+

상태 변경 이력

+
+
+ + +
+
+
+ + + + + + + + +
+ × + +
+ + + + + diff --git a/web-ui/pages.backup.20260202/work/issue-list.html b/web-ui/pages.backup.20260202/work/issue-list.html new file mode 100644 index 0000000..83f550f --- /dev/null +++ b/web-ui/pages.backup.20260202/work/issue-list.html @@ -0,0 +1,301 @@ + + + + + + 신고 목록 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + + +
+
+
-
+
신고
+
+
+
-
+
접수
+
+
+
-
+
처리중
+
+
+
-
+
완료
+
+
+ + +
+ + + + + + + + + + 새 신고 + +
+ + +
+
+
로딩 중...
+
+
+
+ + + + + diff --git a/web-ui/pages.backup.20260202/work/issue-report.html b/web-ui/pages.backup.20260202/work/issue-report.html new file mode 100644 index 0000000..085a83a --- /dev/null +++ b/web-ui/pages.backup.20260202/work/issue-report.html @@ -0,0 +1,618 @@ + + + + + + 문제 신고 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + +
+ +
+
+ 1 + 위치 선택 +
+
+ 2 + 유형 선택 +
+
+ 3 + 항목 선택 +
+
+ 4 + 사진/설명 +
+
+ + +
+

1. 발생 위치 선택

+ +
+ + +
+ +
+ +
+ +
+ 지도에서 작업장을 클릭하여 위치를 선택하세요 +
+ +
+ + +
+ +
+ +
+
+ + +
+

2. 문제 유형 선택

+ +
+
+
부적합 사항
+
자재, 설계, 검사 관련 문제
+
+
+
안전 관련
+
보호구, 위험구역, 안전수칙 관련
+
+
+ + +
+ + +
+

3. 신고 항목 선택

+

해당하는 항목을 선택하세요. 여러 개 선택 가능합니다.

+ +
+

먼저 카테고리를 선택하세요

+
+
+ + +
+

4. 사진 및 추가 설명

+ +
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ +
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+

작업 선택

+

이 위치에 등록된 작업이 있습니다. 연결할 작업을 선택하세요.

+
+ +
+
+ + + + + diff --git a/web-ui/pages.backup.20260202/work/report-create.html b/web-ui/pages.backup.20260202/work/report-create.html new file mode 100644 index 0000000..4903009 --- /dev/null +++ b/web-ui/pages.backup.20260202/work/report-create.html @@ -0,0 +1,180 @@ + + + + + + 일일 작업보고서 작성 | (주)테크니컬코리아 + + + + + + +
+ + + + +
+ +
+ + +
+ + +
+ + +
+ +
+ +
+
+ + + +
+
+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web-ui/pages.backup.20260202/work/report-view.html b/web-ui/pages.backup.20260202/work/report-view.html new file mode 100644 index 0000000..698b886 --- /dev/null +++ b/web-ui/pages.backup.20260202/work/report-view.html @@ -0,0 +1,294 @@ + + + + + + 작업 현황 확인 - TK 건설 + + + + + + + + + + +
+
+ +
+

📅 작업 현황 확인

+

월별 작업자 현황을 한눈에 확인하세요

+
+ + +
+ +
+ + +
+

2025년 11월

+ +
+ + +
+ + +
+
+
+ 확인필요 +
+
+
+ 미입력 +
+
+
+ 부분입력 +
+
+
+ 이상 없음 +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web-ui/pages.backup.20260202/work/tbm.html b/web-ui/pages.backup.20260202/work/tbm.html new file mode 100644 index 0000000..8a990ba --- /dev/null +++ b/web-ui/pages.backup.20260202/work/tbm.html @@ -0,0 +1,650 @@ + + + + + + TBM 관리 | (주)테크니컬코리아 + + + + + + + + + +
+ + + + +
+
+ + + +
+ + +
+ + +
+
+
+

+ 🌅 + 오늘의 TBM +

+
+ +
+
+ +
+ + 📋 + 오늘 등록 0개 + + + + 완료 0개 + + + + 진행중 0개 + +
+ +
+ +
+ + + +
+
+ + +
+
+
+

+ 📚 + TBM 기록 +

+
+ +
+
+ +
+ + 📋 + 총 0개 + + + + 완료 0개 + + +
+ + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + diff --git a/web-ui/pages/work/visit-request.html b/web-ui/pages.backup.20260202/work/visit-request.html similarity index 100% rename from web-ui/pages/work/visit-request.html rename to web-ui/pages.backup.20260202/work/visit-request.html diff --git a/web-ui/pages/admin/accounts.html b/web-ui/pages/admin/accounts.html index 7a7af26..9b8189d 100644 --- a/web-ui/pages/admin/accounts.html +++ b/web-ui/pages/admin/accounts.html @@ -102,7 +102,7 @@
- 영문, 숫자만 사용 가능 (4-20자) + 영문, 숫자, 한글, 특수문자(._-) 사용 가능 (3-20자)
diff --git a/web-ui/pages/admin/attendance-report.html b/web-ui/pages/admin/attendance-report.html new file mode 100644 index 0000000..08a9178 --- /dev/null +++ b/web-ui/pages/admin/attendance-report.html @@ -0,0 +1,493 @@ + + + + + + 출퇴근-작업보고서 대조 | (주)테크니컬코리아 + + + + + + + + + + + + +
+
+ + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+

대조 결과

+

출퇴근 기록과 작업보고서의 시간을 비교합니다

+
+
+
+ +
+
+
+
+
+
+ + + + + + + + diff --git a/web-ui/pages/attendance/annual-overview.html b/web-ui/pages/attendance/annual-overview.html new file mode 100644 index 0000000..9f652b0 --- /dev/null +++ b/web-ui/pages/attendance/annual-overview.html @@ -0,0 +1,143 @@ + + + + + + + 연간 연차 현황 | 테크니컬코리아 + + + + + + + + + + + + + + + + +
+ + + + + +
+
+ + + + + +
+
+
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+

월별 휴가 사용 현황

+
+
+
+ +
+
+
+
+ + +
+
+
+

월별 상세 기록

+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + +
작업자명휴가 유형시작일종료일사용 일수사유상태
+
+

데이터를 불러오는 중...

+
+
+
+
+
+ +
+
+ +
+ + +
+ + + + diff --git a/web-ui/pages/attendance/daily.html b/web-ui/pages/attendance/daily.html new file mode 100644 index 0000000..fc4875b --- /dev/null +++ b/web-ui/pages/attendance/daily.html @@ -0,0 +1,395 @@ + + + + + + 일일 출퇴근 입력 | (주)테크니컬코리아 + + + + + + + + + + + +
+
+ + + +
+
+
+

작업자 출퇴근 기록

+

근태 구분을 선택하면 근무시간이 자동 입력됩니다. 연장근로는 별도로 입력 가능합니다. (조퇴: 2시간, 반반차: 6시간, 반차: 4시간, 정시: 8시간, 주말근무: 0시간)

+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ + + + + + + + diff --git a/web-ui/pages/attendance/monthly.html b/web-ui/pages/attendance/monthly.html new file mode 100644 index 0000000..ddcb1b6 --- /dev/null +++ b/web-ui/pages/attendance/monthly.html @@ -0,0 +1,490 @@ + + + + + + 월별 출퇴근 현황 | (주)테크니컬코리아 + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+

월별 요약

+
+
+
+ +
+
+
+
+ + +
+
+
+

출퇴근 달력

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + + + + + + + diff --git a/web-ui/pages/attendance/vacation-allocation.html b/web-ui/pages/attendance/vacation-allocation.html new file mode 100644 index 0000000..5cd7771 --- /dev/null +++ b/web-ui/pages/attendance/vacation-allocation.html @@ -0,0 +1,354 @@ + + + + + + + 휴가 발생 입력 | 테크니컬코리아 + + + + + + + + + + + + + + + +
+ + + + + +
+
+ + + + + +
+ + + +
+ + +
+
+
+

개별 작업자 휴가 입력

+
+
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+

자동 계산 (연차만 해당)

+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+

기존 입력 내역

+
+ + + + + + + + + + + + + + + + + + +
작업자연도휴가 유형총 일수사용 일수잔여 일수비고작업
+

작업자를 선택하세요

+
+
+
+ +
+
+
+ + +
+
+
+

근속년수별 연차 일괄 생성

+
+
+ +
+ 주의: 이 기능은 연차(ANNUAL) 휴가 유형만 생성합니다. 각 작업자의 입사일을 기준으로 근속년수를 계산하여 자동으로 연차를 부여합니다. +
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + + + +
+
+
+ + +
+
+
+

특별 휴가 유형 관리

+ +
+
+ +
+ + + + + + + + + + + + + + + + + +
유형명코드우선순위특별 휴가시스템 유형설명작업
+
+

데이터를 불러오는 중...

+
+
+ +
+
+
+ +
+
+ +
+ + +
+ + + + + + + + + + diff --git a/web-ui/pages/attendance/vacation-approval.html b/web-ui/pages/attendance/vacation-approval.html new file mode 100644 index 0000000..9f3ece7 --- /dev/null +++ b/web-ui/pages/attendance/vacation-approval.html @@ -0,0 +1,267 @@ + + + + + + 휴가 승인 관리 | (주)테크니컬코리아 + + + + + + + + + + + + +
+
+ + + +
+ + +
+ + +
+
+
+
+

승인 대기 목록

+

대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다

+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+

전체 신청 내역

+
+ + ~ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ + + + + + + + + diff --git a/web-ui/pages/attendance/vacation-input.html b/web-ui/pages/attendance/vacation-input.html new file mode 100644 index 0000000..8d391ce --- /dev/null +++ b/web-ui/pages/attendance/vacation-input.html @@ -0,0 +1,294 @@ + + + + + + 휴가 직접 입력 | (주)테크니컬코리아 + + + + + + + + + + + +
+
+ + + +
+
+
+

휴가 정보 입력

+

승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.

+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ 작업자를 선택하세요 +
+
+ +
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+
+

최근 입력 내역

+
+ +
+
+
+
+ +
+
+
+
+
+
+ + + + + + + + + diff --git a/web-ui/pages/attendance/vacation-management.html b/web-ui/pages/attendance/vacation-management.html new file mode 100644 index 0000000..411dd2a --- /dev/null +++ b/web-ui/pages/attendance/vacation-management.html @@ -0,0 +1,461 @@ + + + + + + 휴가 관리 | (주)테크니컬코리아 + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + +
+
+
+
+

승인 대기 목록

+

대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다

+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+

휴가 정보 직접 입력

+

승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.

+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ 작업자를 선택하세요 +
+
+ +
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+

최근 입력 내역

+
+ +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+

전체 신청 내역

+
+ + ~ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ + + + + + + + + diff --git a/web-ui/pages/attendance/vacation-request.html b/web-ui/pages/attendance/vacation-request.html new file mode 100644 index 0000000..17ce678 --- /dev/null +++ b/web-ui/pages/attendance/vacation-request.html @@ -0,0 +1,272 @@ + + + + + + 휴가 신청 | (주)테크니컬코리아 + + + + + + + + + + + +
+
+ + + +
+
+
+

휴가 잔여 현황

+
+
+
+ +
+
+
+
+ + +
+
+
+

휴가 신청

+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+
+

내 신청 내역

+
+
+
+ +
+
+
+
+
+
+ + + + + + + + + diff --git a/web-ui/pages/dashboard.html b/web-ui/pages/dashboard.html index dc962ba..7f2b9a3 100644 --- a/web-ui/pages/dashboard.html +++ b/web-ui/pages/dashboard.html @@ -15,6 +15,7 @@ + @@ -47,7 +48,7 @@
- +

🚪 출입 신청

작업장 출입 및 안전교육을 신청합니다

@@ -55,7 +56,7 @@
- +

🛡️ 안전관리

출입 신청 승인 및 안전교육 관리

@@ -63,6 +64,30 @@
+ +
+

📋 안전 체크리스트 관리

+

TBM 안전 체크 항목 관리 (기본/날씨/작업별)

+
+
+
+ + +
+

⚠️ 문제 신고

+

작업 중 발생한 문제를 신고합니다

+
+
+
+ + +
+

📋 신고 현황

+

신고 목록 및 처리 현황을 확인합니다

+
+
+
+

작업 보고서 작성

@@ -95,7 +120,7 @@
- +

📅 일일 출퇴근 입력

오늘의 출퇴근 기록을 입력합니다

@@ -103,7 +128,7 @@
- +

📆 월별 출퇴근 현황

이번 달 출퇴근 현황을 조회합니다

@@ -111,7 +136,7 @@
- +

📝 휴가 신청

휴가를 신청하고 신청 내역을 확인합니다

@@ -119,7 +144,7 @@
- +

🏖️ 휴가 관리

휴가 승인, 직접 입력, 전체 내역을 관리합니다

@@ -127,7 +152,7 @@
- +

📊 연간 연차 현황

모든 작업자의 연간 휴가 현황을 차트로 확인합니다

@@ -135,7 +160,7 @@
- +

➕ 휴가 발생 입력

작업자별 휴가를 입력하고 특별 휴가를 관리합니다

@@ -143,7 +168,7 @@
- +

🔍 출퇴근-작업보고서 대조

출퇴근 기록과 작업보고서를 비교 분석합니다

diff --git a/web-ui/pages/safety/checklist-manage.html b/web-ui/pages/safety/checklist-manage.html new file mode 100644 index 0000000..f64ea86 --- /dev/null +++ b/web-ui/pages/safety/checklist-manage.html @@ -0,0 +1,596 @@ + + + + + + 안전 체크리스트 관리 - TK-FB + + + + + + + +
+ + + +
+ + + +
+ + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + diff --git a/web-ui/pages/safety/issue-detail.html b/web-ui/pages/safety/issue-detail.html new file mode 100644 index 0000000..9fcb3f4 --- /dev/null +++ b/web-ui/pages/safety/issue-detail.html @@ -0,0 +1,457 @@ + + + + + + 신고 상세 | (주)테크니컬코리아 + + + + + + + + + + + +
+
+ + ← 목록으로 + + +
+
+
+

로딩 중...

+
+ +
+ + +
+

신고 정보

+
+
+ + +
+

신고 내용

+
+
+ + + + + + + + +
+

상태 변경 이력

+
+
+ + +
+
+
+ + + + + + + + +
+ × + +
+ + + + + diff --git a/web-ui/pages/safety/issue-list.html b/web-ui/pages/safety/issue-list.html new file mode 100644 index 0000000..e2e3876 --- /dev/null +++ b/web-ui/pages/safety/issue-list.html @@ -0,0 +1,301 @@ + + + + + + 신고 목록 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + + +
+
+
-
+
신고
+
+
+
-
+
접수
+
+
+
-
+
처리중
+
+
+
-
+
완료
+
+
+ + +
+ + + + + + + + + + 새 신고 + +
+ + +
+
+
로딩 중...
+
+
+
+ + + + + diff --git a/web-ui/pages/safety/issue-report.html b/web-ui/pages/safety/issue-report.html new file mode 100644 index 0000000..085a83a --- /dev/null +++ b/web-ui/pages/safety/issue-report.html @@ -0,0 +1,618 @@ + + + + + + 문제 신고 | (주)테크니컬코리아 + + + + + + + + + + + +
+ + +
+ +
+
+ 1 + 위치 선택 +
+
+ 2 + 유형 선택 +
+
+ 3 + 항목 선택 +
+
+ 4 + 사진/설명 +
+
+ + +
+

1. 발생 위치 선택

+ +
+ + +
+ +
+ +
+ +
+ 지도에서 작업장을 클릭하여 위치를 선택하세요 +
+ +
+ + +
+ +
+ +
+
+ + +
+

2. 문제 유형 선택

+ +
+
+
부적합 사항
+
자재, 설계, 검사 관련 문제
+
+
+
안전 관련
+
보호구, 위험구역, 안전수칙 관련
+
+
+ + +
+ + +
+

3. 신고 항목 선택

+

해당하는 항목을 선택하세요. 여러 개 선택 가능합니다.

+ +
+

먼저 카테고리를 선택하세요

+
+
+ + +
+

4. 사진 및 추가 설명

+ +
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ +
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+

작업 선택

+

이 위치에 등록된 작업이 있습니다. 연결할 작업을 선택하세요.

+
+ +
+
+ + + + + diff --git a/web-ui/pages/safety/management.html b/web-ui/pages/safety/management.html new file mode 100644 index 0000000..714f16e --- /dev/null +++ b/web-ui/pages/safety/management.html @@ -0,0 +1,291 @@ + + + + + + 안전관리 | (주)테크니컬코리아 + + + + + + + + + +
+ + + + +
+
+ + + +
+
+
승인 대기
+
0
+
+
+
승인 완료
+
0
+
+
+
교육 완료
+
0
+
+
+
반려
+
0
+
+
+ + +
+
+ + + + + +
+ + +
+ +
+
+
+
+
+ + + + + + + + + + + + diff --git a/web-ui/pages/safety/training-conduct.html b/web-ui/pages/safety/training-conduct.html new file mode 100644 index 0000000..214945d --- /dev/null +++ b/web-ui/pages/safety/training-conduct.html @@ -0,0 +1,327 @@ + + + + + + 안전교육 진행 | (주)테크니컬코리아 + + + + + + + + + +
+ + + + +
+
+ + +
+ +
+

출입 신청 정보

+
+ +
+
+ + +
+

안전교육 체크리스트

+

+ 방문자에게 다음 안전 사항을 교육하고 체크해주세요. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
⚠️
+
+ 중요: 모든 체크리스트 항목을 완료하고 방문자의 서명을 받은 후 교육 완료 처리를 해주세요. + 교육 완료 후에는 수정할 수 없습니다. +
+
+ + +
+

방문자 서명 (0명)

+

+ 각 방문자가 왼쪽에 이름을 쓰고 오른쪽에 서명한 후 "저장" 버튼을 눌러주세요. +

+ +
+ +
+ 이름 + + 서명 +
+ +
+
왼쪽에 이름을 쓰고, 오른쪽에 서명해주세요
+
(마우스, 터치, 또는 Apple Pencil 사용)
+
+
+ +
+ + +
+ +
+ 서명 날짜: +
+ + +
+ +
+
+ + +
+ + +
+
+
+
+
+ + + + + + diff --git a/web-ui/pages/safety/visit-request.html b/web-ui/pages/safety/visit-request.html new file mode 100644 index 0000000..e059a9c --- /dev/null +++ b/web-ui/pages/safety/visit-request.html @@ -0,0 +1,371 @@ + + + + + + 출입 신청 | (주)테크니컬코리아 + + + + + + + + + +
+ + + + +
+
+ + + +
+
+

출입 정보 입력

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
📍
+
지도에서 작업장을 선택하세요
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+
+

내 출입 신청 현황

+
+ +
+
+
+
+
+
+
+ + +
+
+
+

작업장 선택

+ +
+ + +
+ + +
+ + + +
+
+ + + + + + diff --git a/web-ui/pages/work/tbm.html b/web-ui/pages/work/tbm.html index ee25989..8a990ba 100644 --- a/web-ui/pages/work/tbm.html +++ b/web-ui/pages/work/tbm.html @@ -10,6 +10,52 @@ +
@@ -98,17 +144,12 @@

📚 - 전체 TBM 기록 + TBM 기록

- - -
@@ -122,10 +163,15 @@ 완료 0개 +
-
- + +
+