5개 테이블(risk_projects/processes/items/mitigations/templates) + 마스터 시딩, 프로젝트·항목·감소대책 CRUD API, ExcelJS 평가표 내보내기, 프로젝트 목록·평가 수행 페이지, 사진 업로드(multer), 네비게이션·CSS 추가. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
212 lines
7.9 KiB
JavaScript
212 lines
7.9 KiB
JavaScript
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 };
|