feat(tksafety): 위험성평가 모듈 Phase 1 구현 — DB·API·Excel·프론트엔드
5개 테이블(risk_projects/processes/items/mitigations/templates) + 마스터 시딩, 프로젝트·항목·감소대책 CRUD API, ExcelJS 평가표 내보내기, 프로젝트 목록·평가 수행 페이지, 사진 업로드(multer), 네비게이션·CSS 추가. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
201
tksafety/api/controllers/riskController.js
Normal file
201
tksafety/api/controllers/riskController.js
Normal file
@@ -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 내보내기 실패' });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
406
tksafety/api/models/riskModel.js
Normal file
406
tksafety/api/models/riskModel.js
Normal file
@@ -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
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
60
tksafety/api/routes/riskRoutes.js
Normal file
60
tksafety/api/routes/riskRoutes.js
Normal file
@@ -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;
|
||||
211
tksafety/api/utils/riskExcelExport.js
Normal file
211
tksafety/api/utils/riskExcelExport.js
Normal file
@@ -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 };
|
||||
@@ -164,7 +164,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-checklist.js?v=2026031401"></script>
|
||||
<script>initChecklistPage();</script>
|
||||
</body>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>안전교육 - TK 안전관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksafety.css?v=2026031401">
|
||||
<link rel="stylesheet" href="/static/css/tksafety.css?v=2026031501">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@@ -190,7 +190,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-education.js?v=2026031401"></script>
|
||||
<script>initEducationPage();</script>
|
||||
</body>
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-entry-dashboard.js?v=2026031401"></script>
|
||||
<script>initEntryDashboard();</script>
|
||||
</body>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>방문 관리 - TK 안전관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksafety.css?v=2026031401">
|
||||
<link rel="stylesheet" href="/static/css/tksafety.css?v=2026031501">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@@ -277,7 +277,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-visit.js?v=2026031401"></script>
|
||||
<script>initVisitPage();</script>
|
||||
</body>
|
||||
|
||||
217
tksafety/web/risk-assess.html
Normal file
217
tksafety/web/risk-assess.html
Normal file
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>위험성평가 수행 - TK 안전관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksafety.css?v=2026031501">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-700 text-white sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-14">
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="mobileMenuBtn" onclick="toggleMobileMenu()" class="lg:hidden text-blue-200 hover:text-white">
|
||||
<i class="fas fa-bars text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-shield-alt text-xl text-blue-200"></i>
|
||||
<h1 class="text-lg font-semibold">TK 안전관리</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="headerUserName" class="text-sm font-medium hidden sm:block">-</div>
|
||||
<div id="headerUserAvatar" class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||
<button onclick="doLogout()" class="text-blue-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||||
<div class="flex gap-6">
|
||||
<!-- Sidebar Nav -->
|
||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 뒤로가기 -->
|
||||
<div class="mb-3">
|
||||
<a href="risk-projects.html" class="text-sm text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-arrow-left mr-1"></i>프로젝트 목록
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 헤더 -->
|
||||
<div id="projectHeader" class="bg-white rounded-xl shadow-sm p-4 mb-5">
|
||||
<div class="text-center text-gray-400 py-4">로딩 중...</div>
|
||||
</div>
|
||||
|
||||
<!-- 세부 공정 + 평가 항목 -->
|
||||
<div id="processContainer" class="mb-5">
|
||||
<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-400">로딩 중...</div>
|
||||
</div>
|
||||
|
||||
<!-- 수시 평가: 공정 추가 버튼 -->
|
||||
<div id="addProcessArea" class="mb-5 hidden">
|
||||
<button onclick="openAddProcessModal()" class="px-4 py-2 border-2 border-dashed border-gray-300 rounded-xl text-gray-400 hover:border-blue-400 hover:text-blue-500 w-full text-sm">
|
||||
<i class="fas fa-plus mr-1"></i>세부 공정 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 감소대책 섹션 -->
|
||||
<div id="mitigationContainer" class="bg-white rounded-xl shadow-sm p-4">
|
||||
<div class="text-center text-gray-400 py-4">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 평가 항목 모달 -->
|
||||
<div id="riskItemModal" class="hidden modal-overlay" onclick="if(event.target===this)closeItemModal()">
|
||||
<div class="modal-content p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 id="riskItemModalTitle" class="text-lg font-semibold">항목 추가</h3>
|
||||
<button onclick="closeItemModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<form id="riskItemForm" onsubmit="submitItem(event)">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">분류</label>
|
||||
<input type="text" id="riCategory" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="예: 기계적, 물리적">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">원인(작업)</label>
|
||||
<input type="text" id="riCause" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="예: 선반작업">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">유해·위험요인</label>
|
||||
<textarea id="riHazard" class="input-field w-full px-3 py-2 rounded-lg text-sm" rows="2" placeholder="유해위험요인 상세 기술"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">관련법규</label>
|
||||
<input type="text" id="riRegulation" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="예: 산업안전보건법 제38조">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">감소대책 No</label>
|
||||
<input type="text" id="riMitigationNo" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="예: 1">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">현재 안전조치</label>
|
||||
<textarea id="riCurrentMeasure" class="input-field w-full px-3 py-2 rounded-lg text-sm" rows="2" placeholder="현재 시행중인 안전조치"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">가능성 (1~5)</label>
|
||||
<select id="riLikelihood" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">-</option>
|
||||
<option value="1">1 (극히 드묾)</option>
|
||||
<option value="2">2 (드묾)</option>
|
||||
<option value="3">3 (보통)</option>
|
||||
<option value="4">4 (자주)</option>
|
||||
<option value="5">5 (매우 자주)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">중대성 (1~5)</label>
|
||||
<select id="riSeverity" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">-</option>
|
||||
<option value="1">1 (미미)</option>
|
||||
<option value="2">2 (경미)</option>
|
||||
<option value="3">3 (보통)</option>
|
||||
<option value="4">4 (중대)</option>
|
||||
<option value="5">5 (치명적)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">세부내용</label>
|
||||
<textarea id="riDetail" class="input-field w-full px-3 py-2 rounded-lg text-sm" rows="2" placeholder="추가 세부사항"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<button type="button" onclick="closeItemModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 감소대책 모달 -->
|
||||
<div id="mitigationModal" class="hidden modal-overlay" onclick="if(event.target===this)closeMitigationModal()">
|
||||
<div class="modal-content p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 id="mitigationModalTitle" class="text-lg font-semibold">감소대책 추가</h3>
|
||||
<button onclick="closeMitigationModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<form id="mitigationForm" onsubmit="submitMitigation(event)">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">대책 No <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="rmNo" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">현재 위험성</label>
|
||||
<input type="number" id="rmRiskScore" class="input-field w-full px-3 py-2 rounded-lg text-sm" min="1" max="25">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">유해·위험요인</label>
|
||||
<textarea id="rmHazard" class="input-field w-full px-3 py-2 rounded-lg text-sm" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">개선계획</label>
|
||||
<textarea id="rmPlan" class="input-field w-full px-3 py-2 rounded-lg text-sm" rows="2"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">담당자</label>
|
||||
<input type="text" id="rmManager" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">예산</label>
|
||||
<input type="text" id="rmBudget" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">일정</label>
|
||||
<input type="text" id="rmSchedule" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">완료일</label>
|
||||
<input type="date" id="rmCompDate" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">가능성(후) (1~5)</label>
|
||||
<select id="rmPostLikelihood" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">-</option>
|
||||
<option value="1">1</option><option value="2">2</option><option value="3">3</option>
|
||||
<option value="4">4</option><option value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">중대성(후) (1~5)</label>
|
||||
<select id="rmPostSeverity" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">-</option>
|
||||
<option value="1">1</option><option value="2">2</option><option value="3">3</option>
|
||||
<option value="4">4</option><option value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
|
||||
<select id="rmStatus" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
<option value="planned">계획</option>
|
||||
<option value="in_progress">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<button type="button" onclick="closeMitigationModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-risk.js?v=2026031501"></script>
|
||||
<script>initRiskAssessPage();</script>
|
||||
</body>
|
||||
</html>
|
||||
181
tksafety/web/risk-projects.html
Normal file
181
tksafety/web/risk-projects.html
Normal file
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>위험성평가 - TK 안전관리</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/tksafety.css?v=2026031501">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-700 text-white sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-14">
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="mobileMenuBtn" onclick="toggleMobileMenu()" class="lg:hidden text-blue-200 hover:text-white">
|
||||
<i class="fas fa-bars text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-shield-alt text-xl text-blue-200"></i>
|
||||
<h1 class="text-lg font-semibold">TK 안전관리</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="headerUserName" class="text-sm font-medium hidden sm:block">-</div>
|
||||
<div id="headerUserAvatar" class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||
<button onclick="doLogout()" class="text-blue-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
|
||||
<div class="flex gap-6">
|
||||
<!-- Sidebar Nav -->
|
||||
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 필터 바 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-4 mb-5">
|
||||
<div class="filter-bar flex flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">평가유형</label>
|
||||
<select id="filterType" onchange="loadProjects()" class="input-field px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
<option value="regular">정기</option>
|
||||
<option value="adhoc">수시</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">연도</label>
|
||||
<select id="filterYear" onchange="loadProjects()" class="input-field px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">제품유형</label>
|
||||
<select id="filterProduct" onchange="loadProjects()" class="input-field px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
<option value="PKG">PKG</option>
|
||||
<option value="VESSEL">VESSEL</option>
|
||||
<option value="HX">HX</option>
|
||||
<option value="SKID">SKID</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
|
||||
<select id="filterStatus" onchange="loadProjects()" class="input-field px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
<option value="draft">작성중</option>
|
||||
<option value="in_progress">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button onclick="openNewProjectModal()" class="px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 font-medium whitespace-nowrap">
|
||||
<i class="fas fa-plus mr-1"></i>신규 프로젝트
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>제목</th>
|
||||
<th class="text-center">유형</th>
|
||||
<th class="text-center">제품</th>
|
||||
<th class="text-center">연도</th>
|
||||
<th class="text-center">상태</th>
|
||||
<th class="text-center">항목수</th>
|
||||
<th class="text-center">고위험</th>
|
||||
<th class="text-center w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectTableBody">
|
||||
<tr><td colspan="8" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 신규 프로젝트 모달 -->
|
||||
<div id="projectModal" class="hidden modal-overlay" onclick="if(event.target===this)closeProjectModal()">
|
||||
<div class="modal-content p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">신규 위험성평가 프로젝트</h3>
|
||||
<button onclick="closeProjectModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<form id="projectForm" onsubmit="submitProject(event)">
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">제목 <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="projectTitle" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="예: 2025년 정기 위험성평가 (PKG)" required>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">평가유형 <span class="text-red-400">*</span></label>
|
||||
<select id="projectAssessType" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
<option value="regular">정기</option>
|
||||
<option value="adhoc">수시</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">제품유형 <span class="text-red-400">*</span></label>
|
||||
<select id="projectProductType" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
<option value="PKG">PKG</option>
|
||||
<option value="VESSEL">VESSEL</option>
|
||||
<option value="HX">HX</option>
|
||||
<option value="SKID">SKID</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">연도 <span class="text-red-400">*</span></label>
|
||||
<input type="number" id="projectYear" class="input-field w-full px-3 py-2 rounded-lg text-sm" min="2020" max="2030" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">월 (수시만)</label>
|
||||
<select id="projectMonth" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">-</option>
|
||||
<option value="1">1월</option><option value="2">2월</option><option value="3">3월</option>
|
||||
<option value="4">4월</option><option value="5">5월</option><option value="6">6월</option>
|
||||
<option value="7">7월</option><option value="8">8월</option><option value="9">9월</option>
|
||||
<option value="10">10월</option><option value="11">11월</option><option value="12">12월</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">평가자</label>
|
||||
<input type="text" id="projectAssessedBy" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="평가 담당자명">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<button type="button" onclick="closeProjectModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">생성</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-risk.js?v=2026031501"></script>
|
||||
<script>
|
||||
// 연도 필터 옵션 생성
|
||||
const yearSelect = document.getElementById('filterYear');
|
||||
const curYear = new Date().getFullYear();
|
||||
for (let y = curYear; y >= 2022; y--) {
|
||||
yearSelect.insertAdjacentHTML('beforeend', `<option value="${y}">${y}</option>`);
|
||||
}
|
||||
initRiskProjectsPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
484
tksafety/web/static/js/tksafety-risk.js
Normal file
484
tksafety/web/static/js/tksafety-risk.js
Normal file
@@ -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 = '<tr><td colspan="8" class="text-center text-gray-400 py-8">프로젝트가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = projects.map(p => `
|
||||
<tr class="cursor-pointer hover:bg-blue-50" onclick="location.href='risk-assess.html?id=${p.id}'">
|
||||
<td class="px-3 py-2">${escapeHtml(p.title)}</td>
|
||||
<td class="px-3 py-2 text-center"><span class="badge ${p.assessment_type === 'regular' ? 'badge-blue' : 'badge-amber'}">${TYPE_LABELS[p.assessment_type]}</span></td>
|
||||
<td class="px-3 py-2 text-center">${p.product_type}</td>
|
||||
<td class="px-3 py-2 text-center">${p.year}${p.month ? '/' + p.month : ''}</td>
|
||||
<td class="px-3 py-2 text-center"><span class="badge ${STATUS_BADGE[p.status]}">${STATUS_LABELS[p.status]}</span></td>
|
||||
<td class="px-3 py-2 text-center">${p.total_items || 0}</td>
|
||||
<td class="px-3 py-2 text-center">${p.high_risk_count ? `<span class="badge badge-risk-high">${p.high_risk_count}</span>` : '-'}</td>
|
||||
<td class="px-3 py-2 text-center whitespace-nowrap">
|
||||
<a href="/api/risk/projects/${p.id}/export" class="text-green-600 hover:text-green-800 mr-2" title="Excel 다운로드" onclick="event.stopPropagation()">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = `
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-800">${escapeHtml(p.title)}</h2>
|
||||
<div class="flex flex-wrap gap-2 mt-1 text-sm text-gray-500">
|
||||
<span class="badge ${p.assessment_type === 'regular' ? 'badge-blue' : 'badge-amber'}">${TYPE_LABELS[p.assessment_type]}</span>
|
||||
<span>${p.product_type}</span>
|
||||
<span>${p.year}년${p.month ? ' ' + p.month + '월' : ''}</span>
|
||||
<span class="badge ${STATUS_BADGE[p.status]}">${STATUS_LABELS[p.status]}</span>
|
||||
${p.assessed_by ? `<span>평가자: ${escapeHtml(p.assessed_by)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<select id="projectStatusSelect" onchange="changeProjectStatus(this.value)"
|
||||
class="input-field px-3 py-1.5 rounded-lg text-sm">
|
||||
<option value="draft" ${p.status==='draft'?'selected':''}>작성중</option>
|
||||
<option value="in_progress" ${p.status==='in_progress'?'selected':''}>진행중</option>
|
||||
<option value="completed" ${p.status==='completed'?'selected':''}>완료</option>
|
||||
</select>
|
||||
<a href="/api/risk/projects/${p.id}/export"
|
||||
class="px-4 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 inline-flex items-center gap-1">
|
||||
<i class="fas fa-file-excel"></i> Excel
|
||||
</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<div class="bg-white rounded-xl shadow-sm p-8 text-center text-gray-400">
|
||||
세부 공정이 없습니다. ${riskProject.assessment_type === 'adhoc' ? '<button onclick="openAddProcessModal()" class="text-blue-500 hover:underline ml-1">공정 추가</button>' : ''}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = processes.map((proc, idx) => `
|
||||
<div class="bg-white rounded-xl shadow-sm mb-4 overflow-hidden">
|
||||
<button onclick="toggleProcess(${idx})" class="w-full risk-section-header px-4 py-3 flex items-center justify-between text-left">
|
||||
<span><i class="fas fa-caret-right mr-2 process-caret" id="caret-${idx}"></i>${escapeHtml(proc.process_name)}</span>
|
||||
<span class="text-xs text-gray-500">${(proc.items||[]).length}건</span>
|
||||
</button>
|
||||
<div id="process-${idx}" class="hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="risk-table w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-10">No</th><th>분류</th><th>원인(작업)</th><th>유해·위험요인</th>
|
||||
<th>관련법규</th><th>현재 안전조치</th><th class="w-12">가능성</th><th class="w-12">중대성</th>
|
||||
<th class="w-14">위험성</th><th>대책No</th><th>세부내용</th><th class="w-16">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="items-${proc.id}">
|
||||
${renderItems(proc.items || [], proc.id)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="p-3 border-t">
|
||||
<button onclick="openItemModal(${proc.id})" class="px-3 py-1.5 bg-blue-600 text-white rounded text-xs hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-1"></i>항목 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 첫 번째 공정 자동 열기
|
||||
if (processes.length > 0) toggleProcess(0);
|
||||
}
|
||||
|
||||
function renderItems(items, processId) {
|
||||
if (!items.length) return '<tr><td colspan="12" class="text-center text-gray-400 py-4">항목이 없습니다</td></tr>';
|
||||
return items.map((item, idx) => `
|
||||
<tr>
|
||||
<td class="text-center">${idx + 1}</td>
|
||||
<td>${escapeHtml(item.category || '')}</td>
|
||||
<td>${escapeHtml(item.cause || '')}</td>
|
||||
<td>${escapeHtml(item.hazard || '')}</td>
|
||||
<td>${escapeHtml(item.regulation || '')}</td>
|
||||
<td>${escapeHtml(item.current_measure || '')}</td>
|
||||
<td class="text-center">${item.likelihood || ''}</td>
|
||||
<td class="text-center">${item.severity || ''}</td>
|
||||
<td class="text-center"><span class="badge ${riskLevelClass(item.risk_score)}">${item.risk_score || ''}</span></td>
|
||||
<td class="text-center">${escapeHtml(item.mitigation_no || '')}</td>
|
||||
<td>${escapeHtml(item.detail || '')}</td>
|
||||
<td class="text-center whitespace-nowrap">
|
||||
<button onclick="openItemModal(${processId}, ${item.id})" class="text-blue-500 hover:text-blue-700 mr-1" title="수정"><i class="fas fa-edit"></i></button>
|
||||
<button onclick="deleteItemConfirm(${item.id})" class="text-red-400 hover:text-red-600" title="삭제"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = `
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-base font-semibold text-gray-800"><i class="fas fa-shield-alt text-blue-500 mr-2"></i>감소대책 수립 및 실행</h3>
|
||||
<button onclick="openMitigationModal()" class="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-1"></i>대책 추가
|
||||
</button>
|
||||
</div>
|
||||
${mitigations.length === 0 ? '<div class="text-center text-gray-400 py-6">감소대책이 없습니다</div>' :
|
||||
`<div class="grid grid-cols-1 md:grid-cols-2 gap-4">${mitigations.map(m => renderMitigationCard(m)).join('')}</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMitigationCard(m) {
|
||||
return `
|
||||
<div class="bg-white border rounded-xl p-4 shadow-sm">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-bold text-blue-600">#${escapeHtml(m.mitigation_no)}</span>
|
||||
<span class="badge ${STATUS_BADGE[m.status] || 'badge-gray'}">${MITIGATION_STATUS[m.status] || m.status}</span>
|
||||
</div>
|
||||
<button onclick="openMitigationModal(${m.id})" class="text-blue-500 hover:text-blue-700 text-sm"><i class="fas fa-edit"></i></button>
|
||||
</div>
|
||||
<div class="text-sm space-y-1 text-gray-600">
|
||||
<div><strong>유해·위험요인:</strong> ${escapeHtml(m.hazard_summary || '-')}</div>
|
||||
<div><strong>현재 위험성:</strong> <span class="badge ${riskLevelClass(m.current_risk_score)}">${m.current_risk_score || '-'}</span></div>
|
||||
<div><strong>개선계획:</strong> ${escapeHtml(m.improvement_plan || '-')}</div>
|
||||
<div class="flex gap-4">
|
||||
<span><strong>담당:</strong> ${escapeHtml(m.manager || '-')}</span>
|
||||
<span><strong>예산:</strong> ${escapeHtml(m.budget || '-')}</span>
|
||||
<span><strong>일정:</strong> ${escapeHtml(m.schedule || '-')}</span>
|
||||
</div>
|
||||
${m.completion_date ? `<div><strong>완료일:</strong> ${formatDate(m.completion_date)}</div>` : ''}
|
||||
${m.post_risk_score ? `<div><strong>대책 후 위험성:</strong> <span class="badge ${riskLevelClass(m.post_risk_score)}">${m.post_risk_score} (${m.post_likelihood}×${m.post_severity})</span></div>` : ''}
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t">
|
||||
<form onsubmit="uploadMitigationPhoto(event, ${m.id})" class="flex items-center gap-2">
|
||||
<input type="file" accept="image/*" class="text-xs flex-1" id="photoInput-${m.id}">
|
||||
<button type="submit" class="px-2 py-1 bg-gray-100 rounded text-xs hover:bg-gray-200"><i class="fas fa-upload"></i></button>
|
||||
</form>
|
||||
${m.completion_photo ? `<img src="${m.completion_photo}" class="mt-2 rounded max-h-32 object-cover" alt="완료사진">` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ==================== 감소대책 모달 ====================
|
||||
|
||||
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'); }
|
||||
}
|
||||
@@ -214,7 +214,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-training.js"></script>
|
||||
<script>initTrainingPage();</script>
|
||||
</body>
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-visit-management.js?v=2026031401"></script>
|
||||
<script>initVisitManagementPage();</script>
|
||||
</body>
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js?v=2026031401"></script>
|
||||
<script src="/static/js/tksafety-core.js?v=2026031501"></script>
|
||||
<script src="/static/js/tksafety-visit-request.js?v=2026031401"></script>
|
||||
<script>initVisitRequestPage();</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user