move_detect/server/public/assets/report.js
유창욱 4bfb7e0274
Some checks are pending
verify / edge (push) Waiting to run
verify / server (push) Waiting to run
feat: 추락 · 동시 다발 쓰러짐 · 구역 프리셋 (1.2.0)
AI CCTV 목적을 원론부터 재점검한 결과. 셋 다 새 추론 모델 없이 지금 있는
자세추정·구역·트랙 이력만으로 구현했다.

■ 추락 (fall_from_height)

잡고 있던 것은 같은 높이의 '넘어짐'이었고, 사망 1위 유형인 '떨어짐'은 못
잡았다. GhostIncidentDetector 가 트랙이 이미 위험 자세일 때만 무장하므로
(_DANGEROUS), 발판에 서 있다가 떨어지는 사람은 소실 순간 STANDING 이라
아무것도 안 걸렸다.

판정의 핵심은 bbox 중심이 아니라 하단(y2)이다. 쭈그려 앉으면 중심은
내려가도 발은 그 자리이고, 추락은 발 밑선이 통째로 내려간다 — 중심으로
재면 쭈그려 앉아 작업하는 사람이 전부 추락이 된다. 원근 접근은 y2 도
내려가므로 높이 증가 가드로 따로 막는다. Track.frames 가 90프레임을 들고
있어 별도 버퍼가 없다.

발보는 두 갈래로 나뉘되 한 사고는 한 건이다: 프레임 안에서 넘어짐이
확정되면 유형을 승격하고, 화각 밖으로 사라지면 ghost 무장을 넓혀 그쪽으로
낸다. 심각도 CRITICAL — 척추·두부 손상 주의가 통보에 실리려면 넘어짐(HIGH)과
등급이 갈라져야 한다. 고소 구역은 rules_json 의 elevated 키로 붙어 스키마
변경이 없고, 구역이 없어도 하강 신호만으로 동작한다.

■ 동시 다발 쓰러짐 (multi_down)

같은 구역에서 짧은 시간에 둘이 쓰러진 것은 넘어짐 두 건이 아니라 질식·
가스·폭발이다. 이 기능의 본체는 감지가 아니라 통보 문구다 — 밀폐공간
사망자의 상당수가 구조하러 들어간 사람이므로, 링크를 열기 전에 메일 본문에서
"공기호흡기 없이 진입하지 마십시오"가 읽혀야 한다. Channels::guidance 로
유형별 안내를 붙였고 문구를 테스트로 고정했다(문구가 사라지면 기능이 사라진
것과 같다).

서버에 둔 이유: 가스는 카메라 화각을 존중하지 않아 노드를 넘는 판단이
필요하고, 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미 확보한다. 오탐 방어의
핵심은 근접성 — 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로
일어나므로 같은 구역이거나 좌표가 가까워야 묶는다. 한 사람이 두 번 넘어진
것은 한 명으로 센다.

■ 구역 프리셋 6종 + max_workers:0 수정

밀폐공간 감시에 필요한 것(제한구역+2인1조+체류상한)은 이미 전부 있었는데
화면이 "최소 인원을 넣으세요"라고만 물어 아무도 그렇게 쓰지 못했다.
안전관리자는 min_workers 가 아니라 밀폐공간을 생각한다.

그 과정에서 잠복 결함 하나: max_workers:0 이 falsy 로 걸러져 "아무도 있으면
안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 통째로
꺼져 있었다. 운영자가 저장해 놓고도 아무 일이 안 일어났다. 감지기·입력란
(min=1)·저장 직렬화(>0)·목록 표시 네 곳에서 막혀 있었다.

■ 검증

pytest 322 passed / phpunit 263 passed. 신규 테스트 30건.
이벤트 유형 2종 추가는 test_event_type_parity 가 강제하는 5곳(엣지 enum ·
EventValidator · schema CHECK · 프런트 라벨 5파일 · 필터 옵션) 전부 동기화했고
기존 설치용 마이그레이션을 추가했다.

■ Pi 번들

1.1.0 과 달리 이번에는 edge/ 가 바뀌었으므로 Pi 번들 재빌드가 필수다.
낡은 1.0.0 번들은 dist/ 에서 제거했다 — 두면 추락 감지가 빠진 노드가 그대로
납품된다. arm64 + 네트워크 빌드 머신에서 재빌드해야 Pi 납품이 가능하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 12:21:50 +09:00

410 lines
20 KiB
JavaScript
Raw Permalink 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.

/**
* Safety operations report: fetches GET /api/report?days=N and renders the management
* indicators a 안전관리자 takes into the weekly safety meeting. Read-only; the 인쇄 button
* prints the page (print CSS strips the app chrome).
*
* XSS-safe: every dynamic value goes through textContent; the only inline style is a
* numeric bar width.
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
var TYPE_LABELS = {
fall: '넘어짐', fall_from_height: '추락', 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: '미확인 경보 에스컬레이션', multi_down: '동시 다발 쓰러짐',
// 모델 확보 후 활성화 (엣지는 아직 발화하지 않음).
ppe_missing: '보호구 미착용', proximity: '중장비 근접', fire_smoke: '화재·연기'
};
// 표본 게이트 임계. 이 밑에서는 비율·추세를 '결과'로 쓰지 않고 원시 수치만 보여준다.
var PATTERN_MIN_PEAK = 4; // 요일×시간 격자에서 최다 칸이 이보다 적으면 무늬는 우연
var ACTION_MIN_BEFORE = 5; // 개선조치 전후 비교의 최소 '전' 표본
var root = document.getElementById('report-kpis');
if (!root) return;
var daysSel = document.getElementById('report-days');
var printBtn = document.getElementById('report-print');
var rangeEl = document.getElementById('report-range');
var typesEl = document.getElementById('report-types');
var camerasEl = document.getElementById('report-cameras');
var ergoEl = document.getElementById('report-ergo');
var rulesEl = document.getElementById('report-rules');
var footEl = document.getElementById('report-foot');
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 fmtDuration(s) {
if (s === null || s === undefined) return '—';
if (s < 60) return Math.round(s) + '초';
if (s < 3600) return Math.floor(s / 60) + '분 ' + Math.round(s % 60) + '초';
return Math.floor(s / 3600) + '시간 ' + Math.floor((s % 3600) / 60) + '분';
}
function fmtHours(s) {
if (!s) return '0분';
if (s < 3600) return Math.round(s / 60) + '분';
return (s / 3600).toFixed(1) + '시간';
}
// One stamp format across the product (app.js / detail.js use the same shape). This page
// printed toLocaleDateString('ko-KR') — "2026. 8. 2." — while the stream printed "08-02",
// so the same day looked like two different notations depending on the screen.
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function fmtDate(ts) {
var d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '-';
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function fmtStamp(ts) {
var d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '-';
return fmtDate(ts) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}
// KPI tile: the headline number plus the one-line reading of what it means.
function kpi(label, value, note, cls) {
var t = el('div', 'kpi' + (cls ? ' ' + cls : ''));
t.appendChild(el('span', 'kpi-label', label));
t.appendChild(el('strong', 'kpi-value', value));
if (note) t.appendChild(el('span', 'kpi-note', note));
return t;
}
// 전기간 대비 추이 한 줄. neutral: 니어미스처럼 증감의 좋고 나쁨을 단정할 수 없는 지표
// (감지가 늘어난 것일 수도, 위험이 늘어난 것일 수도) — 방향만 보여주고 색은 칠하지 않는다.
function trendLine(tile, prevVal, curVal, fmt, neutral) {
if (prevVal === null || prevVal === undefined) return;
var line = el('span', 'kpi-trend');
if (prevVal === curVal) {
line.textContent = '이전 기간과 동일';
} else {
var up = curVal > prevVal;
line.textContent = (up ? '▲ ' : '▼ ') + '이전 기간 ' + fmt(prevVal) + ' → ' + fmt(curVal);
if (!neutral) line.className = 'kpi-trend ' + (up ? 'is-worse' : 'is-better');
}
tile.appendChild(line);
}
function barRow(label, value, max, text, hot) {
var row = el('div', 'bar-row');
var head = el('div', 'bar-head');
head.appendChild(el('span', 'bar-label', label));
head.appendChild(el('span', 'bar-value' + (hot ? ' is-hot' : ''), text));
row.appendChild(head);
var track = el('div', 'bar-track');
var fill = el('div', 'bar-fill' + (hot ? ' is-hot' : ''));
fill.style.width = (max > 0 ? Math.max(2, Math.round(value / max * 100)) : 0) + '%';
track.appendChild(fill);
row.appendChild(track);
return row;
}
function render(r) {
var t = r.totals;
var severe = t.critical + t.high;
rangeEl.textContent = '최근 ' + r.days + '일 · '
+ fmtDate(r.generated_at - r.days * 86400) + ' ~ ' + fmtDate(r.generated_at);
// 전조 급증 경보 — 사고가 아니라 사고의 전조에 거는 알람이라 리포트 최상단에 선다.
var alertEl = document.getElementById('report-alert');
if (alertEl) {
var sp = r.precursor_alert || {};
alertEl.hidden = !sp.alert;
alertEl.textContent = '';
if (sp.alert) {
var lead = el('strong', null, '⚠ 전조 급증');
alertEl.appendChild(lead);
alertEl.appendChild(document.createTextNode(
' — 최근 7일 니어미스·구역 신호 ' + sp.this_week + '건, 이전 4주 주평균 '
+ sp.baseline_weekly_avg + '건' + (sp.ratio ? ' (' + sp.ratio + '배)' : '')
+ '. 배치·동선·휴식 등 사전 개입을 검토하세요.'));
}
}
root.textContent = '';
var prev = r.previous || {};
var kSevere = kpi('중대 이벤트', String(severe),
'위급 ' + t.critical + ' · 높음 ' + t.high, severe > 0 ? 'is-severe' : '');
trendLine(kSevere, (prev.critical || 0) + (prev.high || 0), severe, String);
root.appendChild(kSevere);
var kTta = kpi('평균 대응 시간', fmtDuration(r.tta.avg_s),
r.tta.acked > 0 ? '중대 ' + r.tta.acked + '건 확인 · 최장 ' + fmtDuration(r.tta.max_s) : '확인된 중대 이벤트 없음');
if (r.tta.avg_s !== null) trendLine(kTta, prev.tta_avg_s, r.tta.avg_s, fmtDuration);
root.appendChild(kTta);
// 하인리히 지표: 니어미스가 사고보다 얼마나 앞서 잡히고 있나 — 예방이 작동한다는 증거.
var nm = r.near_miss;
var kNm = kpi('니어미스 : 사고', nm.near_fall + ' : ' + nm.incidents,
nm.near_fall > 0 ? '사고 전 신호가 먼저 잡히고 있습니다' : '기록된 니어미스 없음');
trendLine(kNm, prev.near_fall, nm.near_fall, String, true);
root.appendChild(kNm);
var fpPct = r.fp.total > 0 ? Math.round(r.fp.tagged / r.fp.total * 100) : 0;
var prevFpPct = prev.fp && prev.fp.total > 0 ? Math.round(prev.fp.tagged / prev.fp.total * 100) : null;
var kFp = kpi('오탐율', fpPct + '%',
'전체 ' + r.fp.total + '건 중 오탐 태그 ' + r.fp.tagged + '건', fpPct >= 30 ? 'is-warn' : '');
trendLine(kFp, prevFpPct, fpPct, function (v) { return v + '%'; });
root.appendChild(kFp);
// 현장별 롤업 (본사 관점). 스코프 운영자에게는 서버가 null 을 주고 카드는 숨은 채로 남는다.
var sitesCard = document.getElementById('report-sites-card');
var sitesEl = document.getElementById('report-sites');
if (sitesCard && sitesEl) {
var sites = r.by_site;
// 현장이 하나뿐인 설치에서 한 줄짜리 표는 정보가 아니라 잡음이다.
var show = Array.isArray(sites) && sites.length > 1;
sitesCard.hidden = !show;
if (show) {
sitesEl.textContent = '';
var table = el('div', 'site-rows');
var maxSevere = sites.reduce(function (m, s) { return Math.max(m, s.critical_high); }, 0);
sites.forEach(function (s) {
var row = el('div', 'site-row' + (s.critical_high > 0 && s.critical_high === maxSevere ? ' is-worst' : ''));
row.appendChild(el('span', 'site-name', s.site || '현장 미지정'));
var nums = el('span', 'site-nums');
nums.appendChild(el('strong', 'site-severe' + (s.critical_high > 0 ? ' is-hot' : ''), String(s.critical_high)));
nums.appendChild(el('span', null, '중대'));
nums.appendChild(el('strong', null, String(s.count)));
nums.appendChild(el('span', null, '전체'));
nums.appendChild(el('strong', 'site-unacked' + (s.unacked > 0 ? ' is-open' : ''), String(s.unacked)));
nums.appendChild(el('span', null, '미확인'));
nums.appendChild(el('span', 'site-cams', '카메라 ' + s.cameras));
row.appendChild(nums);
table.appendChild(row);
});
sitesEl.appendChild(table);
sitesEl.appendChild(el('p', 'report-empty',
'현장별 수치는 접근 권한이 전역인 계정에만 표시됩니다. 중대(위급+높음)가 가장 많은 현장을 강조했습니다.'));
}
}
typesEl.textContent = '';
var typeKeys = Object.keys(r.by_type);
if (typeKeys.length === 0) typesEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
var maxType = typeKeys.reduce(function (m, k) { return Math.max(m, r.by_type[k]); }, 0);
typeKeys.forEach(function (k) {
typesEl.appendChild(barRow(TYPE_LABELS[k] || k, r.by_type[k], maxType, r.by_type[k] + '건'));
});
camerasEl.textContent = '';
if (r.by_camera.length === 0) camerasEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
var maxCam = r.by_camera.reduce(function (m, c) { return Math.max(m, c.count); }, 0);
// "위험 순위"인데 중대가 1건이라도 있으면 전부 빨강이었다 — 실제 현장에서는 거의 모든
// 카메라가 그 조건을 만족하므로 순위가 순위 노릇을 못 했다. 최상위 카메라와, 그에 준하는
// (중대 밀도가 최상위의 60% 이상인) 카메라만 강조해 '먼저 볼 곳'을 실제로 좁힌다.
var maxSevere = r.by_camera.reduce(function (m, c) { return Math.max(m, c.critical_high); }, 0);
var hotFloor = Math.max(1, Math.ceil(maxSevere * 0.6));
r.by_camera.forEach(function (c) {
camerasEl.appendChild(barRow(DeviceScope.label(c.camera_id), c.count, maxCam,
c.count + '건 · 중대 ' + c.critical_high, maxSevere > 0 && c.critical_high >= hotFloor));
});
if (maxSevere > 0) {
camerasEl.appendChild(el('p', 'report-empty',
'중대(위급+높음)가 ' + hotFloor + '건 이상인 카메라를 강조했습니다.'));
}
// 인체공학: 관측 시간 대비 각 밴드 노출 — 절대시간과 비율을 함께.
ergoEl.textContent = '';
var eg = r.ergo;
if (!eg.observed_s) {
ergoEl.appendChild(el('p', 'report-empty', '기간 내 자세 관측 데이터가 없습니다.'));
} else {
[['trunk_high_s', '허리 고위험 (60°+)', true],
['trunk_mod_s', '허리 중위험 (20~60°)', false],
['arm_high_s', '팔 거상 (90°+)', false],
['squat_s', '깊은 웅크림', false]].forEach(function (row) {
var v = eg[row[0]] || 0;
var pct = Math.round(v / eg.observed_s * 100);
ergoEl.appendChild(barRow(row[1], v, eg.observed_s,
fmtHours(v) + ' (' + pct + '%)', row[2] && pct >= 20));
});
ergoEl.appendChild(el('p', 'report-empty', '총 관측 ' + fmtHours(eg.observed_s)
+ ' — 비율은 관측 시간 대비입니다.'));
}
rulesEl.textContent = '';
var oc = r.occupancy;
var rules = el('div', 'rule-stats');
// '미확인 잔여'는 존 규칙 위반이 아니다. 같은 2×2 격자에 같은 무게로 놓여 있어서
// 네 번째 규칙처럼 읽혔다 — 처리 현황은 격자 밖 한 줄로 분리한다.
[['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay]]
.forEach(function (pair) {
var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : ''));
box.appendChild(el('strong', null, String(pair[1])));
box.appendChild(el('span', null, pair[0]));
rules.appendChild(box);
});
rulesEl.appendChild(rules);
rulesEl.appendChild(el('p', 'report-empty',
'처리 현황 — 이 기간 감지 ' + t.events + '건 중 미확인 ' + t.unacked + '건.'));
// 시간 패턴: 요일×시간 발생 밀도 — "언제가 위험한가"를 한 장에.
var patternEl = document.getElementById('report-pattern');
if (patternEl) {
patternEl.textContent = '';
var pt = r.pattern || { grid: [], max: 0 };
if (!pt.max) {
patternEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
} else if (pt.max < PATTERN_MIN_PEAK) {
// 168칸에 최대 2~3건이면 무늬는 우연이다. 7일 창에서는 거의 항상 이 상태이므로,
// 전폭 카드가 '패턴을 찾았다'고 말하지 않도록 게이트를 둔다.
patternEl.appendChild(el('p', 'report-empty',
'아직 시간대 패턴을 말할 만큼 감지가 쌓이지 않았습니다 (가장 많은 칸이 ' + pt.max
+ '건). 기간을 30일 이상으로 넓히면 분포가 드러납니다.'));
} else {
var DAYS = ['월', '화', '수', '목', '금', '토', '일'];
var table = el('div', 'pattern');
var head = el('div', 'pattern-row pattern-head');
head.appendChild(el('span', 'pattern-day', ''));
for (var h = 0; h < 24; h++) head.appendChild(el('span', 'pattern-hour', h % 6 === 0 ? String(h) : ''));
table.appendChild(head);
pt.grid.forEach(function (rowArr, d) {
var row = el('div', 'pattern-row');
row.appendChild(el('span', 'pattern-day', DAYS[d]));
rowArr.forEach(function (n, h) {
var cell = el('span', 'pattern-cell');
if (n > 0) {
cell.style.opacity = String(0.25 + 0.75 * (n / pt.max));
cell.classList.add('is-filled');
}
cell.title = DAYS[d] + ' ' + h + '시 · ' + n + '건';
cell.setAttribute('aria-label', cell.title);
row.appendChild(cell);
});
table.appendChild(row);
});
patternEl.appendChild(table);
patternEl.appendChild(el('p', 'report-empty', '진할수록 감지가 몰린 시간대입니다 (최대 ' + pt.max + '건).'));
}
}
footEl.textContent = '생성 ' + fmtStamp(r.generated_at)
+ ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
}
// --- 개선 조치 효과 --------------------------------------------------------------
var actionForm = document.getElementById('action-form');
var actionCamera = document.getElementById('action-camera');
var actionNote = document.getElementById('action-note');
var actionsRoot = document.getElementById('report-actions');
var csrfToken = null;
function loadCsrf() {
return fetch(B + '/api/csrf', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (d) { if (d && d.token) csrfToken = d.token; })
.catch(function () { /* 기록 시 403으로 드러난다 */ });
}
// 이 줄은 안전회의에 그대로 올라간다. 표본 6건에서 나온 "▲33%"를 확정 색으로 칠하면
// 회의에서 바로 반박당하고, 그 다음부터는 이 카드 전체가 신뢰를 잃는다. 표본이 임계 미만이면
// 원시 수치만 중립 톤으로 적고 판정을 미룬다. 창 길이도 함께 적어 서로 다른 창의 두 행이
// 같은 잣대로 읽히지 않게 한다.
function actionEffect(a) {
if (!a.effect) return el('span', 'action-effect is-pending', '효과 측정 중 (시행 3일 미만)');
var e = a.effect;
var base = '전 ' + e.before + '건 → 후 ' + e.after + '건 (' + e.window_days + '일 창)';
if (e.before < ACTION_MIN_BEFORE) {
var thin = el('span', 'action-effect is-thin', base + ' · 표본 부족, 판정 보류');
thin.title = '조치 전 감지가 ' + ACTION_MIN_BEFORE + '건 이상일 때만 증감률을 표시합니다.';
return thin;
}
// 카운트 데이터의 자연 변동폭(√before, 푸아송 표준편차)을 넘지 못하는 차이는 조치의 효과가
// 아니라 잡음이다. 6건→8건을 "▲33% 악화"로 칠하면 그 카드는 회의에서 한 번 반박당하고
// 끝난다. 노이즈 범위 안이면 방향도 색도 주장하지 않는다.
var diff = e.after - e.before;
if (Math.abs(diff) <= Math.sqrt(e.before)) {
var flat = el('span', 'action-effect is-thin', base + ' · 유의미한 변화 아님 (통계적 잡음 범위)');
flat.title = '카운트 데이터의 자연 변동폭(±' + (Math.round(Math.sqrt(e.before) * 10) / 10)
+ '건) 안이라 조치 효과로 볼 수 없습니다.';
return flat;
}
var pct = Math.round(diff / e.before * 100);
var txt = base + ' ' + (pct < 0 ? '▼' : '▲') + Math.abs(pct) + '%';
return el('span', 'action-effect' + (diff < 0 ? ' is-better' : ' is-worse'), txt);
}
function renderActions(list) {
if (!actionsRoot) return;
actionsRoot.textContent = '';
if (!list || list.length === 0) {
actionsRoot.appendChild(el('p', 'report-empty',
'기록된 조치가 없습니다. 조치를 기록하면 전후 감지 밀도를 자동 비교합니다.'));
return;
}
list.forEach(function (a) {
var row = el('div', 'action-row');
var what = el('div', 'action-what');
what.appendChild(el('span', 'action-date', fmtDate(a.taken_at)));
what.appendChild(el('span', 'action-cam', DeviceScope.label(a.camera_id)));
what.appendChild(el('span', 'action-note', a.note));
row.appendChild(what);
row.appendChild(actionEffect(a));
actionsRoot.appendChild(row);
});
}
function loadActions() {
if (!actionsRoot) return;
fetch(B + '/api/actions', { headers: { Accept: 'application/json' } })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (d) { if (d) renderActions(d.actions); })
.catch(function () { /* 카드 유지 */ });
}
if (actionForm) {
actionForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var camera = (actionCamera.value || '').trim();
var note = (actionNote.value || '').trim();
if (!camera || !note) return;
fetch(B + '/api/actions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' },
body: JSON.stringify({ camera_id: camera, note: note })
})
.then(function (res) {
if (!res.ok) throw new Error('action_failed:' + res.status);
actionNote.value = '';
loadActions();
})
.catch(function () { /* 권한/CSRF 실패: 입력 유지 */ });
});
}
var exportLink = document.getElementById('export-labels');
if (exportLink) exportLink.href = B + '/api/export/labels?days=90';
function load() {
var days = daysSel ? daysSel.value : '7';
fetch(B + '/api/report?days=' + encodeURIComponent(days), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('report_failed:' + res.status);
return res.json();
})
.then(function (data) { if (data) render(data); })
.catch(function () {
rangeEl.textContent = '리포트를 불러오지 못했습니다. 새로고침해 주세요.';
});
}
if (daysSel) daysSel.addEventListener('change', load);
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
load();
loadActions();
loadCsrf();
})();