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:
161
system2-report/web/public/js/api-base.js
Normal file
161
system2-report/web/public/js/api-base.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
|
||||
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);
|
||||
})();
|
||||
107
system2-report/web/public/js/app-init.js
Normal file
107
system2-report/web/public/js/app-init.js
Normal 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 };
|
||||
})();
|
||||
847
system2-report/web/public/js/chat-report.js
Normal file
847
system2-report/web/public/js/chat-report.js
Normal 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}` : '' };
|
||||
}
|
||||
42
system2-report/web/public/js/cross-nav.js
Normal file
42
system2-report/web/public/js/cross-nav.js
Normal 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) →</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);
|
||||
});
|
||||
421
system2-report/web/public/js/issue-category-manage.js
Normal file
421
system2-report/web/public/js/issue-category-manage.js
Normal 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);
|
||||
}
|
||||
};
|
||||
761
system2-report/web/public/js/issue-detail.js
Normal file
761
system2-report/web/public/js/issue-detail.js
Normal 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;
|
||||
1232
system2-report/web/public/js/issue-report.js
Normal file
1232
system2-report/web/public/js/issue-report.js
Normal file
File diff suppressed because it is too large
Load Diff
222
system2-report/web/public/js/safety-report-list.js
Normal file
222
system2-report/web/public/js/safety-report-list.js
Normal 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`;
|
||||
}
|
||||
39
system2-report/web/public/js/sso-relay.js
Normal file
39
system2-report/web/public/js/sso-relay.js
Normal 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);
|
||||
})();
|
||||
835
system2-report/web/public/js/work-issue-report.js
Normal file
835
system2-report/web/public/js/work-issue-report.js
Normal 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user