security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거

보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축.

[보안 수정]
- issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성
- pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD
- DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder
- docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가

[보안 강제 시스템 - 신규]
- scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2)
  3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값
- .githooks/pre-commit: 로컬 빠른 피드백
- .githooks/pre-receive-server.sh: Gitea 서버 최종 차단
  bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그)
- SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분
- docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-10 09:44:21 +09:00
parent bbffa47a9d
commit ba9ef32808
257 changed files with 786 additions and 18 deletions

View File

@@ -0,0 +1,161 @@
// /js/api-base.js
// API 기본 설정 및 보안 유틸리티 - System 2 (신고 시스템)
// 서비스 워커 해제 (push-sw.js 제외)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
registrations.forEach(function(registration) {
if (!registration.active || !registration.active.scriptURL.includes('push-sw.js')) {
registration.unregister();
}
});
});
if (typeof caches !== 'undefined') {
caches.keys().then(function(names) {
names.forEach(function(name) { caches.delete(name); });
});
}
}
(function() {
'use strict';
// ==================== SSO 쿠키 유틸리티 ====================
function cookieGet(name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
}
function cookieRemove(name) {
var cookie = name + '=; path=/; max-age=0';
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
}
/**
* SSO 토큰 가져오기 (쿠키 우선, localStorage 폴백)
*/
window.getSSOToken = function() {
return cookieGet('sso_token');
};
window.getSSOUser = function() {
var raw = cookieGet('sso_user');
try { return raw ? JSON.parse(raw) : null; } catch(e) { return null; }
};
/**
* 중앙 로그인 URL 반환 (System 2 → tkfb 도메인의 로그인으로)
*/
window.getLoginUrl = function() {
var hostname = window.location.hostname;
var t = Date.now();
if (hostname.includes('technicalkorea.net')) {
return window.location.protocol + '//tkfb.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
}
return window.location.protocol + '//' + hostname + ':30780/dashboard?redirect=' + encodeURIComponent(window.location.href) + '&_t=' + t;
};
window.clearSSOAuth = function() {
cookieRemove('sso_token');
cookieRemove('sso_user');
cookieRemove('sso_refresh_token');
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
localStorage.removeItem(k);
});
};
// ==================== 보안 유틸리티 (XSS 방지) ====================
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
if (typeof str !== 'string') str = String(str);
var htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return str.replace(/[&<>"'`=\/]/g, function(char) {
return htmlEntities[char];
});
};
window.escapeUrl = function(str) {
if (str === null || str === undefined) return '';
return encodeURIComponent(String(str));
};
// ==================== API 설정 ====================
var API_PORT = 30105;
var API_PATH = '/api';
function getApiBaseUrl() {
var hostname = window.location.hostname;
var protocol = window.location.protocol;
// 프로덕션 환경 - 같은 도메인의 /api 경로 (system2-web nginx가 프록시)
if (hostname.includes('technicalkorea.net')) {
return protocol + '//' + hostname + API_PATH;
}
// 개발 환경
return protocol + '//' + hostname + ':' + API_PORT + API_PATH;
}
var apiUrl = getApiBaseUrl();
window.API_BASE_URL = apiUrl;
window.API = apiUrl;
// 인증 헤더 생성 - SSO 토큰 사용 (쿠키/localStorage)
window.getAuthHeaders = function() {
var token = window.getSSOToken();
return {
'Content-Type': 'application/json',
'Authorization': token ? 'Bearer ' + token : ''
};
};
// API 호출 헬퍼
window.apiCall = async function(endpoint, method, data) {
method = method || 'GET';
var url = window.API_BASE_URL + endpoint;
var config = {
method: method,
headers: window.getAuthHeaders()
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
config.body = JSON.stringify(data);
}
var response = await fetch(url, config);
// 401 Unauthorized 처리 — 토큰만 정리하고 에러 throw (리다이렉트는 app-init이 처리)
if (response.status === 401) {
window.clearSSOAuth();
throw new Error('인증이 만료되었습니다.');
}
return response.json();
};
// 알림 벨 로드
window._loadNotificationBell = function() {
var h = window.location.hostname;
var s = document.createElement('script');
s.src = (h.includes('technicalkorea.net') ? 'https://tkfb.technicalkorea.net' : window.location.protocol + '//' + h + ':30000') + '/shared/notification-bell.js?v=4';
document.head.appendChild(s);
};
console.log('[System2] API 설정 완료:', window.API_BASE_URL);
})();

View File

@@ -0,0 +1,107 @@
// /js/app-init.js
// System 2 (신고 시스템) 앱 초기화 - SSO 인증 체크
(function() {
'use strict';
// ===== 리다이렉트 루프 방지 =====
var REDIRECT_KEY = '_sso_redirect_ts';
var REDIRECT_COOLDOWN = 5000; // 5초 내 재리다이렉트 방지
function safeRedirectToLogin() {
var lastRedirect = parseInt(sessionStorage.getItem(REDIRECT_KEY) || '0', 10);
var now = Date.now();
if (now - lastRedirect < REDIRECT_COOLDOWN) {
console.warn('[System2] 리다이렉트 루프 감지 — 로그인 페이지로 이동하지 않음');
return;
}
sessionStorage.setItem(REDIRECT_KEY, String(now));
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
}
// ===== 쿠키 직접 읽기 (api-base.js의 cookieGet은 IIFE 내부이므로) =====
function cookieGet(name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
}
// ===== 인증 함수 (api-base.js의 전역 헬퍼 활용) =====
function isLoggedIn() {
var token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
return token && token !== 'undefined' && token !== 'null';
}
function getUser() {
return window.getSSOUser ? window.getSSOUser() : (function() {
var u = localStorage.getItem('sso_user');
return u ? JSON.parse(u) : null;
})();
}
function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
}
// ===== 메인 초기화 =====
async function init() {
// 쿠키 우선 검증: 쿠키 없고 localStorage에만 토큰이 있으면 정리
var cookieToken = cookieGet('sso_token');
var localToken = localStorage.getItem('sso_token');
if (!cookieToken && localToken) {
['sso_token','sso_user','sso_refresh_token','token','user','access_token',
'currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) { localStorage.removeItem(k); });
safeRedirectToLogin();
return;
}
// 1. 인증 확인
if (!isLoggedIn()) {
clearAuthData();
safeRedirectToLogin();
return;
}
var currentUser = getUser();
if (!currentUser || !currentUser.username) {
clearAuthData();
safeRedirectToLogin();
return;
}
// 협력업체 계정 차단 (JWT에서 partner_company_id 확인)
var token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
if (token) {
try {
var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
if (payload.partner_company_id) {
var h = window.location.hostname;
window.location.href = h.includes('technicalkorea.net')
? 'https://tkpurchase.technicalkorea.net/partner-portal.html'
: window.location.protocol + '//' + h + ':30480/partner-portal.html';
return;
}
} catch(e) { /* ignore decode errors */ }
}
// 인증 성공 — 루프 카운터 리셋 + localStorage 백업
sessionStorage.removeItem(REDIRECT_KEY);
var token = window.getSSOToken ? window.getSSOToken() : null;
if (token && !localStorage.getItem('sso_token')) localStorage.setItem('sso_token', token);
console.log('[System2] 인증 확인:', currentUser.username);
// 알림 벨 로드
if (window._loadNotificationBell) window._loadNotificationBell();
}
// DOMContentLoaded 시 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 전역 노출
window.appInit = { getUser: getUser, clearAuthData: clearAuthData, isLoggedIn: isLoggedIn };
})();

View File

@@ -0,0 +1,847 @@
/**
* chat-report.js — 챗봇 기반 신고 접수 상태머신 + 대화 로직
*/
// ── 상태 정의 ──
const STATE = {
INIT: 'INIT',
PHOTO_TEXT: 'PHOTO_TEXT',
CLASSIFY_TYPE: 'CLASSIFY_TYPE',
CLASSIFY_CATEGORY: 'CLASSIFY_CATEGORY',
CLASSIFY_ITEM: 'CLASSIFY_ITEM',
LOCATION: 'LOCATION',
PROJECT: 'PROJECT',
CONFIRM: 'CONFIRM',
SUBMIT: 'SUBMIT',
};
let currentState = STATE.INIT;
// ── 신고 데이터 ──
let reportData = {
photos: [],
description: '',
organized_description: '',
issue_type: null, // 'nonconformity' | 'facility' | 'safety'
issue_category_id: null,
issue_category_name: null,
issue_item_id: null,
issue_item_name: null,
custom_item_name: null,
factory_category_id: null,
factory_name: null,
workplace_id: null,
workplace_name: null,
custom_location: null,
project_id: null,
project_name: null,
tbm_session_id: null,
};
// ── 참조 데이터 ──
let refData = {
factories: [],
workplaces: {}, // { factory_id: [workplaces] }
projects: [],
tbmSessions: [],
categories: {}, // { nonconformity: [...], facility: [...], safety: [...] }
items: {}, // { category_id: [...] }
};
// ── AI 분석 결과 캐시 ──
let aiAnalysis = null;
// ── DOM ──
const API_BASE = window.API_BASE_URL || 'http://localhost:30105/api';
const AI_API_BASE = (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
if (hostname.includes('technicalkorea.net')) {
return protocol + '//' + hostname + '/ai-api';
}
return protocol + '//' + hostname + ':30200/api/ai';
})();
let chatArea, textInput, sendBtn, photoInput, photoBtn;
// ── 초기화 ──
document.addEventListener('DOMContentLoaded', () => {
chatArea = document.getElementById('chatArea');
textInput = document.getElementById('textInput');
sendBtn = document.getElementById('sendBtn');
photoInput = document.getElementById('chatPhotoInput');
photoBtn = document.getElementById('photoBtn');
sendBtn.addEventListener('click', onSend);
textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); onSend(); }
});
textInput.addEventListener('input', () => {
textInput.style.height = 'auto';
textInput.style.height = Math.min(textInput.scrollHeight, 100) + 'px';
sendBtn.disabled = !textInput.value.trim() && reportData.photos.length === 0;
});
photoBtn.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', onPhotoSelect);
initChat();
});
// ── initChat: 인사 + 데이터 프리패치 ──
async function initChat() {
appendBot('안녕하세요! AI 신고 도우미입니다.\n\n현장에서 발견한 문제를 **사진과 함께 설명**해주시면, 신고 접수를 도와드리겠습니다.\n\n📎 버튼으로 사진을 첨부하고, 어떤 문제인지 간단히 입력해주세요.');
// 데이터 프리패치
try {
const today = new Date().toISOString().slice(0, 10);
const headers = getHeaders();
const [factoriesRes, projectsRes, tbmRes] = await Promise.all([
fetch(`${API_BASE}/workplaces/categories/active/list`, { headers }),
fetch(`${API_BASE}/projects/active/list`, { headers }),
fetch(`${API_BASE}/tbm/sessions/date/${today}`, { headers }),
]);
if (factoriesRes.ok) {
const d = await factoriesRes.json();
refData.factories = d.data || [];
}
if (projectsRes.ok) {
const d = await projectsRes.json();
refData.projects = d.data || [];
}
if (tbmRes.ok) {
const d = await tbmRes.json();
refData.tbmSessions = d.data || [];
}
// 카테고리 3가지 유형 동시 로드
const types = ['nonconformity', 'facility', 'safety'];
const catResults = await Promise.all(
types.map(t => fetch(`${API_BASE}/work-issues/categories/type/${t}`, { headers }).then(r => r.ok ? r.json() : { data: [] }))
);
types.forEach((t, i) => {
refData.categories[t] = catResults[i].data || [];
});
} catch (err) {
console.error('데이터 프리패치 실패:', err);
}
currentState = STATE.PHOTO_TEXT;
updateInputBar();
}
// ── 전송 핸들러 ──
function onSend() {
const text = textInput.value.trim();
if (!text && reportData.photos.length === 0) return;
if (currentState === STATE.PHOTO_TEXT) {
handlePhotoTextSubmit(text);
}
textInput.value = '';
textInput.style.height = 'auto';
sendBtn.disabled = true;
}
// ── PHOTO_TEXT: 사진+텍스트 제출 → AI 분석 ──
async function handlePhotoTextSubmit(text) {
// 사용자 메시지 표시
if (reportData.photos.length > 0 || text) {
appendUser(text, reportData.photos);
}
reportData.description = text;
setInputDisabled(true);
showTyping();
// AI 분석 호출
try {
const categoriesForAI = {};
for (const [type, cats] of Object.entries(refData.categories)) {
categoriesForAI[type] = cats.map(c => ({ id: c.category_id, name: c.category_name }));
}
const res = await fetch(`${AI_API_BASE}/chatbot/analyze`, {
method: 'POST',
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ user_text: text || '사진 참고', categories: categoriesForAI }),
});
if (res.ok) {
aiAnalysis = await res.json();
} else {
aiAnalysis = { organized_description: text, suggested_type: null, confidence: 0 };
}
} catch (err) {
console.error('AI 분석 실패:', err);
aiAnalysis = { organized_description: text, suggested_type: null, confidence: 0 };
}
hideTyping();
reportData.organized_description = aiAnalysis.organized_description || text;
// 유형 선택 단계로
currentState = STATE.CLASSIFY_TYPE;
showTypeSelection();
setInputDisabled(true);
}
// ── CLASSIFY_TYPE: 유형 선택 ──
function showTypeSelection() {
const typeLabels = {
nonconformity: '부적합',
facility: '시설설비',
safety: '안전',
};
let msg = '내용을 분석했습니다.';
if (aiAnalysis && aiAnalysis.organized_description) {
msg += `\n\n📝 "${escapeHtml(aiAnalysis.organized_description)}"`;
}
msg += '\n\n신고 **유형**을 선택해주세요:';
appendBot(msg);
const options = Object.entries(typeLabels).map(([value, label]) => ({
value, label,
suggested: aiAnalysis && aiAnalysis.suggested_type === value,
}));
appendOptions(options, onTypeSelect);
}
function onTypeSelect(type) {
reportData.issue_type = type;
const label = { nonconformity: '부적합', facility: '시설설비', safety: '안전' }[type];
appendUser(label);
disableCurrentOptions();
currentState = STATE.CLASSIFY_CATEGORY;
showCategorySelection();
}
// ── CLASSIFY_CATEGORY ──
function showCategorySelection() {
const cats = refData.categories[reportData.issue_type] || [];
if (cats.length === 0) {
appendBot('해당 유형의 카테고리가 없습니다. 다음 단계로 진행합니다.');
currentState = STATE.LOCATION;
showLocationSelection();
return;
}
appendBot('**카테고리**를 선택해주세요:');
const options = cats.map(c => ({
value: c.category_id,
label: c.category_name,
suggested: aiAnalysis && aiAnalysis.suggested_category_id === c.category_id,
}));
appendOptions(options, onCategorySelect);
}
async function onCategorySelect(categoryId) {
const cats = refData.categories[reportData.issue_type] || [];
const cat = cats.find(c => c.category_id == categoryId);
reportData.issue_category_id = categoryId;
reportData.issue_category_name = cat ? cat.category_name : '';
appendUser(reportData.issue_category_name);
disableCurrentOptions();
// 항목 로드
currentState = STATE.CLASSIFY_ITEM;
await showItemSelection(categoryId);
}
// ── CLASSIFY_ITEM ──
async function showItemSelection(categoryId) {
if (!refData.items[categoryId]) {
try {
const res = await fetch(`${API_BASE}/work-issues/items/category/${categoryId}`, { headers: getHeaders() });
if (res.ok) {
const d = await res.json();
refData.items[categoryId] = d.data || [];
}
} catch (err) {
console.error('항목 로드 실패:', err);
}
}
const items = refData.items[categoryId] || [];
if (items.length === 0) {
appendBot('항목이 없습니다. 다음 단계로 진행합니다.');
currentState = STATE.LOCATION;
showLocationSelection();
return;
}
appendBot('**세부 항목**을 선택해주세요:');
const options = items.map(i => ({ value: i.item_id, label: i.item_name }));
options.push({ value: '__custom__', label: '+ 직접 입력' });
appendOptions(options, onItemSelect);
}
function onItemSelect(itemValue) {
if (itemValue === '__custom__') {
disableCurrentOptions();
showCustomItemInput();
return;
}
const items = refData.items[reportData.issue_category_id] || [];
const item = items.find(i => i.item_id == itemValue);
reportData.issue_item_id = itemValue;
reportData.issue_item_name = item ? item.item_name : '';
reportData.custom_item_name = null;
appendUser(reportData.issue_item_name);
disableCurrentOptions();
currentState = STATE.LOCATION;
showLocationSelection();
}
function showCustomItemInput() {
appendBot('항목명을 직접 입력해주세요:');
setInputDisabled(false);
textInput.placeholder = '항목명 입력...';
// Temporarily override send for custom item
const origOnSend = onSend;
sendBtn.onclick = () => {
const val = textInput.value.trim();
if (!val) return;
reportData.custom_item_name = val;
reportData.issue_item_id = null;
reportData.issue_item_name = val;
appendUser(val);
textInput.value = '';
textInput.style.height = 'auto';
textInput.placeholder = '메시지 입력...';
sendBtn.onclick = onSend;
setInputDisabled(true);
currentState = STATE.LOCATION;
showLocationSelection();
};
textInput.onkeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBtn.click(); }
};
}
// ── LOCATION: 작업장 선택 ──
function showLocationSelection() {
appendBot('**위치(작업장)**를 선택해주세요:');
const options = [];
refData.factories.forEach(factory => {
// 해당 공장의 TBM 작업장만 우선 표시
const factoryTbm = refData.tbmSessions.filter(s => {
// Check if workplace belongs to this factory
return true; // We'll show all workplaces by factory group
});
options.push({
value: `factory_${factory.category_id}`,
label: `🏭 ${factory.category_name}`,
isGroup: true,
});
});
// 작업장 로드 후 표시 (공장별)
loadWorkplacesAndShow();
}
async function loadWorkplacesAndShow() {
const allOptions = [];
for (const factory of refData.factories) {
// 작업장 로드 (맵 리전에서)
if (!refData.workplaces[factory.category_id]) {
try {
const res = await fetch(`${API_BASE}/workplaces/categories/${factory.category_id}/map-regions`, { headers: getHeaders() });
if (res.ok) {
const d = await res.json();
refData.workplaces[factory.category_id] = (d.data || []).map(r => ({
workplace_id: r.workplace_id,
workplace_name: r.workplace_name,
factory_id: factory.category_id,
factory_name: factory.category_name,
}));
}
} catch (err) {
console.error(`작업장 로드 실패 (${factory.category_name}):`, err);
}
}
const workplaces = refData.workplaces[factory.category_id] || [];
if (workplaces.length > 0) {
// 해당 공장에 TBM이 있는 작업장 표시
const tbmWorkplaceIds = new Set(
refData.tbmSessions
.filter(s => workplaces.some(w => w.workplace_id === s.workplace_id))
.map(s => s.workplace_id)
);
workplaces.forEach(wp => {
const hasTbm = tbmWorkplaceIds.has(wp.workplace_id);
allOptions.push({
value: JSON.stringify({ fid: factory.category_id, fname: factory.category_name, wid: wp.workplace_id, wname: wp.workplace_name }),
label: `${factory.category_name} - ${wp.workplace_name}${hasTbm ? ' 🔨' : ''}`,
});
});
}
}
allOptions.push({ value: '__unknown__', label: '📍 위치 모름 / 직접 입력' });
appendOptions(allOptions, onLocationSelect);
}
function onLocationSelect(value) {
disableCurrentOptions();
if (value === '__unknown__') {
reportData.factory_category_id = null;
reportData.workplace_id = null;
reportData.workplace_name = null;
appendUser('위치 모름');
// 직접 입력
appendBot('위치를 직접 입력해주세요 (또는 "모름"이라고 입력):');
setInputDisabled(false);
textInput.placeholder = '위치 입력...';
sendBtn.onclick = () => {
const val = textInput.value.trim();
if (!val) return;
reportData.custom_location = val === '모름' ? null : val;
appendUser(val);
textInput.value = '';
textInput.style.height = 'auto';
textInput.placeholder = '메시지 입력...';
sendBtn.onclick = onSend;
setInputDisabled(true);
currentState = STATE.PROJECT;
showProjectSelection();
};
textInput.onkeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBtn.click(); }
};
return;
}
try {
const loc = JSON.parse(value);
reportData.factory_category_id = loc.fid;
reportData.factory_name = loc.fname;
reportData.workplace_id = loc.wid;
reportData.workplace_name = loc.wname;
reportData.custom_location = null;
appendUser(`${loc.fname} - ${loc.wname}`);
} catch (e) {
appendUser(value);
}
currentState = STATE.PROJECT;
showProjectSelection();
}
// ── PROJECT: 프로젝트 선택 ──
function showProjectSelection() {
appendBot('**프로젝트**를 선택해주세요:');
const options = [];
// TBM 세션에서 프로젝트 정보 (해당 작업장 우선)
const tbmProjectIds = new Set();
const relevantTbm = reportData.workplace_id
? refData.tbmSessions.filter(s => s.workplace_id === reportData.workplace_id)
: refData.tbmSessions;
relevantTbm.forEach(s => {
if (s.project_id) {
const proj = refData.projects.find(p => p.project_id === s.project_id);
if (proj && !tbmProjectIds.has(s.project_id)) {
tbmProjectIds.add(s.project_id);
const memberCount = s.team_member_count || s.member_count || 0;
options.push({
value: JSON.stringify({ pid: proj.project_id, pname: proj.project_name, sid: s.session_id }),
label: `🔨 ${proj.project_name} (TBM ${memberCount}명)`,
});
}
}
});
// 나머지 활성 프로젝트
refData.projects.forEach(p => {
if (!tbmProjectIds.has(p.project_id)) {
options.push({
value: JSON.stringify({ pid: p.project_id, pname: p.project_name, sid: null }),
label: p.project_name,
});
}
});
options.push({ value: '__unknown__', label: '프로젝트 모름 (건너뛰기)' });
appendOptions(options, onProjectSelect);
}
function onProjectSelect(value) {
disableCurrentOptions();
if (value === '__unknown__') {
reportData.project_id = null;
reportData.project_name = null;
reportData.tbm_session_id = null;
appendUser('프로젝트 모름');
} else {
try {
const proj = JSON.parse(value);
reportData.project_id = proj.pid;
reportData.project_name = proj.pname;
reportData.tbm_session_id = proj.sid;
appendUser(proj.pname);
} catch (e) {
appendUser(value);
}
}
currentState = STATE.CONFIRM;
showConfirmation();
}
// ── CONFIRM: 요약 확인 ──
async function showConfirmation() {
showTyping();
// AI 요약 호출
let summaryText = '';
try {
const typeLabel = { nonconformity: '부적합', facility: '시설설비', safety: '안전' }[reportData.issue_type] || '';
const res = await fetch(`${AI_API_BASE}/chatbot/summarize`, {
method: 'POST',
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
description: reportData.organized_description || reportData.description,
type: typeLabel,
category: reportData.issue_category_name || '',
item: reportData.issue_item_name || reportData.custom_item_name || '',
location: reportData.workplace_name
? `${reportData.factory_name || ''} - ${reportData.workplace_name}`
: (reportData.custom_location || '미지정'),
project: reportData.project_name || '미지정',
}),
});
if (res.ok) {
const d = await res.json();
summaryText = d.summary || '';
}
} catch (err) {
console.error('AI 요약 실패:', err);
}
hideTyping();
appendBot('신고 내용을 확인해주세요:');
// Summary card
const typeLabel = { nonconformity: '부적합', facility: '시설설비', safety: '안전' }[reportData.issue_type] || '';
const locationText = reportData.workplace_name
? `${reportData.factory_name || ''} - ${reportData.workplace_name}`
: (reportData.custom_location || '미지정');
const card = document.createElement('div');
card.className = 'summary-card';
card.innerHTML = `
<div class="summary-title">📋 신고 요약</div>
<div class="summary-row"><span class="summary-label">유형</span><span class="summary-value">${escapeHtml(typeLabel)}</span></div>
<div class="summary-row"><span class="summary-label">카테고리</span><span class="summary-value">${escapeHtml(reportData.issue_category_name || '-')}</span></div>
<div class="summary-row"><span class="summary-label">항목</span><span class="summary-value">${escapeHtml(reportData.issue_item_name || reportData.custom_item_name || '-')}</span></div>
<div class="summary-row"><span class="summary-label">위치</span><span class="summary-value">${escapeHtml(locationText)}</span></div>
<div class="summary-row"><span class="summary-label">프로젝트</span><span class="summary-value">${escapeHtml(reportData.project_name || '미지정')}</span></div>
<div class="summary-row"><span class="summary-label">내용</span><span class="summary-value">${escapeHtml(reportData.organized_description || reportData.description || '-')}</span></div>
${reportData.photos.length > 0 ? `<div class="summary-row"><span class="summary-label">사진</span><span class="summary-value">${reportData.photos.length}장 첨부</span></div>` : ''}
${summaryText ? `<div style="margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid #e2e8f0;font-size:0.8125rem;color:#64748b;">${escapeHtml(summaryText)}</div>` : ''}
`;
chatArea.appendChild(card);
// Actions
const actions = document.createElement('div');
actions.className = 'summary-actions';
actions.innerHTML = `
<button class="btn-submit" id="confirmSubmitBtn">✅ 제출하기</button>
<button class="btn-edit" id="confirmEditBtn">✏️ 처음부터</button>
`;
chatArea.appendChild(actions);
scrollToBottom();
document.getElementById('confirmSubmitBtn').addEventListener('click', () => {
actions.remove();
submitReport();
});
document.getElementById('confirmEditBtn').addEventListener('click', () => {
window.location.reload();
});
setInputDisabled(true);
}
// ── SUBMIT: 제출 ──
async function submitReport() {
currentState = STATE.SUBMIT;
appendUser('제출하기');
const loading = document.createElement('div');
loading.className = 'chat-loading';
loading.innerHTML = '<div class="chat-loading-inner">신고를 접수하고 있습니다...</div>';
document.body.appendChild(loading);
try {
const requestBody = {
factory_category_id: reportData.factory_category_id || null,
workplace_id: reportData.workplace_id || null,
custom_location: reportData.custom_location || null,
project_id: reportData.project_id || null,
tbm_session_id: reportData.tbm_session_id || null,
visit_request_id: null,
issue_category_id: reportData.issue_category_id,
issue_item_id: reportData.issue_item_id || null,
custom_item_name: reportData.custom_item_name || null,
additional_description: reportData.organized_description || reportData.description || null,
photos: reportData.photos,
};
const res = await fetch(`${API_BASE}/work-issues`, {
method: 'POST',
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
const data = await res.json();
loading.remove();
if (data.success) {
const isNonconformity = reportData.issue_type === 'nonconformity';
const typeLabel = { nonconformity: '부적합', facility: '시설설비', safety: '안전' }[reportData.issue_type] || '';
const destMsg = isNonconformity
? 'TKQC 수신함에서 확인하실 수 있습니다.'
: `${typeLabel} 신고 현황에서 확인하실 수 있습니다.`;
appendBot(`✅ **신고가 성공적으로 접수되었습니다!**\n\n접수된 신고는 ${destMsg}`);
const linkDiv = document.createElement('div');
linkDiv.className = 'summary-actions';
if (isNonconformity) {
linkDiv.innerHTML = `
<button class="btn-submit" onclick="window.location.href=(location.hostname.includes('technicalkorea.net') ? 'https://tkqc.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30280')">📋 TKQC 수신함</button>
<button class="btn-edit" onclick="window.location.reload()"> 새 신고</button>
`;
} else {
linkDiv.innerHTML = `
<button class="btn-submit" onclick="window.location.href='/pages/safety/report-status.html'">📋 신고 현황</button>
<button class="btn-edit" onclick="window.location.reload()"> 새 신고</button>
`;
}
chatArea.appendChild(linkDiv);
scrollToBottom();
} else {
throw new Error(data.error || '신고 등록 실패');
}
} catch (err) {
loading.remove();
console.error('신고 제출 실패:', err);
appendBot(`❌ 신고 접수에 실패했습니다: ${escapeHtml(err.message)}\n\n다시 시도해주세요.`);
const retryDiv = document.createElement('div');
retryDiv.className = 'summary-actions';
retryDiv.innerHTML = `
<button class="btn-submit" id="retrySubmitBtn">🔄 다시 시도</button>
<button class="btn-edit" onclick="window.location.reload()">처음부터</button>
`;
chatArea.appendChild(retryDiv);
document.getElementById('retrySubmitBtn').addEventListener('click', () => {
retryDiv.remove();
submitReport();
});
scrollToBottom();
}
}
// ── 사진 처리 ──
function onPhotoSelect(e) {
const file = e.target.files[0];
if (!file) return;
if (reportData.photos.length >= 5) return;
processPhoto(file);
e.target.value = '';
}
async function processPhoto(file) {
// 1단계: 브라우저가 직접 처리 (JPEG/PNG/WebP, iOS Safari HEIC)
try {
const dataUrl = await resizeImage(file, 1280, 0.8);
reportData.photos.push(dataUrl);
updatePhotoCount();
sendBtn.disabled = false;
return;
} catch (e) {
// 브라우저가 직접 처리 못하는 형식 (HEIC on Chrome 등)
}
// 2단계: 원본 파일을 base64로 읽어서 그대로 전송 (서버 sharp가 JPEG 변환)
const reader = new FileReader();
reader.onload = (ev) => {
reportData.photos.push(ev.target.result);
updatePhotoCount();
sendBtn.disabled = false;
};
reader.readAsDataURL(file);
}
/**
* 이미지 리사이징 (기존 issue-report.js와 동일 패턴)
*/
function resizeImage(file, maxSize, quality) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let w = img.width, h = img.height;
if (w > maxSize || h > maxSize) {
if (w > h) { h = Math.round(h * maxSize / w); w = maxSize; }
else { w = Math.round(w * maxSize / h); h = maxSize; }
}
const cvs = document.createElement('canvas');
cvs.width = w; cvs.height = h;
cvs.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(cvs.toDataURL('image/jpeg', quality));
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
function updatePhotoCount() {
const countEl = photoBtn.querySelector('.photo-count');
if (reportData.photos.length > 0) {
if (countEl) {
countEl.textContent = reportData.photos.length;
} else {
const badge = document.createElement('span');
badge.className = 'photo-count';
badge.textContent = reportData.photos.length;
photoBtn.appendChild(badge);
}
} else if (countEl) {
countEl.remove();
}
}
// ── UI 헬퍼 ──
function appendBot(text) {
const msg = document.createElement('div');
msg.className = 'chat-msg bot';
msg.innerHTML = `
<div class="chat-avatar">🤖</div>
<div class="chat-bubble">${formatMessage(text)}</div>
`;
chatArea.appendChild(msg);
scrollToBottom();
}
function appendUser(text, photos) {
const msg = document.createElement('div');
msg.className = 'chat-msg user';
let photoHtml = '';
if (photos && photos.length > 0) {
photoHtml = '<div class="chat-photos">' +
photos.map((p, i) => `<img src="${p}" alt="첨부 사진" onerror="this.style.display='none';this.insertAdjacentHTML('afterend','<div class=\\'photo-fallback\\'>📷 사진 ${i+1}</div>')">`).join('') +
'</div>';
}
msg.innerHTML = `
<div class="chat-avatar">👤</div>
<div class="chat-bubble">${escapeHtml(text || '')}${photoHtml}</div>
`;
chatArea.appendChild(msg);
scrollToBottom();
}
function appendOptions(options, callback) {
const container = document.createElement('div');
container.className = 'chat-options';
container.dataset.active = 'true';
options.forEach(opt => {
if (opt.isGroup) return; // Skip group headers
const btn = document.createElement('button');
btn.className = 'chat-option-btn' + (opt.suggested ? ' suggested' : '');
btn.textContent = opt.label;
btn.addEventListener('click', () => {
callback(opt.value);
});
container.appendChild(btn);
});
chatArea.appendChild(container);
scrollToBottom();
}
function disableCurrentOptions() {
chatArea.querySelectorAll('.chat-options[data-active="true"]').forEach(el => {
el.dataset.active = 'false';
el.querySelectorAll('.chat-option-btn').forEach(btn => {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.5';
});
});
}
let typingEl = null;
function showTyping() {
if (typingEl) return;
typingEl = document.createElement('div');
typingEl.className = 'chat-msg bot';
typingEl.innerHTML = `
<div class="chat-avatar">🤖</div>
<div class="chat-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>
`;
chatArea.appendChild(typingEl);
scrollToBottom();
}
function hideTyping() {
if (typingEl) { typingEl.remove(); typingEl = null; }
}
function setInputDisabled(disabled) {
const bar = document.querySelector('.chat-input-bar');
if (disabled) {
bar.classList.add('disabled');
} else {
bar.classList.remove('disabled');
textInput.focus();
}
}
function updateInputBar() {
setInputDisabled(currentState !== STATE.PHOTO_TEXT);
}
function scrollToBottom() {
requestAnimationFrame(() => {
chatArea.scrollTop = chatArea.scrollHeight;
});
}
function formatMessage(text) {
// Simple markdown: **bold**, \n → <br>
return text
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>');
}
function getHeaders() {
const token = window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token');
return { 'Authorization': token ? `Bearer ${token}` : '' };
}

View File

@@ -0,0 +1,42 @@
/**
* 크로스시스템 네비게이션 배너
* tkreport 페이지 상단에 시스템 간 이동 링크를 제공
*/
document.addEventListener('DOMContentLoaded', function() {
var path = window.location.pathname;
var host = window.location.hostname;
var protocol = window.location.protocol;
// tkqc URL 결정
var tkqcUrl;
if (host.includes('technicalkorea.net')) {
tkqcUrl = protocol + '//tkqc.technicalkorea.net';
} else {
tkqcUrl = protocol + '//' + host + ':30200';
}
// 현재 페이지 판별
var isIssueReport = path.includes('issue-report');
var isReportStatus = path.includes('report-status');
var isChatReport = path.includes('chat-report');
var nav = document.createElement('div');
nav.id = 'crossNav';
nav.innerHTML =
'<a href="/pages/safety/issue-report.html" class="cn-link' + (isIssueReport ? ' cn-active' : '') + '">신고하기</a>' +
'<a href="/pages/safety/report-status.html" class="cn-link' + (isReportStatus ? ' cn-active' : '') + '">신고현황</a>' +
'<a href="/pages/safety/chat-report.html" class="cn-link' + (isChatReport ? ' cn-active' : '') + '">AI 도우미</a>' +
'<a href="' + tkqcUrl + '" class="cn-link cn-external">부적합관리(TKQC) &rarr;</a>';
var style = document.createElement('style');
style.textContent =
'#crossNav{display:flex;align-items:center;gap:0.25rem;padding:0.5rem 0.75rem;background:#1e293b;overflow-x:auto;-webkit-overflow-scrolling:touch;}' +
'.cn-link{color:rgba(255,255,255,0.7);text-decoration:none;font-size:0.8125rem;font-weight:500;padding:0.25rem 0.625rem;border-radius:0.375rem;white-space:nowrap;transition:background 0.15s,color 0.15s;-webkit-tap-highlight-color:transparent;}' +
'.cn-link:hover,.cn-link:active{color:#fff;background:rgba(255,255,255,0.1);}' +
'.cn-active{color:#fff !important;background:rgba(255,255,255,0.15) !important;font-weight:600;}' +
'.cn-external{margin-left:auto;color:#38bdf8;border:1px solid rgba(56,189,248,0.3);font-size:0.75rem;}' +
'.cn-external:hover,.cn-external:active{background:rgba(56,189,248,0.15);color:#7dd3fc;}';
document.head.appendChild(style);
document.body.insertBefore(nav, document.body.firstChild);
});

View File

@@ -0,0 +1,421 @@
/**
* 신고 카테고리 관리 JavaScript
*/
import { API, getAuthHeaders } from '/js/api-config.js';
let currentType = 'nonconformity';
let categories = [];
let items = [];
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
await loadCategories();
});
/**
* 유형 탭 전환
*/
window.switchType = async function(type) {
currentType = type;
// 탭 상태 업데이트
document.querySelectorAll('.type-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.type === type);
});
await loadCategories();
};
/**
* 카테고리 로드
*/
async function loadCategories() {
const container = document.getElementById('categoryList');
container.innerHTML = '<div class="empty-state">카테고리를 불러오는 중...</div>';
try {
const response = await fetch(`${API}/work-issues/categories/type/${currentType}`, {
headers: getAuthHeaders()
});
if (!response.ok) throw new Error('카테고리 조회 실패');
const data = await response.json();
if (data.success && data.data) {
categories = data.data;
// 항목도 로드
const itemsResponse = await fetch(`${API}/work-issues/items`, {
headers: getAuthHeaders()
});
if (itemsResponse.ok) {
const itemsData = await itemsResponse.json();
if (itemsData.success) {
items = itemsData.data || [];
}
}
renderCategories();
} else {
container.innerHTML = '<div class="empty-state">카테고리가 없습니다.</div>';
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
container.innerHTML = '<div class="empty-state">카테고리를 불러오지 못했습니다.</div>';
}
}
/**
* 카테고리 렌더링
*/
function renderCategories() {
const container = document.getElementById('categoryList');
if (categories.length === 0) {
container.innerHTML = '<div class="empty-state">등록된 카테고리가 없습니다.</div>';
return;
}
const severityLabel = {
low: '낮음',
medium: '보통',
high: '높음',
critical: '심각'
};
container.innerHTML = categories.map(cat => {
const catItems = items.filter(item => item.category_id === cat.category_id);
return `
<div class="category-section" data-category-id="${cat.category_id}">
<div class="category-header" onclick="toggleCategory(${cat.category_id})">
<div class="category-name">${cat.category_name}</div>
<div class="category-badge">
<span class="severity-badge ${cat.severity || 'medium'}">${severityLabel[cat.severity] || '보통'}</span>
<span class="item-count">${catItems.length}개 항목</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openCategoryModal(${cat.category_id})">수정</button>
</div>
</div>
<div class="category-items">
<div class="item-list">
${catItems.length > 0 ? catItems.map(item => `
<div class="item-card">
<div class="item-info">
<div class="item-name">${item.item_name}</div>
${item.description ? `<div class="item-desc">${item.description}</div>` : ''}
</div>
<div class="item-actions">
<span class="severity-badge ${item.severity || 'medium'}">${severityLabel[item.severity] || '보통'}</span>
<button class="btn btn-secondary btn-sm" onclick="openItemModal(${cat.category_id}, ${item.item_id})">수정</button>
</div>
</div>
`).join('') : '<div class="empty-state" style="padding: 24px;">등록된 항목이 없습니다.</div>'}
</div>
<div class="add-item-form">
<input type="text" id="newItemName_${cat.category_id}" placeholder="새 항목 이름">
<button class="btn btn-primary btn-sm" onclick="quickAddItem(${cat.category_id})">추가</button>
<button class="btn btn-secondary btn-sm" onclick="openItemModal(${cat.category_id})">상세 추가</button>
</div>
</div>
</div>
`;
}).join('');
}
/**
* 카테고리 토글
*/
window.toggleCategory = function(categoryId) {
const section = document.querySelector(`.category-section[data-category-id="${categoryId}"]`);
if (section) {
section.classList.toggle('expanded');
}
};
/**
* 카테고리 모달 열기
*/
window.openCategoryModal = function(categoryId = null) {
const modal = document.getElementById('categoryModal');
const title = document.getElementById('categoryModalTitle');
const deleteBtn = document.getElementById('deleteCategoryBtn');
document.getElementById('categoryId').value = '';
document.getElementById('categoryName').value = '';
document.getElementById('categoryDescription').value = '';
document.getElementById('categorySeverity').value = 'medium';
if (categoryId) {
const category = categories.find(c => c.category_id === categoryId);
if (category) {
title.textContent = '카테고리 수정';
document.getElementById('categoryId').value = category.category_id;
document.getElementById('categoryName').value = category.category_name;
document.getElementById('categoryDescription').value = category.description || '';
document.getElementById('categorySeverity').value = category.severity || 'medium';
deleteBtn.style.display = 'block';
}
} else {
title.textContent = '새 카테고리';
deleteBtn.style.display = 'none';
}
modal.style.display = 'flex';
};
/**
* 카테고리 모달 닫기
*/
window.closeCategoryModal = function() {
document.getElementById('categoryModal').style.display = 'none';
};
/**
* 카테고리 저장
*/
window.saveCategory = async function() {
const categoryId = document.getElementById('categoryId').value;
const name = document.getElementById('categoryName').value.trim();
const description = document.getElementById('categoryDescription').value.trim();
const severity = document.getElementById('categorySeverity').value;
if (!name) {
alert('카테고리 이름을 입력하세요.');
return;
}
try {
const url = categoryId
? `${API}/work-issues/categories/${categoryId}`
: `${API}/work-issues/categories`;
const response = await fetch(url, {
method: categoryId ? 'PUT' : 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_name: name,
category_type: currentType,
description,
severity
})
});
const data = await response.json();
if (response.ok && data.success) {
alert(categoryId ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.');
closeCategoryModal();
await loadCategories();
} else {
throw new Error(data.error || '저장 실패');
}
} catch (error) {
console.error('카테고리 저장 실패:', error);
alert('카테고리 저장에 실패했습니다: ' + error.message);
}
};
/**
* 카테고리 삭제
*/
window.deleteCategory = async function() {
const categoryId = document.getElementById('categoryId').value;
if (!categoryId) return;
const catItems = items.filter(item => item.category_id == categoryId);
if (catItems.length > 0) {
alert(`이 카테고리에 ${catItems.length}개의 항목이 있습니다. 먼저 항목을 삭제하세요.`);
return;
}
if (!confirm('이 카테고리를 삭제하시겠습니까?')) return;
try {
const response = await fetch(`${API}/work-issues/categories/${categoryId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await response.json();
if (response.ok && data.success) {
alert('카테고리가 삭제되었습니다.');
closeCategoryModal();
await loadCategories();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
console.error('카테고리 삭제 실패:', error);
alert('카테고리 삭제에 실패했습니다: ' + error.message);
}
};
/**
* 항목 모달 열기
*/
window.openItemModal = function(categoryId, itemId = null) {
const modal = document.getElementById('itemModal');
const title = document.getElementById('itemModalTitle');
const deleteBtn = document.getElementById('deleteItemBtn');
document.getElementById('itemId').value = '';
document.getElementById('itemCategoryId').value = categoryId;
document.getElementById('itemName').value = '';
document.getElementById('itemDescription').value = '';
document.getElementById('itemSeverity').value = 'medium';
if (itemId) {
const item = items.find(i => i.item_id === itemId);
if (item) {
title.textContent = '항목 수정';
document.getElementById('itemId').value = item.item_id;
document.getElementById('itemName').value = item.item_name;
document.getElementById('itemDescription').value = item.description || '';
document.getElementById('itemSeverity').value = item.severity || 'medium';
deleteBtn.style.display = 'block';
}
} else {
const category = categories.find(c => c.category_id === categoryId);
title.textContent = `새 항목 (${category?.category_name || ''})`;
deleteBtn.style.display = 'none';
}
modal.style.display = 'flex';
};
/**
* 항목 모달 닫기
*/
window.closeItemModal = function() {
document.getElementById('itemModal').style.display = 'none';
};
/**
* 항목 저장
*/
window.saveItem = async function() {
const itemId = document.getElementById('itemId').value;
const categoryId = document.getElementById('itemCategoryId').value;
const name = document.getElementById('itemName').value.trim();
const description = document.getElementById('itemDescription').value.trim();
const severity = document.getElementById('itemSeverity').value;
if (!name) {
alert('항목 이름을 입력하세요.');
return;
}
try {
const url = itemId
? `${API}/work-issues/items/${itemId}`
: `${API}/work-issues/items`;
const response = await fetch(url, {
method: itemId ? 'PUT' : 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_id: categoryId,
item_name: name,
description,
severity
})
});
const data = await response.json();
if (response.ok && data.success) {
alert(itemId ? '항목이 수정되었습니다.' : '항목이 추가되었습니다.');
closeItemModal();
await loadCategories();
} else {
throw new Error(data.error || '저장 실패');
}
} catch (error) {
console.error('항목 저장 실패:', error);
alert('항목 저장에 실패했습니다: ' + error.message);
}
};
/**
* 항목 삭제
*/
window.deleteItem = async function() {
const itemId = document.getElementById('itemId').value;
if (!itemId) return;
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
try {
const response = await fetch(`${API}/work-issues/items/${itemId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await response.json();
if (response.ok && data.success) {
alert('항목이 삭제되었습니다.');
closeItemModal();
await loadCategories();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
console.error('항목 삭제 실패:', error);
alert('항목 삭제에 실패했습니다: ' + error.message);
}
};
/**
* 빠른 항목 추가
*/
window.quickAddItem = async function(categoryId) {
const input = document.getElementById(`newItemName_${categoryId}`);
const name = input.value.trim();
if (!name) {
alert('항목 이름을 입력하세요.');
input.focus();
return;
}
try {
const response = await fetch(`${API}/work-issues/items`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_id: categoryId,
item_name: name,
severity: 'medium'
})
});
const data = await response.json();
if (response.ok && data.success) {
input.value = '';
await loadCategories();
// 카테고리 펼침 유지
toggleCategory(categoryId);
} else {
throw new Error(data.error || '추가 실패');
}
} catch (error) {
console.error('항목 추가 실패:', error);
alert('항목 추가에 실패했습니다: ' + error.message);
}
};

View File

@@ -0,0 +1,761 @@
/**
* 신고 상세 페이지 JavaScript
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
let reportId = null;
let reportData = null;
let currentUser = null;
// 상태 한글명
const statusNames = {
reported: '신고',
received: '접수',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
// 유형 한글명
const typeNames = {
nonconformity: '부적합',
safety: '안전',
facility: '시설설비'
};
// 심각도 한글명
const severityNames = {
critical: '심각',
high: '높음',
medium: '보통',
low: '낮음'
};
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
// URL에서 ID 가져오기
const urlParams = new URLSearchParams(window.location.search);
reportId = urlParams.get('id');
if (!reportId) {
alert('신고 ID가 없습니다.');
goBackToList();
return;
}
// 현재 사용자 정보 로드
await loadCurrentUser();
// 상세 데이터 로드
await loadReportDetail();
});
/**
* 현재 사용자 정보 로드
*/
async function loadCurrentUser() {
try {
const response = await fetch(`${API_BASE}/users/me`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (response.ok) {
const data = await response.json();
currentUser = data.data;
}
} catch (error) {
console.error('사용자 정보 로드 실패:', error);
}
}
/**
* 신고 상세 로드
*/
async function loadReportDetail() {
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) {
throw new Error('신고를 찾을 수 없습니다.');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || '데이터 조회 실패');
}
reportData = data.data;
renderDetail();
await loadStatusLogs();
} catch (error) {
console.error('상세 로드 실패:', error);
alert(error.message);
goBackToList();
}
}
/**
* 상세 정보 렌더링
*/
function renderDetail() {
const d = reportData;
// 헤더
document.getElementById('reportId').textContent = `#${d.report_id}`;
document.getElementById('reportTitle').textContent = d.issue_item_name || d.issue_category_name || '신고';
// 상태 배지
const statusBadge = document.getElementById('statusBadge');
statusBadge.className = `status-badge ${d.status}`;
statusBadge.textContent = statusNames[d.status] || d.status;
// 기본 정보
renderBasicInfo(d);
// 신고 내용
renderIssueContent(d);
// 사진
renderPhotos(d);
// 처리 정보
renderProcessInfo(d);
// 액션 버튼
renderActionButtons(d);
}
/**
* 기본 정보 렌더링
*/
function renderBasicInfo(d) {
const container = document.getElementById('basicInfo');
const formatDate = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const validTypes = ['nonconformity', 'safety', 'facility'];
const safeType = validTypes.includes(d.category_type) ? d.category_type : '';
const reporterName = escapeHtml(d.reporter_full_name || d.reporter_name || '-');
const locationText = escapeHtml(d.custom_location || d.workplace_name || '-');
const factoryText = d.factory_name ? ` (${escapeHtml(d.factory_name)})` : '';
container.innerHTML = `
<div class="info-item">
<div class="info-label">신고 유형</div>
<div class="info-value">
<span class="type-badge ${safeType}">${typeNames[d.category_type] || escapeHtml(d.category_type || '-')}</span>
</div>
</div>
<div class="info-item">
<div class="info-label">신고일시</div>
<div class="info-value">${formatDate(d.report_date)}</div>
</div>
<div class="info-item">
<div class="info-label">신고자</div>
<div class="info-value">${reporterName}</div>
</div>
<div class="info-item">
<div class="info-label">위치</div>
<div class="info-value">${locationText}${factoryText}</div>
</div>
`;
}
/**
* 신고 내용 렌더링
*/
function renderIssueContent(d) {
const container = document.getElementById('issueContent');
const validSeverities = ['critical', 'high', 'medium', 'low'];
const safeSeverity = validSeverities.includes(d.severity) ? d.severity : '';
let html = `
<div class="info-grid" style="margin-bottom: 1rem;">
<div class="info-item">
<div class="info-label">카테고리</div>
<div class="info-value">${escapeHtml(d.issue_category_name || '-')}</div>
</div>
<div class="info-item">
<div class="info-label">항목</div>
<div class="info-value">
${escapeHtml(d.issue_item_name || '-')}
${d.severity ? `<span class="severity-badge ${safeSeverity}">${severityNames[d.severity] || escapeHtml(d.severity)}</span>` : ''}
</div>
</div>
</div>
`;
if (d.additional_description) {
html += `
<div style="padding: 1rem; background: #f9fafb; border-radius: 0.5rem; white-space: pre-wrap; line-height: 1.6;">
${escapeHtml(d.additional_description)}
</div>
`;
}
container.innerHTML = html;
}
/**
* 사진 렌더링
*/
function renderPhotos(d) {
const section = document.getElementById('photoSection');
const gallery = document.getElementById('photoGallery');
const photos = [d.photo_path1, d.photo_path2, d.photo_path3, d.photo_path4, d.photo_path5].filter(Boolean);
if (photos.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
const baseUrl = (API_BASE).replace('/api', '');
gallery.innerHTML = photos.map(photo => {
const fullUrl = photo.startsWith('http') ? photo : `${baseUrl}${photo}`;
return `
<div class="photo-item" data-url="${escapeHtml(fullUrl)}">
<img src="${escapeHtml(fullUrl)}" alt="첨부 사진">
</div>
`;
}).join('');
gallery.querySelectorAll('.photo-item[data-url]').forEach(el => {
el.addEventListener('click', () => openPhotoModal(el.dataset.url));
});
}
/**
* 처리 정보 렌더링
*/
function renderProcessInfo(d) {
const section = document.getElementById('processSection');
const container = document.getElementById('processInfo');
// 담당자 배정 또는 처리 정보가 있는 경우만 표시
if (!d.assigned_user_id && !d.resolution_notes) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
const formatDate = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
let html = '<div class="info-grid">';
if (d.assigned_user_id) {
html += `
<div class="info-item">
<div class="info-label">담당자</div>
<div class="info-value">${escapeHtml(d.assigned_full_name || d.assigned_user_name || '-')}</div>
</div>
<div class="info-item">
<div class="info-label">담당 부서</div>
<div class="info-value">${escapeHtml(d.assigned_department || '-')}</div>
</div>
`;
}
if (d.resolved_at) {
html += `
<div class="info-item">
<div class="info-label">처리 완료일</div>
<div class="info-value">${formatDate(d.resolved_at)}</div>
</div>
<div class="info-item">
<div class="info-label">처리자</div>
<div class="info-value">${escapeHtml(d.resolved_by_name || '-')}</div>
</div>
`;
}
html += '</div>';
if (d.resolution_notes) {
html += `
<div style="margin-top: 1rem; padding: 1rem; background: #ecfdf5; border-radius: 0.5rem; border: 1px solid #a7f3d0;">
<div style="font-weight: 600; margin-bottom: 0.5rem; color: #047857;">처리 내용</div>
<div style="white-space: pre-wrap; line-height: 1.6;">${escapeHtml(d.resolution_notes)}</div>
</div>
`;
}
container.innerHTML = html;
}
/**
* 액션 버튼 렌더링
*/
function renderActionButtons(d) {
const container = document.getElementById('actionButtons');
if (!currentUser) {
container.innerHTML = '';
return;
}
const isAdmin = ['admin', 'system', 'support_team'].includes(currentUser.access_level);
const isOwner = d.reporter_id === currentUser.user_id;
const isAssignee = d.assigned_user_id === currentUser.user_id;
let buttons = [];
// 관리자 권한 버튼
if (isAdmin) {
if (d.status === 'reported') {
buttons.push(`<button class="action-btn primary" onclick="receiveReport()">접수하기</button>`);
}
if (d.status === 'received' || d.status === 'in_progress') {
buttons.push(`<button class="action-btn" onclick="openAssignModal()">담당자 배정</button>`);
}
if (d.status === 'received') {
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
}
if (d.status === 'in_progress') {
buttons.push(`<button class="action-btn success" onclick="openCompleteModal()">처리 완료</button>`);
}
if (d.status === 'completed') {
buttons.push(`<button class="action-btn" onclick="closeReport()">종료</button>`);
}
}
// 담당자 버튼
if (isAssignee && !isAdmin) {
if (d.status === 'received') {
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
}
if (d.status === 'in_progress') {
buttons.push(`<button class="action-btn success" onclick="openCompleteModal()">처리 완료</button>`);
}
}
// 유형 이관 버튼 (admin/support_team/담당자, closed 아닐 때)
if ((isAdmin || isAssignee) && d.status !== 'closed') {
buttons.push(`<button class="action-btn" onclick="openTransferModal()">유형 이관</button>`);
}
// 신고자 버튼 (수정/삭제는 reported 상태에서만)
if (isOwner && d.status === 'reported') {
buttons.push(`<button class="action-btn danger" onclick="deleteReport()">삭제</button>`);
}
container.innerHTML = buttons.join('');
}
/**
* 상태 변경 이력 로드
*/
async function loadStatusLogs() {
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/status-logs`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) return;
const data = await response.json();
if (data.success && data.data) {
renderStatusTimeline(data.data);
}
} catch (error) {
console.error('상태 이력 로드 실패:', error);
}
}
/**
* 상태 타임라인 렌더링
*/
function renderStatusTimeline(logs) {
const container = document.getElementById('statusTimeline');
if (!logs || logs.length === 0) {
container.innerHTML = '<p style="color: #6b7280;">상태 변경 이력이 없습니다.</p>';
return;
}
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
container.innerHTML = logs.map(log => `
<div class="timeline-item">
<div class="timeline-status">
${log.previous_status ? `${statusNames[log.previous_status] || escapeHtml(log.previous_status)}` : ''}${statusNames[log.new_status] || escapeHtml(log.new_status)}
</div>
<div class="timeline-meta">
${escapeHtml(log.changed_by_full_name || log.changed_by_name || '-')} | ${formatDate(log.changed_at)}
${log.change_reason ? `<br><small>${escapeHtml(log.change_reason)}</small>` : ''}
</div>
</div>
`).join('');
}
// ==================== 액션 함수 ====================
/**
* 신고 접수
*/
async function receiveReport() {
if (!confirm('이 신고를 접수하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/receive`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
const data = await response.json();
if (data.success) {
alert('신고가 접수되었습니다.');
location.reload();
} else {
throw new Error(data.error || '접수 실패');
}
} catch (error) {
alert('접수 실패: ' + error.message);
}
}
/**
* 처리 시작
*/
async function startProcessing() {
if (!confirm('처리를 시작하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/start`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
const data = await response.json();
if (data.success) {
alert('처리가 시작되었습니다.');
location.reload();
} else {
throw new Error(data.error || '처리 시작 실패');
}
} catch (error) {
alert('처리 시작 실패: ' + error.message);
}
}
/**
* 신고 종료
*/
async function closeReport() {
if (!confirm('이 신고를 종료하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/close`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
const data = await response.json();
if (data.success) {
alert('신고가 종료되었습니다.');
location.reload();
} else {
throw new Error(data.error || '종료 실패');
}
} catch (error) {
alert('종료 실패: ' + error.message);
}
}
/**
* 신고 삭제
*/
async function deleteReport() {
if (!confirm('정말 이 신고를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
const data = await response.json();
if (data.success) {
alert('신고가 삭제되었습니다.');
goBackToList();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
alert('삭제 실패: ' + error.message);
}
}
// ==================== 담당자 배정 모달 ====================
async function openAssignModal() {
// 사용자 목록 로드
try {
const response = await fetch(`${API_BASE}/users`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (response.ok) {
const data = await response.json();
const select = document.getElementById('assignUser');
select.innerHTML = '<option value="">담당자 선택</option>';
if (data.success && data.data) {
data.data.forEach(user => {
const safeUserId = parseInt(user.user_id) || 0;
select.innerHTML += `<option value="${safeUserId}">${escapeHtml(user.name || '-')} (${escapeHtml(user.username || '-')})</option>`;
});
}
}
} catch (error) {
console.error('사용자 목록 로드 실패:', error);
}
document.getElementById('assignModal').classList.add('visible');
}
function closeAssignModal() {
document.getElementById('assignModal').classList.remove('visible');
}
async function submitAssign() {
const department = document.getElementById('assignDepartment').value;
const userId = document.getElementById('assignUser').value;
if (!userId) {
alert('담당자를 선택해주세요.');
return;
}
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/assign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}`
},
body: JSON.stringify({
assigned_department: department,
assigned_user_id: parseInt(userId)
})
});
const data = await response.json();
if (data.success) {
alert('담당자가 배정되었습니다.');
closeAssignModal();
location.reload();
} else {
throw new Error(data.error || '배정 실패');
}
} catch (error) {
alert('담당자 배정 실패: ' + error.message);
}
}
// ==================== 처리 완료 모달 ====================
function openCompleteModal() {
document.getElementById('completeModal').classList.add('visible');
}
function closeCompleteModal() {
document.getElementById('completeModal').classList.remove('visible');
}
async function submitComplete() {
const notes = document.getElementById('resolutionNotes').value;
if (!notes.trim()) {
alert('처리 내용을 입력해주세요.');
return;
}
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}`
},
body: JSON.stringify({
resolution_notes: notes
})
});
const data = await response.json();
if (data.success) {
alert('처리가 완료되었습니다.');
closeCompleteModal();
location.reload();
} else {
throw new Error(data.error || '완료 처리 실패');
}
} catch (error) {
alert('처리 완료 실패: ' + error.message);
}
}
// ==================== 유형 이관 모달 ====================
function openTransferModal() {
const select = document.getElementById('transferCategoryType');
// 현재 유형은 선택 불가 처리
for (const option of select.options) {
option.disabled = (option.value === reportData.category_type);
}
select.value = '';
document.getElementById('transferModal').classList.add('visible');
}
function closeTransferModal() {
document.getElementById('transferModal').classList.remove('visible');
}
async function submitTransfer() {
const newType = document.getElementById('transferCategoryType').value;
if (!newType) {
alert('이관할 유형을 선택해주세요.');
return;
}
if (newType === reportData.category_type) {
alert('현재 유형과 동일합니다.');
return;
}
const typeName = typeNames[newType] || newType;
if (!confirm(`이 신고를 "${typeName}" 유형으로 이관하시겠습니까?`)) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/transfer`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}`
},
body: JSON.stringify({ category_type: newType })
});
const data = await response.json();
if (data.success) {
alert('유형이 이관되었습니다.');
closeTransferModal();
location.reload();
} else {
throw new Error(data.error || '이관 실패');
}
} catch (error) {
alert('유형 이관 실패: ' + error.message);
}
}
// ==================== 사진 모달 ====================
function openPhotoModal(src) {
document.getElementById('photoModalImg').src = src;
document.getElementById('photoModal').classList.add('visible');
}
function closePhotoModal() {
document.getElementById('photoModal').classList.remove('visible');
}
// ==================== 유틸리티 ====================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 목록으로 돌아가기
*/
function goBackToList() {
const urlParams = new URLSearchParams(window.location.search);
const from = urlParams.get('from');
if (from === 'nonconformity') {
window.location.href = '/pages/work/nonconformity.html';
} else if (from === 'safety') {
window.location.href = '/pages/safety/report-status.html';
} else if (from === 'my-reports') {
window.location.href = '/pages/safety/my-reports.html';
} else {
if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = '/pages/safety/my-reports.html';
}
}
}
// 전역 함수 노출
window.goBackToList = goBackToList;
window.receiveReport = receiveReport;
window.startProcessing = startProcessing;
window.closeReport = closeReport;
window.deleteReport = deleteReport;
window.openAssignModal = openAssignModal;
window.closeAssignModal = closeAssignModal;
window.submitAssign = submitAssign;
window.openCompleteModal = openCompleteModal;
window.closeCompleteModal = closeCompleteModal;
window.submitComplete = submitComplete;
window.openTransferModal = openTransferModal;
window.closeTransferModal = closeTransferModal;
window.submitTransfer = submitTransfer;
window.openPhotoModal = openPhotoModal;
window.closePhotoModal = closePhotoModal;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
/**
* 안전신고 현황 페이지 JavaScript
* category_type=safety 고정 필터
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
const CATEGORY_TYPE = 'safety';
// 상태 한글 변환
const STATUS_LABELS = {
reported: '신고',
received: '접수',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
// DOM 요소
let issueList;
let filterStatus, filterStartDate, filterEndDate;
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
issueList = document.getElementById('issueList');
filterStatus = document.getElementById('filterStatus');
filterStartDate = document.getElementById('filterStartDate');
filterEndDate = document.getElementById('filterEndDate');
// 필터 이벤트 리스너
filterStatus.addEventListener('change', loadIssues);
filterStartDate.addEventListener('change', loadIssues);
filterEndDate.addEventListener('change', loadIssues);
// 데이터 로드
await Promise.all([loadStats(), loadIssues()]);
});
/**
* 통계 로드 (안전만)
*/
async function loadStats() {
try {
const response = await fetch(`${API_BASE}/work-issues/stats/summary?category_type=${CATEGORY_TYPE}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) {
document.getElementById('statsGrid').style.display = 'none';
return;
}
const data = await response.json();
if (data.success && data.data) {
document.getElementById('statReported').textContent = data.data.reported || 0;
document.getElementById('statReceived').textContent = data.data.received || 0;
document.getElementById('statProgress').textContent = data.data.in_progress || 0;
document.getElementById('statCompleted').textContent = data.data.completed || 0;
}
} catch (error) {
console.error('통계 로드 실패:', error);
document.getElementById('statsGrid').style.display = 'none';
}
}
/**
* 안전신고 목록 로드
*/
async function loadIssues() {
try {
// 필터 파라미터 구성 (category_type 고정)
const params = new URLSearchParams();
params.append('category_type', CATEGORY_TYPE);
if (filterStatus.value) params.append('status', filterStatus.value);
if (filterStartDate.value) params.append('start_date', filterStartDate.value);
if (filterEndDate.value) params.append('end_date', filterEndDate.value);
const response = await fetch(`${API_BASE}/work-issues?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) throw new Error('목록 조회 실패');
const data = await response.json();
if (data.success) {
renderIssues(data.data || []);
}
} catch (error) {
console.error('안전신고 목록 로드 실패:', error);
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">목록을 불러올 수 없습니다</div>
<p>잠시 후 다시 시도해주세요.</p>
</div>
`;
}
}
/**
* 안전신고 목록 렌더링
*/
function renderIssues(issues) {
if (issues.length === 0) {
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">등록된 안전 신고가 없습니다</div>
<p>새로운 안전 문제를 신고하려면 '안전 신고' 버튼을 클릭하세요.</p>
</div>
`;
return;
}
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
issueList.innerHTML = issues.map(issue => {
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// 위치 정보 (escaped)
let location = escapeHtml(issue.custom_location || '');
if (issue.factory_name) {
location = escapeHtml(issue.factory_name);
if (issue.workplace_name) {
location += ` - ${escapeHtml(issue.workplace_name)}`;
}
}
// 신고 제목 (항목명 또는 카테고리명)
const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '안전 신고');
const categoryName = escapeHtml(issue.issue_category_name || '안전');
// 사진 목록
const photos = [
issue.photo_path1,
issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(Boolean);
// 안전한 값들
const safeReportId = parseInt(issue.report_id) || 0;
const validStatuses = ['reported', 'received', 'in_progress', 'completed', 'closed'];
const safeStatus = validStatuses.includes(issue.status) ? issue.status : 'reported';
const reporterName = escapeHtml(issue.reporter_full_name || issue.reporter_name || '-');
const assignedName = issue.assigned_full_name ? escapeHtml(issue.assigned_full_name) : '';
return `
<div class="issue-card" onclick="viewIssue(${safeReportId})">
<div class="issue-header">
<span class="issue-id">#${safeReportId}</span>
<span class="issue-status ${safeStatus}">${STATUS_LABELS[issue.status] || escapeHtml(issue.status || '-')}</span>
</div>
<div class="issue-title">
<span class="issue-category-badge">${categoryName}</span>
${title}
</div>
<div class="issue-meta">
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
${reporterName}
</span>
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
${reportDate}
</span>
${location ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
${location}
</span>
` : ''}
${assignedName ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
담당: ${assignedName}
</span>
` : ''}
</div>
${photos.length > 0 ? `
<div class="issue-photos">
${photos.slice(0, 3).map(p => `
<img src="${baseUrl}${encodeURI(p)}" alt="신고 사진" loading="lazy">
`).join('')}
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
</div>
` : ''}
</div>
`;
}).join('');
}
/**
* 상세 보기
*/
function viewIssue(reportId) {
window.location.href = `/pages/safety/issue-detail.html?id=${reportId}&from=safety`;
}

View File

@@ -0,0 +1,39 @@
/**
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
*
* Canonical source: shared/frontend/sso-relay.js
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
* system1-factory/web/js/sso-relay.js
* system2-report/web/js/sso-relay.js
* system3-nonconformance/web/static/js/sso-relay.js
* user-management/web/static/js/sso-relay.js
* tkpurchase/web/static/js/sso-relay.js
* tksafety/web/static/js/sso-relay.js
* tksupport/web/static/js/sso-relay.js
*
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
*/
(function() {
var hash = location.hash;
if (!hash || hash.indexOf('_sso=') === -1) return;
var match = hash.match(/[#&]_sso=([^&]*)/);
if (!match) return;
var token = decodeURIComponent(match[1]);
if (!token) return;
// 로컬(1st-party) 쿠키 설정
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
// localStorage 폴백
try { localStorage.setItem('sso_token', token); } catch (e) {}
// URL에서 hash 제거
history.replaceState(null, '', location.pathname + location.search);
})();

View File

@@ -0,0 +1,835 @@
/**
* 문제 신고 등록 페이지 JavaScript
*/
// API 설정
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
// 상태 변수
let selectedFactoryId = null;
let selectedWorkplaceId = null;
let selectedWorkplaceName = null;
let selectedType = null; // 'nonconformity' | 'safety' | 'facility'
let selectedCategoryId = null;
let selectedCategoryName = null;
let selectedItemId = null;
let customItemName = null;
let selectedTbmSessionId = null;
let selectedVisitRequestId = null;
let photos = [null, null, null, null, null];
// 지도 관련 변수
let canvas, ctx, canvasImage;
let mapRegions = [];
let todayWorkers = [];
let todayVisitors = [];
// DOM 요소
let factorySelect, issueMapCanvas;
let photoInput, currentPhotoIndex;
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
factorySelect = document.getElementById('factorySelect');
issueMapCanvas = document.getElementById('issueMapCanvas');
photoInput = document.getElementById('photoInput');
canvas = issueMapCanvas;
ctx = canvas.getContext('2d');
// 이벤트 리스너 설정
setupEventListeners();
// 공장 목록 로드
await loadFactories();
});
/**
* 이벤트 리스너 설정
*/
function setupEventListeners() {
// 공장 선택
factorySelect.addEventListener('change', onFactoryChange);
// 지도 클릭
canvas.addEventListener('click', onMapClick);
// 기타 위치 토글
document.getElementById('useCustomLocation').addEventListener('change', (e) => {
const customInput = document.getElementById('customLocationInput');
customInput.classList.toggle('visible', e.target.checked);
if (e.target.checked) {
// 지도 선택 초기화
selectedWorkplaceId = null;
selectedWorkplaceName = null;
selectedTbmSessionId = null;
selectedVisitRequestId = null;
updateLocationInfo();
}
});
// 유형 버튼 클릭
document.querySelectorAll('.type-btn').forEach(btn => {
btn.addEventListener('click', () => onTypeSelect(btn.dataset.type));
});
// 사진 슬롯 클릭
document.querySelectorAll('.photo-slot').forEach(slot => {
slot.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-btn')) return;
currentPhotoIndex = parseInt(slot.dataset.index);
photoInput.click();
});
});
// 사진 삭제 버튼
document.querySelectorAll('.photo-slot .remove-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const slot = btn.closest('.photo-slot');
const index = parseInt(slot.dataset.index);
removePhoto(index);
});
});
// 사진 선택
photoInput.addEventListener('change', onPhotoSelect);
}
/**
* 공장 목록 로드
*/
async function loadFactories() {
try {
const response = await fetch(`${API_BASE}/workplaces/categories/active/list`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) throw new Error('공장 목록 조회 실패');
const data = await response.json();
if (data.success && data.data) {
data.data.forEach(factory => {
const option = document.createElement('option');
option.value = factory.category_id;
option.textContent = factory.category_name;
factorySelect.appendChild(option);
});
// 첫 번째 공장 자동 선택
if (data.data.length > 0) {
factorySelect.value = data.data[0].category_id;
onFactoryChange();
}
}
} catch (error) {
console.error('공장 목록 로드 실패:', error);
}
}
/**
* 공장 변경 시
*/
async function onFactoryChange() {
selectedFactoryId = factorySelect.value;
if (!selectedFactoryId) return;
// 위치 선택 초기화
selectedWorkplaceId = null;
selectedWorkplaceName = null;
selectedTbmSessionId = null;
selectedVisitRequestId = null;
updateLocationInfo();
// 지도 데이터 로드
await Promise.all([
loadMapImage(),
loadMapRegions(),
loadTodayData()
]);
renderMap();
}
/**
* 배치도 이미지 로드
*/
async function loadMapImage() {
try {
const response = await fetch(`${API_BASE}/workplaces/categories`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) return;
const data = await response.json();
if (data.success && data.data) {
const selectedCategory = data.data.find(c => c.category_id == selectedFactoryId);
if (selectedCategory && selectedCategory.layout_image) {
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
? selectedCategory.layout_image
: selectedCategory.layout_image;
canvasImage = new Image();
canvasImage.onload = () => renderMap();
canvasImage.src = fullImageUrl;
}
}
} catch (error) {
console.error('배치도 이미지 로드 실패:', error);
}
}
/**
* 지도 영역 로드
*/
async function loadMapRegions() {
try {
const response = await fetch(`${API_BASE}/workplaces/categories/${selectedFactoryId}/map-regions`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) return;
const data = await response.json();
if (data.success) {
mapRegions = data.data || [];
}
} catch (error) {
console.error('지도 영역 로드 실패:', error);
}
}
/**
* 오늘 TBM/출입신청 데이터 로드
*/
async function loadTodayData() {
const today = new Date().toISOString().split('T')[0];
try {
// TBM 세션 로드
const tbmResponse = await fetch(`${API_BASE}/tbm/sessions/date/${today}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (tbmResponse.ok) {
const tbmData = await tbmResponse.json();
todayWorkers = tbmData.data || [];
}
// 출입 신청 로드
const visitResponse = await fetch(`${API_BASE}/workplace-visits/requests`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (visitResponse.ok) {
const visitData = await visitResponse.json();
todayVisitors = (visitData.data || []).filter(v =>
v.visit_date === today &&
(v.status === 'approved' || v.status === 'training_completed')
);
}
} catch (error) {
console.error('오늘 데이터 로드 실패:', error);
}
}
/**
* 지도 렌더링
*/
function renderMap() {
if (!canvas || !ctx) return;
// 캔버스 크기 설정
const container = canvas.parentElement;
canvas.width = container.clientWidth;
canvas.height = 400;
// 배경 그리기
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 배치도 이미지
if (canvasImage && canvasImage.complete && canvasImage.naturalWidth > 0) {
const scale = Math.min(canvas.width / canvasImage.width, canvas.height / canvasImage.height);
const x = (canvas.width - canvasImage.width * scale) / 2;
const y = (canvas.height - canvasImage.height * scale) / 2;
ctx.drawImage(canvasImage, x, y, canvasImage.width * scale, canvasImage.height * scale);
}
// 작업장 영역 그리기
mapRegions.forEach(region => {
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
const workerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
drawWorkplaceRegion(region, workerCount, visitorCount);
});
}
/**
* 작업장 영역 그리기
*/
function drawWorkplaceRegion(region, workerCount, visitorCount) {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
const width = x2 - x1;
const height = y2 - y1;
// 선택된 작업장 하이라이트
const isSelected = region.workplace_id === selectedWorkplaceId;
// 색상 결정
let fillColor, strokeColor;
if (isSelected) {
fillColor = 'rgba(34, 197, 94, 0.3)'; // 초록색
strokeColor = 'rgb(34, 197, 94)';
} else if (workerCount > 0 && visitorCount > 0) {
fillColor = 'rgba(34, 197, 94, 0.2)'; // 초록색 (작업+방문)
strokeColor = 'rgb(34, 197, 94)';
} else if (workerCount > 0) {
fillColor = 'rgba(59, 130, 246, 0.2)'; // 파란색 (작업만)
strokeColor = 'rgb(59, 130, 246)';
} else if (visitorCount > 0) {
fillColor = 'rgba(168, 85, 247, 0.2)'; // 보라색 (방문만)
strokeColor = 'rgb(168, 85, 247)';
} else {
fillColor = 'rgba(156, 163, 175, 0.2)'; // 회색 (없음)
strokeColor = 'rgb(156, 163, 175)';
}
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = isSelected ? 3 : 2;
ctx.beginPath();
ctx.rect(x1, y1, width, height);
ctx.fill();
ctx.stroke();
// 작업장명 표시
const centerX = x1 + width / 2;
const centerY = y1 + height / 2;
ctx.fillStyle = '#374151';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(region.workplace_name, centerX, centerY);
// 인원수 표시
const total = workerCount + visitorCount;
if (total > 0) {
ctx.fillStyle = strokeColor;
ctx.font = 'bold 14px sans-serif';
ctx.fillText(`(${total}명)`, centerX, centerY + 16);
}
}
/**
* 지도 클릭 처리
*/
function onMapClick(e) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 클릭된 영역 찾기
for (const region of mapRegions) {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
selectWorkplace(region);
return;
}
}
}
/**
* 작업장 선택
*/
function selectWorkplace(region) {
// 기타 위치 체크박스 해제
document.getElementById('useCustomLocation').checked = false;
document.getElementById('customLocationInput').classList.remove('visible');
selectedWorkplaceId = region.workplace_id;
selectedWorkplaceName = region.workplace_name;
// 해당 작업장의 TBM/출입신청 확인
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
if (workers.length > 0 || visitors.length > 0) {
// 작업 선택 모달 표시
showWorkSelectionModal(workers, visitors);
} else {
selectedTbmSessionId = null;
selectedVisitRequestId = null;
}
updateLocationInfo();
renderMap();
updateStepStatus();
}
/**
* 작업 선택 모달 표시
*/
function showWorkSelectionModal(workers, visitors) {
const modal = document.getElementById('workSelectionModal');
const optionsList = document.getElementById('workOptionsList');
optionsList.innerHTML = '';
// TBM 작업 옵션
workers.forEach(w => {
const option = document.createElement('div');
option.className = 'work-option';
option.innerHTML = `
<div class="work-option-title">TBM: ${w.task_name || '작업'}</div>
<div class="work-option-desc">${w.project_name || ''} - ${w.member_count || 0}명</div>
`;
option.onclick = () => {
selectedTbmSessionId = w.session_id;
selectedVisitRequestId = null;
closeWorkModal();
updateLocationInfo();
};
optionsList.appendChild(option);
});
// 출입신청 옵션
visitors.forEach(v => {
const option = document.createElement('div');
option.className = 'work-option';
option.innerHTML = `
<div class="work-option-title">출입: ${v.visitor_company}</div>
<div class="work-option-desc">${v.purpose_name || '방문'} - ${v.visitor_count || 0}명</div>
`;
option.onclick = () => {
selectedVisitRequestId = v.request_id;
selectedTbmSessionId = null;
closeWorkModal();
updateLocationInfo();
};
optionsList.appendChild(option);
});
modal.classList.add('visible');
}
/**
* 작업 선택 모달 닫기
*/
function closeWorkModal() {
document.getElementById('workSelectionModal').classList.remove('visible');
}
/**
* 선택된 위치 정보 업데이트
*/
function updateLocationInfo() {
const infoBox = document.getElementById('selectedLocationInfo');
const customLocation = document.getElementById('customLocation').value;
const useCustom = document.getElementById('useCustomLocation').checked;
if (useCustom && customLocation) {
infoBox.classList.remove('empty');
infoBox.innerHTML = `<strong>선택된 위치:</strong> ${customLocation}`;
} else if (selectedWorkplaceName) {
infoBox.classList.remove('empty');
let html = `<strong>선택된 위치:</strong> ${selectedWorkplaceName}`;
if (selectedTbmSessionId) {
const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId);
if (worker) {
html += `<br><span style="color: var(--primary-600);">연결 작업: ${worker.task_name} (TBM)</span>`;
}
} else if (selectedVisitRequestId) {
const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId);
if (visitor) {
html += `<br><span style="color: var(--primary-600);">연결 작업: ${visitor.visitor_company} (출입)</span>`;
}
}
infoBox.innerHTML = html;
} else {
infoBox.classList.add('empty');
infoBox.textContent = '지도에서 작업장을 클릭하여 위치를 선택하세요';
}
}
/**
* 유형 선택
*/
function onTypeSelect(type) {
selectedType = type;
selectedCategoryId = null;
selectedCategoryName = null;
selectedItemId = null;
// 버튼 상태 업데이트
document.querySelectorAll('.type-btn').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.type === type);
});
// 카테고리 로드
loadCategories(type);
updateStepStatus();
}
/**
* 카테고리 로드
*/
async function loadCategories(type) {
try {
const response = await fetch(`${API_BASE}/work-issues/categories/type/${type}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) throw new Error('카테고리 조회 실패');
const data = await response.json();
if (data.success && data.data) {
renderCategories(data.data);
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
}
}
/**
* 카테고리 렌더링
*/
function renderCategories(categories) {
const container = document.getElementById('categoryContainer');
const grid = document.getElementById('categoryGrid');
grid.innerHTML = '';
categories.forEach(cat => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'category-btn';
btn.textContent = cat.category_name;
btn.onclick = () => onCategorySelect(cat);
grid.appendChild(btn);
});
container.style.display = 'block';
}
/**
* 카테고리 선택
*/
function onCategorySelect(category) {
selectedCategoryId = category.category_id;
selectedCategoryName = category.category_name;
selectedItemId = null;
// 버튼 상태 업데이트
document.querySelectorAll('.category-btn').forEach(btn => {
btn.classList.toggle('selected', btn.textContent === category.category_name);
});
// 항목 로드
loadItems(category.category_id);
updateStepStatus();
}
/**
* 항목 로드
*/
async function loadItems(categoryId) {
try {
const response = await fetch(`${API_BASE}/work-issues/items/category/${categoryId}`, {
headers: { 'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}` }
});
if (!response.ok) throw new Error('항목 조회 실패');
const data = await response.json();
if (data.success && data.data) {
renderItems(data.data);
}
} catch (error) {
console.error('항목 로드 실패:', error);
}
}
/**
* 항목 렌더링
*/
function renderItems(items) {
const grid = document.getElementById('itemGrid');
grid.innerHTML = '';
if (items.length === 0) {
grid.innerHTML = '<p style="color: var(--gray-400);">등록된 항목이 없습니다</p>';
return;
}
items.forEach(item => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'item-btn';
btn.textContent = item.item_name;
btn.dataset.severity = item.severity;
btn.onclick = () => onItemSelect(item, btn);
grid.appendChild(btn);
});
// 직접 입력 버튼 추가
const customBtn = document.createElement('button');
customBtn.type = 'button';
customBtn.className = 'item-btn custom-input-btn';
customBtn.textContent = '+ 직접 입력';
customBtn.onclick = () => showCustomItemInput(customBtn);
grid.appendChild(customBtn);
}
/**
* 항목 선택
*/
function onItemSelect(item, btn) {
// 단일 선택 (기존 선택 해제)
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
selectedItemId = item.item_id;
customItemName = null;
// 직접 입력 영역 숨기기
const customInput = document.getElementById('customItemInput');
if (customInput) {
customInput.style.display = 'none';
document.getElementById('customItemName').value = '';
}
// 직접 입력 버튼 텍스트 초기화
const customBtn = document.querySelector('.item-btn.custom-input-btn');
if (customBtn) {
customBtn.textContent = '+ 직접 입력';
}
updateStepStatus();
}
/**
* 사진 선택
*/
function onPhotoSelect(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
photos[currentPhotoIndex] = event.target.result;
updatePhotoSlot(currentPhotoIndex);
};
reader.readAsDataURL(file);
// 입력 초기화
e.target.value = '';
}
/**
* 사진 슬롯 업데이트
*/
function updatePhotoSlot(index) {
const slot = document.querySelector(`.photo-slot[data-index="${index}"]`);
if (photos[index]) {
slot.classList.add('has-photo');
let img = slot.querySelector('img');
if (!img) {
img = document.createElement('img');
slot.insertBefore(img, slot.firstChild);
}
img.src = photos[index];
} else {
slot.classList.remove('has-photo');
const img = slot.querySelector('img');
if (img) img.remove();
}
}
/**
* 사진 삭제
*/
function removePhoto(index) {
photos[index] = null;
updatePhotoSlot(index);
}
/**
* 단계 상태 업데이트
*/
function updateStepStatus() {
const steps = document.querySelectorAll('.step');
const customLocation = document.getElementById('customLocation').value;
const useCustom = document.getElementById('useCustomLocation').checked;
// Step 1: 위치
const step1Complete = (useCustom && customLocation) || selectedWorkplaceId;
steps[0].classList.toggle('completed', step1Complete);
steps[1].classList.toggle('active', step1Complete);
// Step 2: 유형
const step2Complete = selectedType && selectedCategoryId;
steps[1].classList.toggle('completed', step2Complete);
steps[2].classList.toggle('active', step2Complete);
// Step 3: 항목
const step3Complete = selectedItemId || (selectedItemId === 'custom' && customItemName);
steps[2].classList.toggle('completed', !!step3Complete);
steps[3].classList.toggle('active', !!step3Complete);
// 제출 버튼 활성화
const submitBtn = document.getElementById('submitBtn');
const hasPhoto = photos.some(p => p !== null);
submitBtn.disabled = !(step1Complete && step2Complete && hasPhoto);
}
/**
* 신고 제출
*/
async function submitReport() {
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '제출 중...';
try {
const useCustom = document.getElementById('useCustomLocation').checked;
const customLocation = document.getElementById('customLocation').value;
const additionalDescription = document.getElementById('additionalDescription').value;
const requestBody = {
factory_category_id: useCustom ? null : selectedFactoryId,
workplace_id: useCustom ? null : selectedWorkplaceId,
custom_location: useCustom ? customLocation : null,
tbm_session_id: selectedTbmSessionId,
visit_request_id: selectedVisitRequestId,
issue_category_id: selectedCategoryId,
issue_item_id: selectedItemId === 'custom' ? null : selectedItemId,
custom_item_name: customItemName || null,
additional_description: additionalDescription || null,
photos: photos.filter(p => p !== null)
};
const response = await fetch(`${API_BASE}/work-issues`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${(window.getSSOToken ? window.getSSOToken() : localStorage.getItem('sso_token'))}`
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (data.success) {
alert('문제 신고가 등록되었습니다.');
window.location.href = '/pages/safety/issue-list.html';
} else {
throw new Error(data.error || '신고 등록 실패');
}
} catch (error) {
console.error('신고 제출 실패:', error);
alert('신고 등록에 실패했습니다: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '신고 제출';
}
}
/**
* 직접 입력 버튼 클릭
*/
function showCustomItemInput(btn) {
// 기존 항목 선택 해제
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
selectedItemId = null;
customItemName = null;
const customInput = document.getElementById('customItemInput');
if (customInput) {
customInput.style.display = 'flex';
document.getElementById('customItemName').focus();
}
updateStepStatus();
}
/**
* 직접 입력 확인
*/
function confirmCustomItem() {
const input = document.getElementById('customItemName');
const name = input.value.trim();
if (!name) {
input.focus();
return;
}
customItemName = name;
selectedItemId = 'custom';
updateStepStatus();
// 직접 입력 UI 숨기되 값은 유지
const customInput = document.getElementById('customItemInput');
if (customInput) {
customInput.style.display = 'none';
}
// 직접 입력 버튼 텍스트 업데이트
const customBtn = document.querySelector('.item-btn.custom-input-btn');
if (customBtn) {
customBtn.textContent = `${name}`;
customBtn.classList.add('selected');
}
}
/**
* 직접 입력 취소
*/
function cancelCustomItem() {
const customInput = document.getElementById('customItemInput');
if (customInput) {
customInput.style.display = 'none';
document.getElementById('customItemName').value = '';
}
customItemName = null;
if (selectedItemId === 'custom') {
selectedItemId = null;
}
// 직접 입력 버튼 상태 초기화
const customBtn = document.querySelector('.item-btn.custom-input-btn');
if (customBtn) {
customBtn.textContent = '+ 직접 입력';
customBtn.classList.remove('selected');
}
updateStepStatus();
}
// 기타 위치 입력 시 위치 정보 업데이트
document.addEventListener('DOMContentLoaded', () => {
const customLocationInput = document.getElementById('customLocation');
if (customLocationInput) {
customLocationInput.addEventListener('input', () => {
updateLocationInfo();
updateStepStatus();
});
}
});