Files
tk-factory-services/tksafety/web/static/js/tksafety-risk.js
Hyungi Ahn e9b69ed87b 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>
2026-03-15 08:05:19 +09:00

485 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===== 위험성평가 모듈 ===== */
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'); }
}