From e9b69ed87b8d96e32798172b94bc9bdb08fedbb8 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sun, 15 Mar 2026 08:05:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(tksafety):=20=EC=9C=84=ED=97=98=EC=84=B1?= =?UTF-8?q?=ED=8F=89=EA=B0=80=20=EB=AA=A8=EB=93=88=20Phase=201=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=E2=80=94=20DB=C2=B7API=C2=B7Excel=C2=B7=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5개 테이블(risk_projects/processes/items/mitigations/templates) + 마스터 시딩, 프로젝트·항목·감소대책 CRUD API, ExcelJS 평가표 내보내기, 프로젝트 목록·평가 수행 페이지, 사진 업로드(multer), 네비게이션·CSS 추가. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 5 + tksafety/api/controllers/riskController.js | 201 +++++++++ tksafety/api/index.js | 4 + tksafety/api/models/riskModel.js | 406 +++++++++++++++++ tksafety/api/package.json | 2 + tksafety/api/routes/riskRoutes.js | 60 +++ tksafety/api/utils/riskExcelExport.js | 211 +++++++++ tksafety/web/checklist.html | 2 +- tksafety/web/education.html | 4 +- tksafety/web/entry-dashboard.html | 2 +- tksafety/web/index.html | 4 +- tksafety/web/risk-assess.html | 217 +++++++++ tksafety/web/risk-projects.html | 181 ++++++++ tksafety/web/static/css/tksafety.css | 11 + tksafety/web/static/js/tksafety-core.js | 1 + tksafety/web/static/js/tksafety-risk.js | 484 +++++++++++++++++++++ tksafety/web/training.html | 2 +- tksafety/web/visit-management.html | 2 +- tksafety/web/visit-request.html | 2 +- 19 files changed, 1792 insertions(+), 9 deletions(-) create mode 100644 tksafety/api/controllers/riskController.js create mode 100644 tksafety/api/models/riskModel.js create mode 100644 tksafety/api/routes/riskRoutes.js create mode 100644 tksafety/api/utils/riskExcelExport.js create mode 100644 tksafety/web/risk-assess.html create mode 100644 tksafety/web/risk-projects.html create mode 100644 tksafety/web/static/js/tksafety-risk.js diff --git a/docker-compose.yml b/docker-compose.yml index 85fe276..0b88a28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -354,6 +354,8 @@ services: - DB_NAME=${MYSQL_DATABASE:-hyungi} - SSO_JWT_SECRET=${SSO_JWT_SECRET} - INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY} + volumes: + - tksafety_uploads:/usr/src/app/uploads depends_on: mariadb: condition: service_healthy @@ -368,6 +370,8 @@ services: restart: unless-stopped ports: - "30580:80" + volumes: + - tksafety_uploads:/usr/share/nginx/html/uploads depends_on: - tksafety-api networks: @@ -488,6 +492,7 @@ volumes: system1_logs: system2_uploads: system2_logs: + tksafety_uploads: system3_uploads: external: true name: tkqc-package_uploads diff --git a/tksafety/api/controllers/riskController.js b/tksafety/api/controllers/riskController.js new file mode 100644 index 0000000..1f99e90 --- /dev/null +++ b/tksafety/api/controllers/riskController.js @@ -0,0 +1,201 @@ +const riskModel = require('../models/riskModel'); + +// ==================== 공정 템플릿 ==================== + +exports.getTemplates = async (req, res) => { + try { + const templates = await riskModel.getTemplates(req.query.product_type); + res.json({ success: true, data: templates }); + } catch (err) { + console.error('템플릿 조회 오류:', err); + res.status(500).json({ success: false, error: '템플릿 조회 실패' }); + } +}; + +// ==================== 프로젝트 CRUD ==================== + +exports.createProject = async (req, res) => { + try { + const { title, product_type } = req.body; + if (!title || !product_type) { + return res.status(400).json({ success: false, error: '제목과 제품유형은 필수입니다' }); + } + const data = { ...req.body, created_by: req.user.user_id || req.user.id }; + const projectId = await riskModel.createProject(data); + res.status(201).json({ success: true, message: '프로젝트가 생성되었습니다', data: { id: projectId } }); + } catch (err) { + console.error('프로젝트 생성 오류:', err); + res.status(500).json({ success: false, error: '프로젝트 생성 실패' }); + } +}; + +exports.getAllProjects = async (req, res) => { + try { + const projects = await riskModel.getAllProjects(req.query); + + // 대시보드 요약 + const summary = { + total: projects.length, + risk_distribution: { high: 0, substantial: 0, moderate: 0, low: 0 } + }; + for (const p of projects) { + summary.risk_distribution.high += p.high_risk_count || 0; + } + + res.json({ success: true, data: { projects, summary } }); + } catch (err) { + console.error('프로젝트 목록 조회 오류:', err); + res.status(500).json({ success: false, error: '프로젝트 목록 조회 실패' }); + } +}; + +exports.getProjectById = async (req, res) => { + try { + const project = await riskModel.getProjectById(req.params.id); + if (!project) return res.status(404).json({ success: false, error: '프로젝트를 찾을 수 없습니다' }); + res.json({ success: true, data: project }); + } catch (err) { + console.error('프로젝트 상세 조회 오류:', err); + res.status(500).json({ success: false, error: '프로젝트 상세 조회 실패' }); + } +}; + +exports.updateProject = async (req, res) => { + try { + const result = await riskModel.updateProject(req.params.id, req.body); + if (result.affectedRows === 0) return res.status(404).json({ success: false, error: '프로젝트를 찾을 수 없습니다' }); + res.json({ success: true, message: '프로젝트가 수정되었습니다' }); + } catch (err) { + console.error('프로젝트 수정 오류:', err); + res.status(500).json({ success: false, error: '프로젝트 수정 실패' }); + } +}; + +exports.deleteProject = async (req, res) => { + try { + const result = await riskModel.deleteProject(req.params.id); + if (result.affectedRows === 0) return res.status(404).json({ success: false, error: '프로젝트를 찾을 수 없습니다' }); + res.json({ success: true, message: '프로젝트가 삭제되었습니다' }); + } catch (err) { + console.error('프로젝트 삭제 오류:', err); + res.status(500).json({ success: false, error: '프로젝트 삭제 실패' }); + } +}; + +// ==================== 세부 공정 ==================== + +exports.addProcess = async (req, res) => { + try { + const { process_name } = req.body; + if (!process_name) return res.status(400).json({ success: false, error: '공정명은 필수입니다' }); + const processId = await riskModel.addProcess(req.params.id, req.body); + res.status(201).json({ success: true, message: '공정이 추가되었습니다', data: { id: processId } }); + } catch (err) { + console.error('공정 추가 오류:', err); + res.status(500).json({ success: false, error: '공정 추가 실패' }); + } +}; + +// ==================== 평가 항목 CRUD ==================== + +exports.createItem = async (req, res) => { + try { + const itemId = await riskModel.createItem(req.params.processId, req.body); + res.status(201).json({ success: true, message: '항목이 추가되었습니다', data: { id: itemId } }); + } catch (err) { + console.error('항목 추가 오류:', err); + res.status(500).json({ success: false, error: '항목 추가 실패' }); + } +}; + +exports.updateItem = async (req, res) => { + try { + const result = await riskModel.updateItem(req.params.itemId, req.body); + if (result.affectedRows === 0) return res.status(404).json({ success: false, error: '항목을 찾을 수 없습니다' }); + res.json({ success: true, message: '항목이 수정되었습니다' }); + } catch (err) { + console.error('항목 수정 오류:', err); + res.status(500).json({ success: false, error: '항목 수정 실패' }); + } +}; + +exports.deleteItem = async (req, res) => { + try { + const result = await riskModel.deleteItem(req.params.itemId); + if (result.affectedRows === 0) return res.status(404).json({ success: false, error: '항목을 찾을 수 없습니다' }); + res.json({ success: true, message: '항목이 삭제되었습니다' }); + } catch (err) { + console.error('항목 삭제 오류:', err); + res.status(500).json({ success: false, error: '항목 삭제 실패' }); + } +}; + +// ==================== 감소대책 CRUD ==================== + +exports.getMitigations = async (req, res) => { + try { + const mitigations = await riskModel.getMitigationsByProject(req.params.id); + res.json({ success: true, data: mitigations }); + } catch (err) { + console.error('감소대책 조회 오류:', err); + res.status(500).json({ success: false, error: '감소대책 조회 실패' }); + } +}; + +exports.createMitigation = async (req, res) => { + try { + const { mitigation_no } = req.body; + if (!mitigation_no) return res.status(400).json({ success: false, error: '대책 번호는 필수입니다' }); + const mitigationId = await riskModel.createMitigation(req.params.id, req.body); + res.status(201).json({ success: true, message: '감소대책이 추가되었습니다', data: { id: mitigationId } }); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ success: false, error: '이미 존재하는 대책 번호입니다' }); + } + console.error('감소대책 생성 오류:', err); + res.status(500).json({ success: false, error: '감소대책 생성 실패' }); + } +}; + +exports.updateMitigation = async (req, res) => { + try { + const result = await riskModel.updateMitigation(req.params.mitigationId, req.body); + if (result.affectedRows === 0) return res.status(404).json({ success: false, error: '감소대책을 찾을 수 없습니다' }); + res.json({ success: true, message: '감소대책이 수정되었습니다' }); + } catch (err) { + console.error('감소대책 수정 오류:', err); + res.status(500).json({ success: false, error: '감소대책 수정 실패' }); + } +}; + +exports.uploadPhoto = async (req, res) => { + try { + if (!req.file) return res.status(400).json({ success: false, error: '사진 파일이 없습니다' }); + const photoPath = '/uploads/risk/' + req.file.filename; + await riskModel.updateMitigationPhoto(req.params.mitigationId, photoPath); + res.json({ success: true, message: '사진이 업로드되었습니다', data: { photo_url: photoPath } }); + } catch (err) { + console.error('사진 업로드 오류:', err); + res.status(500).json({ success: false, error: '사진 업로드 실패' }); + } +}; + +// ==================== Excel 내보내기 ==================== + +exports.exportExcel = async (req, res) => { + try { + const project = await riskModel.getProjectById(req.params.id); + if (!project) return res.status(404).json({ success: false, error: '프로젝트를 찾을 수 없습니다' }); + + const { generateRiskExcel } = require('../utils/riskExcelExport'); + const buffer = await generateRiskExcel(project); + + const filename = encodeURIComponent(`위험성평가_${project.title}_${project.year}.xlsx`); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${filename}`); + res.send(buffer); + } catch (err) { + console.error('Excel 내보내기 오류:', err); + res.status(500).json({ success: false, error: 'Excel 내보내기 실패' }); + } +}; diff --git a/tksafety/api/index.js b/tksafety/api/index.js index fa28099..f79c0e9 100644 --- a/tksafety/api/index.js +++ b/tksafety/api/index.js @@ -5,8 +5,10 @@ const dailyVisitRoutes = require('./routes/dailyVisitRoutes'); const educationRoutes = require('./routes/educationRoutes'); const visitRequestRoutes = require('./routes/visitRequestRoutes'); const checklistRoutes = require('./routes/checklistRoutes'); +const riskRoutes = require('./routes/riskRoutes'); const dailyVisitModel = require('./models/dailyVisitModel'); const visitRequestModel = require('./models/visitRequestModel'); +const riskModel = require('./models/riskModel'); const { requireAuth } = require('./middleware/auth'); const app = express(); @@ -42,6 +44,7 @@ app.use('/api/daily-visits', dailyVisitRoutes); app.use('/api/education', educationRoutes); app.use('/api/visit-requests', visitRequestRoutes); app.use('/api/checklist', checklistRoutes); +app.use('/api/risk', riskRoutes); // Partner search (autocomplete) app.get('/api/partners/search', requireAuth, async (req, res) => { @@ -89,6 +92,7 @@ app.listen(PORT, async () => { // DB 마이그레이션 실행 try { await visitRequestModel.runMigration(); + await riskModel.runMigration(); } catch (err) { console.error('Migration error:', err.message); } diff --git a/tksafety/api/models/riskModel.js b/tksafety/api/models/riskModel.js new file mode 100644 index 0000000..9036bd2 --- /dev/null +++ b/tksafety/api/models/riskModel.js @@ -0,0 +1,406 @@ +const { getPool } = require('../middleware/auth'); + +// ==================== DB 마이그레이션 ==================== + +const runMigration = async () => { + const db = getPool(); + try { + // 1. risk_process_templates (마스터) + await db.query(` + CREATE TABLE IF NOT EXISTS risk_process_templates ( + id INT AUTO_INCREMENT PRIMARY KEY, + product_type ENUM('PKG','VESSEL','HX','SKID') NOT NULL, + process_name VARCHAR(100) NOT NULL, + display_order INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_type_name (product_type, process_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `); + + // 2. risk_projects (평가 프로젝트) + await db.query(` + CREATE TABLE IF NOT EXISTS risk_projects ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + assessment_type ENUM('regular','adhoc') NOT NULL DEFAULT 'regular', + product_type ENUM('PKG','VESSEL','HX','SKID') NOT NULL, + year SMALLINT NOT NULL, + month TINYINT NULL, + status ENUM('draft','in_progress','completed') NOT NULL DEFAULT 'draft', + assessed_by VARCHAR(100) NULL, + created_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_type_year (assessment_type, year), + INDEX idx_product (product_type), + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `); + + // 3. risk_processes (세부 공정) + await db.query(` + CREATE TABLE IF NOT EXISTS risk_processes ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL, + process_name VARCHAR(100) NOT NULL, + display_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_project (project_id), + FOREIGN KEY (project_id) REFERENCES risk_projects(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `); + + // 4. risk_assessment_items (평가 항목) + await db.query(` + CREATE TABLE IF NOT EXISTS risk_assessment_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + process_id INT NOT NULL, + category VARCHAR(100) NULL, + cause VARCHAR(200) NULL, + hazard TEXT NULL, + regulation VARCHAR(200) NULL, + current_measure TEXT NULL, + likelihood TINYINT NULL, + severity TINYINT NULL, + risk_score TINYINT GENERATED ALWAYS AS (likelihood * severity) STORED, + mitigation_no VARCHAR(20) NULL, + detail TEXT NULL, + display_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_process (process_id), + FOREIGN KEY (process_id) REFERENCES risk_processes(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `); + + // 5. risk_mitigations (감소대책) + await db.query(` + CREATE TABLE IF NOT EXISTS risk_mitigations ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL, + mitigation_no VARCHAR(20) NOT NULL, + hazard_summary TEXT NULL, + current_risk_score TINYINT NULL, + improvement_plan TEXT NULL, + manager VARCHAR(100) NULL, + budget VARCHAR(100) NULL, + schedule VARCHAR(100) NULL, + completion_photo VARCHAR(500) NULL, + completion_date DATE NULL, + post_likelihood TINYINT NULL, + post_severity TINYINT NULL, + post_risk_score TINYINT GENERATED ALWAYS AS (post_likelihood * post_severity) STORED, + status ENUM('planned','in_progress','completed') NOT NULL DEFAULT 'planned', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_project (project_id), + UNIQUE KEY uk_proj_no (project_id, mitigation_no), + FOREIGN KEY (project_id) REFERENCES risk_projects(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `); + + // 마스터 시딩 (INSERT IGNORE — 중복 무시) + const templates = [ + ['PKG', '가공', 1], ['PKG', '용접', 2], ['PKG', '사상', 3], + ['PKG', '조립(피팅)', 4], ['PKG', '도장', 5], ['PKG', '검사', 6], + ['VESSEL', '절단', 1], ['VESSEL', '가공', 2], ['VESSEL', '조립', 3], + ['VESSEL', '용접', 4], ['VESSEL', '검사', 5], ['VESSEL', '열처리', 6], + ['VESSEL', '사상', 7], ['VESSEL', '도장', 8], + ['HX', '절단', 1], ['HX', '가공', 2], ['HX', '조립', 3], + ['HX', '용접', 4], ['HX', '확관', 5], ['HX', '사상/도장', 6], + ['HX', '검사', 7], + ['SKID', '절단', 1], ['SKID', '가공', 2], ['SKID', '배관조립', 3], + ['SKID', '용접', 4], ['SKID', '계장설치', 5], ['SKID', '사상/도장', 6], + ['SKID', '검사', 7], + ]; + for (const [productType, processName, order] of templates) { + await db.query( + 'INSERT IGNORE INTO risk_process_templates (product_type, process_name, display_order) VALUES (?, ?, ?)', + [productType, processName, order] + ); + } + + console.log('[migration] risk tables + templates ready'); + } catch (err) { + console.error('[migration] Risk migration error:', err.message); + } +}; + +// ==================== 공정 템플릿 조회 ==================== + +const getTemplates = async (productType) => { + const db = getPool(); + let query = 'SELECT * FROM risk_process_templates'; + const params = []; + if (productType) { + query += ' WHERE product_type = ?'; + params.push(productType); + } + query += ' ORDER BY product_type, display_order'; + const [rows] = await db.query(query, params); + return rows; +}; + +// ==================== 프로젝트 CRUD ==================== + +const createProject = async (data) => { + const db = getPool(); + const connection = await db.getConnection(); + try { + await connection.beginTransaction(); + + const { title, assessment_type = 'regular', product_type, year, month = null, assessed_by = null, created_by = null } = data; + + const [result] = await connection.query( + `INSERT INTO risk_projects (title, assessment_type, product_type, year, month, assessed_by, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [title, assessment_type, product_type, year, month, assessed_by, created_by] + ); + const projectId = result.insertId; + + // 정기 평가: 템플릿에서 세부 공정 자동 생성 + if (assessment_type === 'regular') { + const [templates] = await connection.query( + 'SELECT process_name, display_order FROM risk_process_templates WHERE product_type = ? ORDER BY display_order', + [product_type] + ); + for (const t of templates) { + await connection.query( + 'INSERT INTO risk_processes (project_id, process_name, display_order) VALUES (?, ?, ?)', + [projectId, t.process_name, t.display_order] + ); + } + } + + await connection.commit(); + return projectId; + } catch (err) { + await connection.rollback(); + throw err; + } finally { + connection.release(); + } +}; + +const getAllProjects = async (filters = {}) => { + const db = getPool(); + let query = ` + SELECT p.*, + (SELECT COUNT(*) FROM risk_assessment_items i + JOIN risk_processes rp ON i.process_id = rp.id + WHERE rp.project_id = p.id) AS total_items, + (SELECT COUNT(*) FROM risk_assessment_items i + JOIN risk_processes rp ON i.process_id = rp.id + WHERE rp.project_id = p.id AND i.risk_score >= 16) AS high_risk_count, + (SELECT COUNT(*) FROM risk_mitigations m + WHERE m.project_id = p.id AND m.status != 'completed') AS open_mitigations + FROM risk_projects p + WHERE 1=1 + `; + const params = []; + + if (filters.assessment_type) { + query += ' AND p.assessment_type = ?'; + params.push(filters.assessment_type); + } + if (filters.year) { + query += ' AND p.year = ?'; + params.push(filters.year); + } + if (filters.product_type) { + query += ' AND p.product_type = ?'; + params.push(filters.product_type); + } + if (filters.status) { + query += ' AND p.status = ?'; + params.push(filters.status); + } + + query += ' ORDER BY p.year DESC, p.created_at DESC'; + + const [rows] = await db.query(query, params); + return rows; +}; + +const getProjectById = async (id) => { + const db = getPool(); + + const [projects] = await db.query('SELECT * FROM risk_projects WHERE id = ?', [id]); + if (!projects.length) return null; + const project = projects[0]; + + // 세부 공정 + 항목 (3단계 중첩) + const [processes] = await db.query( + 'SELECT * FROM risk_processes WHERE project_id = ? ORDER BY display_order', [id] + ); + for (const proc of processes) { + const [items] = await db.query( + 'SELECT * FROM risk_assessment_items WHERE process_id = ? ORDER BY display_order, id', [proc.id] + ); + proc.items = items; + } + project.processes = processes; + + // 감소대책 + const [mitigations] = await db.query( + 'SELECT * FROM risk_mitigations WHERE project_id = ? ORDER BY CAST(mitigation_no AS UNSIGNED), mitigation_no', [id] + ); + project.mitigations = mitigations; + + return project; +}; + +const updateProject = async (id, data) => { + const db = getPool(); + const fields = []; + const params = []; + + for (const key of ['title', 'assessment_type', 'product_type', 'year', 'month', 'status', 'assessed_by']) { + if (data[key] !== undefined) { + fields.push(`${key} = ?`); + params.push(data[key]); + } + } + if (!fields.length) return { affectedRows: 0 }; + + params.push(id); + const [result] = await db.query( + `UPDATE risk_projects SET ${fields.join(', ')} WHERE id = ?`, params + ); + return result; +}; + +const deleteProject = async (id) => { + const db = getPool(); + const [result] = await db.query('DELETE FROM risk_projects WHERE id = ?', [id]); + return result; +}; + +// ==================== 세부 공정 ==================== + +const addProcess = async (projectId, data) => { + const db = getPool(); + const { process_name, display_order = 0 } = data; + const [result] = await db.query( + 'INSERT INTO risk_processes (project_id, process_name, display_order) VALUES (?, ?, ?)', + [projectId, process_name, display_order] + ); + return result.insertId; +}; + +// ==================== 평가 항목 CRUD ==================== + +const createItem = async (processId, data) => { + const db = getPool(); + const { category = null, cause = null, hazard = null, regulation = null, + current_measure = null, likelihood = null, severity = null, + mitigation_no = null, detail = null, display_order = 0 } = data; + const [result] = await db.query( + `INSERT INTO risk_assessment_items + (process_id, category, cause, hazard, regulation, current_measure, + likelihood, severity, mitigation_no, detail, display_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [processId, category, cause, hazard, regulation, current_measure, + likelihood, severity, mitigation_no, detail, display_order] + ); + return result.insertId; +}; + +const updateItem = async (itemId, data) => { + const db = getPool(); + const fields = []; + const params = []; + + for (const key of ['category', 'cause', 'hazard', 'regulation', 'current_measure', + 'likelihood', 'severity', 'mitigation_no', 'detail', 'display_order']) { + if (data[key] !== undefined) { + fields.push(`${key} = ?`); + params.push(data[key]); + } + } + if (!fields.length) return { affectedRows: 0 }; + + params.push(itemId); + const [result] = await db.query( + `UPDATE risk_assessment_items SET ${fields.join(', ')} WHERE id = ?`, params + ); + return result; +}; + +const deleteItem = async (itemId) => { + const db = getPool(); + const [result] = await db.query('DELETE FROM risk_assessment_items WHERE id = ?', [itemId]); + return result; +}; + +// ==================== 감소대책 CRUD ==================== + +const getMitigationsByProject = async (projectId) => { + const db = getPool(); + const [rows] = await db.query( + 'SELECT * FROM risk_mitigations WHERE project_id = ? ORDER BY CAST(mitigation_no AS UNSIGNED), mitigation_no', + [projectId] + ); + return rows; +}; + +const createMitigation = async (projectId, data) => { + const db = getPool(); + const { mitigation_no, hazard_summary = null, current_risk_score = null, + improvement_plan = null, manager = null, budget = null, schedule = null } = data; + const [result] = await db.query( + `INSERT INTO risk_mitigations + (project_id, mitigation_no, hazard_summary, current_risk_score, improvement_plan, manager, budget, schedule) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [projectId, mitigation_no, hazard_summary, current_risk_score, improvement_plan, manager, budget, schedule] + ); + return result.insertId; +}; + +const updateMitigation = async (mitigationId, data) => { + const db = getPool(); + const fields = []; + const params = []; + + for (const key of ['mitigation_no', 'hazard_summary', 'current_risk_score', 'improvement_plan', + 'manager', 'budget', 'schedule', 'completion_photo', 'completion_date', + 'post_likelihood', 'post_severity', 'status']) { + if (data[key] !== undefined) { + fields.push(`${key} = ?`); + params.push(data[key]); + } + } + if (!fields.length) return { affectedRows: 0 }; + + params.push(mitigationId); + const [result] = await db.query( + `UPDATE risk_mitigations SET ${fields.join(', ')} WHERE id = ?`, params + ); + return result; +}; + +const updateMitigationPhoto = async (mitigationId, photoPath) => { + const db = getPool(); + const [result] = await db.query( + 'UPDATE risk_mitigations SET completion_photo = ? WHERE id = ?', + [photoPath, mitigationId] + ); + return result; +}; + +module.exports = { + runMigration, + getTemplates, + createProject, + getAllProjects, + getProjectById, + updateProject, + deleteProject, + addProcess, + createItem, + updateItem, + deleteItem, + getMitigationsByProject, + createMitigation, + updateMitigation, + updateMitigationPhoto +}; diff --git a/tksafety/api/package.json b/tksafety/api/package.json index 665b068..49ee700 100644 --- a/tksafety/api/package.json +++ b/tksafety/api/package.json @@ -11,6 +11,8 @@ "cors": "^2.8.5", "express": "^4.18.2", "jsonwebtoken": "^9.0.0", + "exceljs": "^4.4.0", + "multer": "^1.4.5-lts.1", "mysql2": "^3.14.1", "node-cron": "^3.0.3" } diff --git a/tksafety/api/routes/riskRoutes.js b/tksafety/api/routes/riskRoutes.js new file mode 100644 index 0000000..9edc462 --- /dev/null +++ b/tksafety/api/routes/riskRoutes.js @@ -0,0 +1,60 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const riskController = require('../controllers/riskController'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +// 업로드 디렉토리 보장 +const uploadDir = path.join(__dirname, '..', 'uploads', 'risk'); +if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + +// Multer 설정 +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, uploadDir), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `${req.params.mitigationId}_photo_${Date.now()}${ext}`); + } +}); +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + const ext = path.extname(file.originalname).toLowerCase(); + cb(null, allowed.includes(ext)); + } +}); + +router.use(requireAuth); + +// 공정 템플릿 +router.get('/templates', riskController.getTemplates); + +// 프로젝트 CRUD +router.get('/projects', riskController.getAllProjects); +router.post('/projects', riskController.createProject); +router.get('/projects/:id', riskController.getProjectById); +router.patch('/projects/:id', riskController.updateProject); +router.delete('/projects/:id', requireAdmin, riskController.deleteProject); + +// 세부 공정 추가 (수시 평가용) +router.post('/projects/:id/processes', riskController.addProcess); + +// Excel 내보내기 +router.get('/projects/:id/export', riskController.exportExcel); + +// 평가 항목 CRUD +router.post('/processes/:processId/items', riskController.createItem); +router.patch('/items/:itemId', riskController.updateItem); +router.delete('/items/:itemId', requireAdmin, riskController.deleteItem); + +// 감소대책 CRUD +router.get('/projects/:id/mitigations', riskController.getMitigations); +router.post('/projects/:id/mitigations', riskController.createMitigation); +router.patch('/mitigations/:mitigationId', riskController.updateMitigation); +router.post('/mitigations/:mitigationId/photo', upload.single('photo'), riskController.uploadPhoto); + +module.exports = router; diff --git a/tksafety/api/utils/riskExcelExport.js b/tksafety/api/utils/riskExcelExport.js new file mode 100644 index 0000000..5180bf4 --- /dev/null +++ b/tksafety/api/utils/riskExcelExport.js @@ -0,0 +1,211 @@ +const ExcelJS = require('exceljs'); + +const RISK_COLORS = { + low: { fill: '92D050', font: '000000' }, // 1-4 green + moderate: { fill: 'FFFF00', font: '000000' }, // 5-9 amber/yellow + substantial: { fill: 'FFC000', font: '000000' }, // 10-15 orange + high: { fill: 'FF0000', font: 'FFFFFF' }, // 16-25 red +}; + +function getRiskLevel(score) { + if (!score) return null; + if (score <= 4) return 'low'; + if (score <= 9) return 'moderate'; + if (score <= 15) return 'substantial'; + return 'high'; +} + +function applyRiskColor(cell, score) { + const level = getRiskLevel(score); + if (!level) return; + const c = RISK_COLORS[level]; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF' + c.fill } }; + cell.font = { ...(cell.font || {}), color: { argb: 'FF' + c.font } }; +} + +function thinBorder() { + return { + top: { style: 'thin' }, bottom: { style: 'thin' }, + left: { style: 'thin' }, right: { style: 'thin' } + }; +} + +async function generateRiskExcel(project) { + const wb = new ExcelJS.Workbook(); + wb.creator = 'TK Safety'; + wb.created = new Date(); + + // ========== Sheet 1: 위험성 평가표 ========== + const ws = wb.addWorksheet('위험성 평가표'); + + // 컬럼 너비 + ws.columns = [ + { width: 5 }, // A: No + { width: 10 }, // B: 분류 + { width: 12 }, // C: 원인(작업) + { width: 25 }, // D: 유해·위험요인 + { width: 12 }, // E: 관련법규 + { width: 20 }, // F: 현재 안전조치 + { width: 8 }, // G: 가능성 + { width: 8 }, // H: 중대성 + { width: 8 }, // I: 위험성 + { width: 10 }, // J: 감소대책 No + { width: 20 }, // K: 세부내용 + ]; + + // 제목 + const titleRow = ws.addRow([`위험성평가표 — ${project.title} (${project.year}년)`]); + ws.mergeCells(titleRow.number, 1, titleRow.number, 11); + titleRow.getCell(1).font = { size: 14, bold: true }; + titleRow.getCell(1).alignment = { horizontal: 'center' }; + titleRow.height = 30; + + // 프로젝트 정보 + const infoRow = ws.addRow([ + `평가유형: ${project.assessment_type === 'regular' ? '정기' : '수시'}`, + '', '', `제품유형: ${project.product_type}`, + '', `평가자: ${project.assessed_by || '-'}`, + '', `상태: ${project.status === 'draft' ? '작성중' : project.status === 'in_progress' ? '진행중' : '완료'}`, + ]); + ws.mergeCells(infoRow.number, 1, infoRow.number, 3); + ws.mergeCells(infoRow.number, 4, infoRow.number, 5); + ws.mergeCells(infoRow.number, 6, infoRow.number, 7); + ws.mergeCells(infoRow.number, 8, infoRow.number, 11); + infoRow.eachCell(c => { c.font = { size: 10 }; }); + + ws.addRow([]); + + const HEADER_LABELS = ['No', '분류', '원인(작업)', '유해·위험요인', '관련법규', + '현재 안전조치', '가능성', '중대성', '위험성', '감소대책 No', '세부내용']; + + if (!project.processes || project.processes.length === 0) { + const emptyRow = ws.addRow(['(평가 항목이 없습니다)']); + ws.mergeCells(emptyRow.number, 1, emptyRow.number, 11); + emptyRow.getCell(1).alignment = { horizontal: 'center' }; + emptyRow.getCell(1).font = { italic: true, color: { argb: 'FF999999' } }; + } + + for (const proc of (project.processes || [])) { + // 공정 섹션 헤더 (빨간 배경) + const sectionRow = ws.addRow([proc.process_name]); + ws.mergeCells(sectionRow.number, 1, sectionRow.number, 11); + sectionRow.getCell(1).font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }; + sectionRow.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDC2626' } }; + sectionRow.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' }; + sectionRow.height = 24; + + // 컬럼 헤더 + const headerRow = ws.addRow(HEADER_LABELS); + headerRow.eachCell((cell) => { + cell.font = { bold: true, size: 9, color: { argb: 'FFFFFFFF' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDC2626' } }; + cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true }; + cell.border = thinBorder(); + }); + headerRow.height = 22; + + // 데이터 행 + const items = proc.items || []; + if (items.length === 0) { + const noDataRow = ws.addRow(['', '', '', '(항목 없음)', '', '', '', '', '', '', '']); + noDataRow.getCell(4).font = { italic: true, color: { argb: 'FF999999' } }; + for (let i = 1; i <= 11; i++) noDataRow.getCell(i).border = thinBorder(); + } + items.forEach((item, idx) => { + const row = ws.addRow([ + idx + 1, + item.category || '', + item.cause || '', + item.hazard || '', + item.regulation || '', + item.current_measure || '', + item.likelihood || '', + item.severity || '', + item.risk_score || '', + item.mitigation_no || '', + item.detail || '' + ]); + row.eachCell((cell, colNumber) => { + cell.font = { size: 9 }; + cell.alignment = { vertical: 'middle', wrapText: true }; + if ([1, 7, 8, 9, 10].includes(colNumber)) cell.alignment.horizontal = 'center'; + cell.border = thinBorder(); + }); + // 위험성 셀 색상 + if (item.risk_score) applyRiskColor(row.getCell(9), item.risk_score); + }); + + ws.addRow([]); + } + + // ========== Sheet 2~N: 감소대책 ========== + const mitigations = project.mitigations || []; + if (mitigations.length > 0) { + const msWs = wb.addWorksheet('감소대책 수립 및 실행'); + + msWs.columns = [ + { width: 5 }, // A: No + { width: 25 }, // B: 유해·위험요인 + { width: 10 }, // C: 현재 위험성 + { width: 25 }, // D: 개선계획 + { width: 12 }, // E: 담당자 + { width: 12 }, // F: 예산 + { width: 12 }, // G: 일정 + { width: 15 }, // H: 완료일 + { width: 15 }, // I: 완료사진 + { width: 8 }, // J: 가능성(후) + { width: 8 }, // K: 중대성(후) + { width: 8 }, // L: 위험성(후) + { width: 10 }, // M: 상태 + ]; + + const titleRow2 = msWs.addRow([`감소대책 수립 및 실행 — ${project.title}`]); + msWs.mergeCells(titleRow2.number, 1, titleRow2.number, 13); + titleRow2.getCell(1).font = { size: 14, bold: true }; + titleRow2.getCell(1).alignment = { horizontal: 'center' }; + + msWs.addRow([]); + + const mHeaders = ['No', '유해·위험요인', '현재 위험성', '개선계획', '담당자', + '예산', '일정', '완료일', '완료사진', '가능성(후)', '중대성(후)', '위험성(후)', '상태']; + const mHeaderRow = msWs.addRow(mHeaders); + mHeaderRow.eachCell((cell) => { + cell.font = { bold: true, size: 9, color: { argb: 'FFFFFFFF' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } }; + cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true }; + cell.border = thinBorder(); + }); + + const STATUS_MAP = { planned: '계획', in_progress: '진행중', completed: '완료' }; + + mitigations.forEach((m) => { + const row = msWs.addRow([ + m.mitigation_no, + m.hazard_summary || '', + m.current_risk_score || '', + m.improvement_plan || '', + m.manager || '', + m.budget || '', + m.schedule || '', + m.completion_date ? String(m.completion_date).substring(0, 10) : '', + m.completion_photo ? '(사진 첨부)' : '', + m.post_likelihood || '', + m.post_severity || '', + m.post_risk_score || '', + STATUS_MAP[m.status] || m.status + ]); + row.eachCell((cell, colNumber) => { + cell.font = { size: 9 }; + cell.alignment = { vertical: 'middle', wrapText: true }; + if ([1, 3, 10, 11, 12, 13].includes(colNumber)) cell.alignment.horizontal = 'center'; + cell.border = thinBorder(); + }); + if (m.current_risk_score) applyRiskColor(row.getCell(3), m.current_risk_score); + if (m.post_risk_score) applyRiskColor(row.getCell(12), m.post_risk_score); + }); + } + + return wb.xlsx.writeBuffer(); +} + +module.exports = { generateRiskExcel }; diff --git a/tksafety/web/checklist.html b/tksafety/web/checklist.html index dd06668..b9ffd9f 100644 --- a/tksafety/web/checklist.html +++ b/tksafety/web/checklist.html @@ -164,7 +164,7 @@ - + diff --git a/tksafety/web/education.html b/tksafety/web/education.html index c4cf3db..37379d9 100644 --- a/tksafety/web/education.html +++ b/tksafety/web/education.html @@ -6,7 +6,7 @@ 안전교육 - TK 안전관리 - + @@ -190,7 +190,7 @@ - + diff --git a/tksafety/web/entry-dashboard.html b/tksafety/web/entry-dashboard.html index 21832ca..073e511 100644 --- a/tksafety/web/entry-dashboard.html +++ b/tksafety/web/entry-dashboard.html @@ -106,7 +106,7 @@ - + diff --git a/tksafety/web/index.html b/tksafety/web/index.html index e0b6947..2d9da0f 100644 --- a/tksafety/web/index.html +++ b/tksafety/web/index.html @@ -6,7 +6,7 @@ 방문 관리 - TK 안전관리 - + @@ -277,7 +277,7 @@ - + diff --git a/tksafety/web/risk-assess.html b/tksafety/web/risk-assess.html new file mode 100644 index 0000000..21f8e53 --- /dev/null +++ b/tksafety/web/risk-assess.html @@ -0,0 +1,217 @@ + + + + + + 위험성평가 수행 - TK 안전관리 + + + + + + +
+
+
+
+ + +

TK 안전관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+ + + + +
+
로딩 중...
+
+ + +
+
로딩 중...
+
+ + + + + +
+
로딩 중...
+
+
+
+
+ + + + + + + + + + + + diff --git a/tksafety/web/risk-projects.html b/tksafety/web/risk-projects.html new file mode 100644 index 0000000..1ff939e --- /dev/null +++ b/tksafety/web/risk-projects.html @@ -0,0 +1,181 @@ + + + + + + 위험성평가 - TK 안전관리 + + + + + + +
+
+
+
+ + +

TK 안전관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + +
제목유형제품연도상태항목수고위험
로딩 중...
+
+
+
+
+
+ + + + + + + + + diff --git a/tksafety/web/static/css/tksafety.css b/tksafety/web/static/css/tksafety.css index ebb3d5a..a249665 100644 --- a/tksafety/web/static/css/tksafety.css +++ b/tksafety/web/static/css/tksafety.css @@ -107,6 +107,17 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b .visit-table td:last-child { gap: 2px; display: flex; flex-wrap: nowrap; justify-content: flex-end; align-items: center; } } +/* Risk assessment */ +.badge-risk-high { background: #fef2f2; color: #dc2626; } +.badge-risk-substantial { background: #fff7ed; color: #ea580c; } +.badge-risk-moderate { background: #fefce8; color: #ca8a04; } +.badge-risk-low { background: #f0fdf4; color: #16a34a; } +.risk-table th { background: #dc2626; color: white; padding: 0.5rem; font-size: 0.75rem; text-align: center; white-space: nowrap; } +.risk-table td { padding: 0.5rem; border: 1px solid #e5e7eb; font-size: 0.8rem; vertical-align: middle; } +.risk-table tr:hover { background: #fef2f2; } +.risk-section-header { background: #fee2e2; font-weight: 600; } +.process-caret { transition: transform 0.2s; display: inline-block; } + /* iOS zoom prevention */ @media (max-width: 768px) { input, select, textarea { font-size: 16px !important; } diff --git a/tksafety/web/static/js/tksafety-core.js b/tksafety/web/static/js/tksafety-core.js index 4c3465d..4c6b13d 100644 --- a/tksafety/web/static/js/tksafety-core.js +++ b/tksafety/web/static/js/tksafety-core.js @@ -111,6 +111,7 @@ function renderNavbar() { { href: '/entry-dashboard.html', icon: 'fa-id-card-alt', label: '출입 현황판', match: ['entry-dashboard.html'], admin: true }, { href: '/education.html', icon: 'fa-graduation-cap', label: '안전교육', match: ['education.html'] }, { href: '/training.html', icon: 'fa-chalkboard-teacher', label: '안전교육 실시', match: ['training.html'], admin: true }, + { href: '/risk-projects.html', icon: 'fa-exclamation-triangle', label: '위험성평가', match: ['risk-projects.html', 'risk-assess.html'], admin: true }, { href: '/checklist.html', icon: 'fa-tasks', label: '체크리스트 관리', match: ['checklist.html'], admin: true }, ]; const nav = document.getElementById('sideNav'); diff --git a/tksafety/web/static/js/tksafety-risk.js b/tksafety/web/static/js/tksafety-risk.js new file mode 100644 index 0000000..20a2cab --- /dev/null +++ b/tksafety/web/static/js/tksafety-risk.js @@ -0,0 +1,484 @@ +/* ===== 위험성평가 모듈 ===== */ + +const PRODUCT_TYPES = ['PKG', 'VESSEL', 'HX', 'SKID']; +const STATUS_LABELS = { draft: '작성중', in_progress: '진행중', completed: '완료' }; +const STATUS_BADGE = { draft: 'badge-gray', in_progress: 'badge-amber', completed: 'badge-green' }; +const TYPE_LABELS = { regular: '정기', adhoc: '수시' }; +const MITIGATION_STATUS = { planned: '계획', in_progress: '진행중', completed: '완료' }; + +function riskLevelClass(score) { + if (!score) return ''; + if (score <= 4) return 'badge-risk-low'; + if (score <= 9) return 'badge-risk-moderate'; + if (score <= 15) return 'badge-risk-substantial'; + return 'badge-risk-high'; +} +function riskLevelLabel(score) { + if (!score) return '-'; + if (score <= 4) return '저'; + if (score <= 9) return '보통'; + if (score <= 15) return '상당'; + return '높음'; +} + +// ==================== 프로젝트 목록 페이지 ==================== + +async function initRiskProjectsPage() { + if (!initAuth()) return; + await loadProjects(); +} + +async function loadProjects() { + try { + const params = new URLSearchParams(); + const typeFilter = document.getElementById('filterType')?.value; + const yearFilter = document.getElementById('filterYear')?.value; + const productFilter = document.getElementById('filterProduct')?.value; + const statusFilter = document.getElementById('filterStatus')?.value; + if (typeFilter) params.set('assessment_type', typeFilter); + if (yearFilter) params.set('year', yearFilter); + if (productFilter) params.set('product_type', productFilter); + if (statusFilter) params.set('status', statusFilter); + + const res = await api('/risk/projects?' + params.toString()); + const { projects } = res.data; + renderProjectTable(projects); + } catch (err) { + console.error(err); + showToast('프로젝트 목록을 불러올 수 없습니다', 'error'); + } +} + +function renderProjectTable(projects) { + const tbody = document.getElementById('projectTableBody'); + if (!tbody) return; + if (!projects.length) { + tbody.innerHTML = '프로젝트가 없습니다'; + return; + } + tbody.innerHTML = projects.map(p => ` + + ${escapeHtml(p.title)} + ${TYPE_LABELS[p.assessment_type]} + ${p.product_type} + ${p.year}${p.month ? '/' + p.month : ''} + ${STATUS_LABELS[p.status]} + ${p.total_items || 0} + ${p.high_risk_count ? `${p.high_risk_count}` : '-'} + + + + + + + `).join(''); +} + +function openNewProjectModal() { + document.getElementById('projectModal').classList.remove('hidden'); + document.getElementById('projectForm').reset(); + document.getElementById('projectYear').value = new Date().getFullYear(); +} +function closeProjectModal() { + document.getElementById('projectModal').classList.add('hidden'); +} + +async function submitProject(e) { + e.preventDefault(); + const data = { + title: document.getElementById('projectTitle').value.trim(), + assessment_type: document.getElementById('projectAssessType').value, + product_type: document.getElementById('projectProductType').value, + year: parseInt(document.getElementById('projectYear').value), + month: document.getElementById('projectMonth').value ? parseInt(document.getElementById('projectMonth').value) : null, + assessed_by: document.getElementById('projectAssessedBy').value.trim() || null + }; + if (!data.title) return showToast('제목을 입력하세요', 'error'); + + try { + const res = await api('/risk/projects', { + method: 'POST', + body: JSON.stringify(data) + }); + showToast('프로젝트가 생성되었습니다'); + closeProjectModal(); + location.href = 'risk-assess.html?id=' + res.data.id; + } catch (err) { + showToast(err.message, 'error'); + } +} + +// ==================== 평가 수행 페이지 ==================== + +let riskProject = null; + +async function initRiskAssessPage() { + if (!initAuth()) return; + const id = new URLSearchParams(location.search).get('id'); + if (!id) { location.href = 'risk-projects.html'; return; } + await loadProject(id); +} + +async function loadProject(id) { + try { + const res = await api('/risk/projects/' + id); + riskProject = res.data; + renderProjectHeader(); + renderProcesses(); + renderMitigations(); + } catch (err) { + console.error(err); + showToast('프로젝트를 불러올 수 없습니다', 'error'); + } +} + +function renderProjectHeader() { + const el = document.getElementById('projectHeader'); + if (!el) return; + const p = riskProject; + el.innerHTML = ` +
+
+

${escapeHtml(p.title)}

+
+ ${TYPE_LABELS[p.assessment_type]} + ${p.product_type} + ${p.year}년${p.month ? ' ' + p.month + '월' : ''} + ${STATUS_LABELS[p.status]} + ${p.assessed_by ? `평가자: ${escapeHtml(p.assessed_by)}` : ''} +
+
+
+ + + Excel + +
+
`; +} + +async function changeProjectStatus(status) { + try { + await api('/risk/projects/' + riskProject.id, { + method: 'PATCH', body: JSON.stringify({ status }) + }); + riskProject.status = status; + renderProjectHeader(); + showToast('상태가 변경되었습니다'); + } catch (err) { showToast(err.message, 'error'); } +} + +// ==================== 세부 공정 + 평가 항목 ==================== + +function renderProcesses() { + const container = document.getElementById('processContainer'); + if (!container) return; + const processes = riskProject.processes || []; + + if (!processes.length) { + container.innerHTML = `
+ 세부 공정이 없습니다. ${riskProject.assessment_type === 'adhoc' ? '' : ''} +
`; + return; + } + + container.innerHTML = processes.map((proc, idx) => ` +
+ + +
+ `).join(''); + + // 첫 번째 공정 자동 열기 + if (processes.length > 0) toggleProcess(0); +} + +function renderItems(items, processId) { + if (!items.length) return '항목이 없습니다'; + return items.map((item, idx) => ` + + ${idx + 1} + ${escapeHtml(item.category || '')} + ${escapeHtml(item.cause || '')} + ${escapeHtml(item.hazard || '')} + ${escapeHtml(item.regulation || '')} + ${escapeHtml(item.current_measure || '')} + ${item.likelihood || ''} + ${item.severity || ''} + ${item.risk_score || ''} + ${escapeHtml(item.mitigation_no || '')} + ${escapeHtml(item.detail || '')} + + + + + + `).join(''); +} + +function toggleProcess(idx) { + const el = document.getElementById('process-' + idx); + const caret = document.getElementById('caret-' + idx); + if (!el) return; + const isOpen = !el.classList.contains('hidden'); + el.classList.toggle('hidden'); + if (caret) caret.style.transform = isOpen ? '' : 'rotate(90deg)'; +} + +// ==================== 항목 모달 ==================== + +let editingItemId = null; +let editingProcessId = null; + +function openItemModal(processId, itemId) { + editingProcessId = processId; + editingItemId = itemId || null; + const modal = document.getElementById('riskItemModal'); + modal.classList.remove('hidden'); + document.getElementById('riskItemModalTitle').textContent = itemId ? '항목 수정' : '항목 추가'; + const form = document.getElementById('riskItemForm'); + form.reset(); + + if (itemId) { + // 기존 데이터 채우기 + let item = null; + for (const proc of riskProject.processes) { + item = (proc.items || []).find(i => i.id === itemId); + if (item) break; + } + if (item) { + document.getElementById('riCategory').value = item.category || ''; + document.getElementById('riCause').value = item.cause || ''; + document.getElementById('riHazard').value = item.hazard || ''; + document.getElementById('riRegulation').value = item.regulation || ''; + document.getElementById('riCurrentMeasure').value = item.current_measure || ''; + document.getElementById('riLikelihood').value = item.likelihood || ''; + document.getElementById('riSeverity').value = item.severity || ''; + document.getElementById('riMitigationNo').value = item.mitigation_no || ''; + document.getElementById('riDetail').value = item.detail || ''; + } + } +} +function closeItemModal() { + document.getElementById('riskItemModal').classList.add('hidden'); + editingItemId = null; + editingProcessId = null; +} + +async function submitItem(e) { + e.preventDefault(); + const data = { + category: document.getElementById('riCategory').value.trim() || null, + cause: document.getElementById('riCause').value.trim() || null, + hazard: document.getElementById('riHazard').value.trim() || null, + regulation: document.getElementById('riRegulation').value.trim() || null, + current_measure: document.getElementById('riCurrentMeasure').value.trim() || null, + likelihood: document.getElementById('riLikelihood').value ? parseInt(document.getElementById('riLikelihood').value) : null, + severity: document.getElementById('riSeverity').value ? parseInt(document.getElementById('riSeverity').value) : null, + mitigation_no: document.getElementById('riMitigationNo').value.trim() || null, + detail: document.getElementById('riDetail').value.trim() || null, + }; + + try { + if (editingItemId) { + await api('/risk/items/' + editingItemId, { method: 'PATCH', body: JSON.stringify(data) }); + showToast('항목이 수정되었습니다'); + } else { + await api('/risk/processes/' + editingProcessId + '/items', { method: 'POST', body: JSON.stringify(data) }); + showToast('항목이 추가되었습니다'); + } + closeItemModal(); + await loadProject(riskProject.id); + } catch (err) { showToast(err.message, 'error'); } +} + +async function deleteItemConfirm(itemId) { + if (!confirm('이 항목을 삭제하시겠습니까?')) return; + try { + await api('/risk/items/' + itemId, { method: 'DELETE' }); + showToast('항목이 삭제되었습니다'); + await loadProject(riskProject.id); + } catch (err) { showToast(err.message, 'error'); } +} + +// ==================== 공정 추가 (수시 평가용) ==================== + +function openAddProcessModal() { + const name = prompt('추가할 공정명을 입력하세요:'); + if (!name || !name.trim()) return; + addProcessToProject(name.trim()); +} + +async function addProcessToProject(processName) { + try { + await api('/risk/projects/' + riskProject.id + '/processes', { + method: 'POST', body: JSON.stringify({ process_name: processName }) + }); + showToast('공정이 추가되었습니다'); + await loadProject(riskProject.id); + } catch (err) { showToast(err.message, 'error'); } +} + +// ==================== 감소대책 ==================== + +function renderMitigations() { + const container = document.getElementById('mitigationContainer'); + if (!container) return; + const mitigations = riskProject.mitigations || []; + + container.innerHTML = ` +
+

감소대책 수립 및 실행

+ +
+ ${mitigations.length === 0 ? '
감소대책이 없습니다
' : + `
${mitigations.map(m => renderMitigationCard(m)).join('')}
`} + `; +} + +function renderMitigationCard(m) { + return ` +
+
+
+ #${escapeHtml(m.mitigation_no)} + ${MITIGATION_STATUS[m.status] || m.status} +
+ +
+
+
유해·위험요인: ${escapeHtml(m.hazard_summary || '-')}
+
현재 위험성: ${m.current_risk_score || '-'}
+
개선계획: ${escapeHtml(m.improvement_plan || '-')}
+
+ 담당: ${escapeHtml(m.manager || '-')} + 예산: ${escapeHtml(m.budget || '-')} + 일정: ${escapeHtml(m.schedule || '-')} +
+ ${m.completion_date ? `
완료일: ${formatDate(m.completion_date)}
` : ''} + ${m.post_risk_score ? `
대책 후 위험성: ${m.post_risk_score} (${m.post_likelihood}×${m.post_severity})
` : ''} +
+
+
+ + +
+ ${m.completion_photo ? `완료사진` : ''} +
+
`; +} + +// ==================== 감소대책 모달 ==================== + +let editingMitigationId = null; + +function openMitigationModal(mitigationId) { + editingMitigationId = mitigationId || null; + const modal = document.getElementById('mitigationModal'); + modal.classList.remove('hidden'); + document.getElementById('mitigationModalTitle').textContent = mitigationId ? '감소대책 수정' : '감소대책 추가'; + const form = document.getElementById('mitigationForm'); + form.reset(); + + if (mitigationId) { + const m = (riskProject.mitigations || []).find(x => x.id === mitigationId); + if (m) { + document.getElementById('rmNo').value = m.mitigation_no || ''; + document.getElementById('rmHazard').value = m.hazard_summary || ''; + document.getElementById('rmRiskScore').value = m.current_risk_score || ''; + document.getElementById('rmPlan').value = m.improvement_plan || ''; + document.getElementById('rmManager').value = m.manager || ''; + document.getElementById('rmBudget').value = m.budget || ''; + document.getElementById('rmSchedule').value = m.schedule || ''; + document.getElementById('rmCompDate').value = m.completion_date ? String(m.completion_date).substring(0, 10) : ''; + document.getElementById('rmPostLikelihood').value = m.post_likelihood || ''; + document.getElementById('rmPostSeverity').value = m.post_severity || ''; + document.getElementById('rmStatus').value = m.status || 'planned'; + } + } else { + // 자동 번호 부여 + const existing = riskProject.mitigations || []; + const maxNo = existing.reduce((max, m) => Math.max(max, parseInt(m.mitigation_no) || 0), 0); + document.getElementById('rmNo').value = maxNo + 1; + } +} +function closeMitigationModal() { + document.getElementById('mitigationModal').classList.add('hidden'); + editingMitigationId = null; +} + +async function submitMitigation(e) { + e.preventDefault(); + const data = { + mitigation_no: document.getElementById('rmNo').value.trim(), + hazard_summary: document.getElementById('rmHazard').value.trim() || null, + current_risk_score: document.getElementById('rmRiskScore').value ? parseInt(document.getElementById('rmRiskScore').value) : null, + improvement_plan: document.getElementById('rmPlan').value.trim() || null, + manager: document.getElementById('rmManager').value.trim() || null, + budget: document.getElementById('rmBudget').value.trim() || null, + schedule: document.getElementById('rmSchedule').value.trim() || null, + completion_date: document.getElementById('rmCompDate').value || null, + post_likelihood: document.getElementById('rmPostLikelihood').value ? parseInt(document.getElementById('rmPostLikelihood').value) : null, + post_severity: document.getElementById('rmPostSeverity').value ? parseInt(document.getElementById('rmPostSeverity').value) : null, + status: document.getElementById('rmStatus').value + }; + if (!data.mitigation_no) return showToast('대책 번호를 입력하세요', 'error'); + + try { + if (editingMitigationId) { + await api('/risk/mitigations/' + editingMitigationId, { method: 'PATCH', body: JSON.stringify(data) }); + showToast('감소대책이 수정되었습니다'); + } else { + await api('/risk/projects/' + riskProject.id + '/mitigations', { method: 'POST', body: JSON.stringify(data) }); + showToast('감소대책이 추가되었습니다'); + } + closeMitigationModal(); + await loadProject(riskProject.id); + } catch (err) { showToast(err.message, 'error'); } +} + +async function uploadMitigationPhoto(e, mitigationId) { + e.preventDefault(); + const input = document.getElementById('photoInput-' + mitigationId); + if (!input.files.length) return showToast('사진을 선택하세요', 'error'); + const formData = new FormData(); + formData.append('photo', input.files[0]); + try { + await api('/risk/mitigations/' + mitigationId + '/photo', { + method: 'POST', body: formData, headers: {} + }); + showToast('사진이 업로드되었습니다'); + await loadProject(riskProject.id); + } catch (err) { showToast(err.message, 'error'); } +} diff --git a/tksafety/web/training.html b/tksafety/web/training.html index 5d3c5cb..8378cca 100644 --- a/tksafety/web/training.html +++ b/tksafety/web/training.html @@ -214,7 +214,7 @@ - + diff --git a/tksafety/web/visit-management.html b/tksafety/web/visit-management.html index ee5870b..ef4d5dd 100644 --- a/tksafety/web/visit-management.html +++ b/tksafety/web/visit-management.html @@ -184,7 +184,7 @@ - + diff --git a/tksafety/web/visit-request.html b/tksafety/web/visit-request.html index 972d4d1..72cc933 100644 --- a/tksafety/web/visit-request.html +++ b/tksafety/web/visit-request.html @@ -148,7 +148,7 @@ - +