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 @@
-
+
@@ -190,7 +190,7 @@
-
+
@@ -277,7 +277,7 @@
-
+
+
+
+
+
+
+
+
+
TK 안전관리
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
감소대책 추가
+
+
+
+
+
+
+
+
+
+
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 @@
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 @@
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 @@
+
+
+