/** * 사고조사 패키지. * * 사고가 난 다음 날 요구되는 것은 "그 사건 하나"가 아니라 그 사건의 맥락 전부다: * 무슨 일이 있었는지, 그 전후에 같은 카메라가 무엇을 봤는지, 누구에게 언제 알려졌는지, * 이미 어떤 조치가 있었는지, 그 위험요인을 무엇으로 통제하고 있었는지. 손으로 모으면 * 반나절이고, 사고 직후에 반나절을 쓸 수 있는 사람은 없다. * * 인쇄 CSS 가 앱 크롬을 걷어내므로 이 화면 그대로 PDF 로 저장해 제출할 수 있다. * * XSS-safe: 모든 동적 값은 textContent. */ (function () { 'use strict'; var B = window.APP_BASE || ''; var root = document.getElementById('package-root'); if (!root) return; var m = location.pathname.match(/\/events\/(\d+)\/package/); if (!m) { root.textContent = '잘못된 주소입니다.'; return; } var id = m[1]; var TYPE_LABELS = { fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트', camera_moved: '카메라 화각 변경', camera_obstructed: '카메라 가림', low_light: '조도 저하', unacked_escalation: '미확인 경보 에스컬레이션', ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기' }; var SEV_LABELS = { critical: '위급', high: '높음', warning: '경고', info: '정보' }; var ALERT_STATUS = { sent: '발송됨', pending: '발송 대기', failed: '발송 실패', suppressed: '억제됨 (정비 중)' }; function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined && text !== null) n.textContent = text; return n; } function pad2(n) { return n < 10 ? '0' + n : String(n); } function stamp(ts) { var d = new Date(Number(ts) * 1000); if (isNaN(d.getTime())) return '-'; return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()); } function clockOnly(ts) { var d = new Date(Number(ts) * 1000); return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()); } function section(title) { var s = el('section', 'pkg-section'); s.appendChild(el('h2', 'pkg-h2', title)); return s; } function kv(parent, label, value) { var row = el('div', 'pkg-kv'); row.appendChild(el('dt', null, label)); row.appendChild(el('dd', null, value === null || value === undefined || value === '' ? '-' : String(value))); parent.appendChild(row); } function render(d) { var ev = d.event || {}; root.textContent = ''; // ---- 표지 ----------------------------------------------------------------- var head = el('header', 'pkg-head'); head.appendChild(el('p', 'pkg-doc-kind', '작업장 안전 사고조사 패키지')); head.appendChild(el('h1', 'pkg-title', TYPE_LABELS[ev.event_type] || ev.event_type)); var sub = el('p', 'pkg-sub'); sub.textContent = [SEV_LABELS[ev.severity] || ev.severity, ev.camera_id, stamp(ev.occurred_at)] .filter(Boolean).join(' · '); head.appendChild(sub); root.appendChild(head); // ---- 사건 개요 ------------------------------------------------------------- var facts = section('사건 개요'); var dl = el('dl', 'pkg-facts'); kv(dl, '사건 번호', ev.event_id); kv(dl, '유형', TYPE_LABELS[ev.event_type] || ev.event_type); kv(dl, '심각도', SEV_LABELS[ev.severity] || ev.severity); kv(dl, '장치', ev.device_id); kv(dl, '카메라', ev.camera_id); kv(dl, '현장', ev.site); kv(dl, '발생 시각', stamp(ev.occurred_at)); kv(dl, '지속', (ev.duration_s || 0) + '초'); kv(dl, '처리 상태', ev.false_positive_by ? ('오탐 판정 · ' + ev.false_positive_by) : (ev.acknowledged_by ? ('확인 · ' + ev.acknowledged_by + ' · ' + stamp(ev.acknowledged_at)) : '미확인')); // 대응 시간은 조사에서 가장 먼저 묻는 숫자다. if (ev.acknowledged_at) { var tta = Math.max(0, Math.round(ev.acknowledged_at - ev.occurred_at)); kv(dl, '대응 소요', tta < 60 ? tta + '초' : Math.floor(tta / 60) + '분 ' + (tta % 60) + '초'); } facts.appendChild(dl); root.appendChild(facts); // ---- 증거 프레임 ----------------------------------------------------------- var evidence = section('증거 프레임'); var snaps = (d.snapshots || []).filter(function (s) { return s && s.file_uuid; }); if (!snaps.length) { evidence.appendChild(el('p', 'pkg-empty', '저장된 스냅샷이 없습니다 (미촬영이거나 개인정보 보존기간이 지나 삭제됨).')); } else { var strip = el('div', 'pkg-frames'); var CAPTIONS = ['이전', '직전', '발생 시점']; snaps.forEach(function (s, i) { var fig = el('figure', 'pkg-frame'); var img = document.createElement('img'); img.src = B + '/snap/' + encodeURIComponent(s.file_uuid); img.alt = '사고 스냅샷'; img.loading = 'lazy'; fig.appendChild(img); fig.appendChild(el('figcaption', null, snaps.length > 1 ? CAPTIONS[Math.max(0, CAPTIONS.length - snaps.length + i)] : '발생 시점')); strip.appendChild(fig); }); evidence.appendChild(strip); evidence.appendChild(el('p', 'pkg-note', '얼굴은 개인정보 보호를 위해 촬영 시점에 블러 처리되어 저장됩니다. 프레임 간격은 엣지의 스냅샷 주기 설정을 따릅니다.')); } root.appendChild(evidence); // ---- 전후 타임라인 --------------------------------------------------------- var win = Math.round((d.context_window_s || 1800) / 60); var tl = section('같은 카메라의 전후 ' + win + '분'); var rows = d.timeline || []; if (!rows.length) { tl.appendChild(el('p', 'pkg-empty', '해당 구간에 다른 감지가 없습니다.')); } else { var list = el('ol', 'pkg-timeline'); rows.forEach(function (r) { var li = el('li', 'pkg-tl-row' + (r.event_id === ev.event_id ? ' is-subject' : '')); li.appendChild(el('span', 'pkg-tl-time', clockOnly(r.occurred_at))); li.appendChild(el('span', 'pkg-tl-sev sev-' + r.severity, SEV_LABELS[r.severity] || r.severity)); li.appendChild(el('span', 'pkg-tl-type', TYPE_LABELS[r.event_type] || r.event_type)); li.appendChild(el('span', 'pkg-tl-state', r.false_positive_by ? '오탐' : (r.acknowledged_by ? '확인됨' : '미확인'))); list.appendChild(li); }); tl.appendChild(list); } root.appendChild(tl); // ---- 통보 이력 ------------------------------------------------------------- // 시스템이 "봤다"는 것만 증명하고 사람에게 "닿았는가"를 비워두면 조사의 절반이 빈다. var notif = section('통보 이력'); var alerts = d.notifications || []; if (!alerts.length) { notif.appendChild(el('p', 'pkg-empty', '이 사건에 대한 통보 기록이 없습니다. 통보 경로가 설정되지 않았거나, 심각도가 발송 기준에 못 미쳤습니다.')); } else { var nt = el('ul', 'pkg-alerts'); alerts.forEach(function (a) { var li = el('li', 'pkg-alert is-' + a.status); li.appendChild(el('span', 'pkg-alert-ch', (a.channel_name || '삭제된 채널'))); li.appendChild(el('span', 'pkg-alert-status', ALERT_STATUS[a.status] || a.status)); li.appendChild(el('span', 'pkg-alert-when', a.sent_at ? stamp(a.sent_at) : ('접수 ' + stamp(a.queued_at)))); if (a.last_error) li.appendChild(el('span', 'pkg-alert-err', a.last_error)); nt.appendChild(li); }); notif.appendChild(nt); } root.appendChild(notif); // ---- 위험성평가 ------------------------------------------------------------- var risk = section('해당 위험요인 · 통제 대책'); if (!d.risk_control) { risk.appendChild(el('p', 'pkg-empty', '이 감지 유형에 등록된 위험요인이 없습니다. [안전 관리] 화면에서 등록하면 다음 조사부터 함께 실립니다.')); } else { var rc = d.risk_control; var rdl = el('dl', 'pkg-facts'); kv(rdl, '위험요인', rc.hazard); kv(rdl, '현재 대책', rc.control); kv(rdl, '위험도', rc.risk_score + ' (강도 ' + rc.severity_rating + ' × 빈도 ' + rc.likelihood_rating + ')'); kv(rdl, '최종 검토', rc.reviewed_by ? (rc.reviewed_by + ' · ' + stamp(rc.reviewed_at)) : '-'); risk.appendChild(rdl); } root.appendChild(risk); // ---- 조치 이력 ------------------------------------------------------------- var acts = section('이 카메라의 개선 조치 이력'); var actions = d.actions || []; if (!actions.length) { acts.appendChild(el('p', 'pkg-empty', '기록된 조치가 없습니다.')); } else { var al = el('ul', 'pkg-actions'); actions.forEach(function (a) { var li = el('li', null); li.appendChild(el('span', 'pkg-act-date', stamp(a.taken_at).slice(0, 10))); li.appendChild(el('span', 'pkg-act-note', a.note)); li.appendChild(el('span', 'pkg-act-by', a.created_by)); al.appendChild(li); }); acts.appendChild(al); } root.appendChild(acts); // ---- 서명 ----------------------------------------------------------------- var sign = section('확인'); var grid = el('div', 'pkg-sign'); ['조사자', '안전관리자', '사업주 · 대리인'].forEach(function (role) { var box = el('div', 'pkg-sign-box'); box.appendChild(el('span', 'pkg-sign-role', role)); box.appendChild(el('span', 'pkg-sign-line', '')); grid.appendChild(box); }); sign.appendChild(grid); root.appendChild(sign); var foot = el('footer', 'pkg-foot'); foot.textContent = '생성 ' + stamp(d.generated_at) + ' · 작성 ' + (d.generated_by || '-') + ' · 본 문서는 AI 감지 기록에 근거한 보조 자료이며 공식 재해조사 결과를 대체하지 않습니다.'; root.appendChild(foot); document.title = '사고조사 #' + ev.event_id + ' · AI SAFETY CCTV'; } var printBtn = document.getElementById('package-print'); if (printBtn) printBtn.addEventListener('click', function () { window.print(); }); fetch(B + '/api/events/' + encodeURIComponent(id) + '/package', { headers: { Accept: 'application/json' } }) .then(function (r) { if (r.status === 401) { window.location.href = B + '/login'; return null; } if (r.status === 404) { root.textContent = '해당 사건을 찾을 수 없습니다.'; return null; } if (!r.ok) throw new Error('package_failed'); return r.json(); }) .then(function (d) { if (d) render(d); }) .catch(function () { root.textContent = '패키지를 불러오지 못했습니다.'; }); })();