feat: 챗봇 신고 페이지 AI 백엔드 추가 및 기타 개선
- ai-service: 챗봇 분석/요약 엔드포인트 추가 (chatbot.py, chatbot_service.py) - tkreport: 챗봇 신고 페이지 (chat-report.html/js/css), nginx ai-api 프록시 - tkreport: 이미지 업로드 서비스 개선, M-Project 연동 신고자 정보 전달 - system1: TBM 작업보고서 UI 개선 - TKQC: 관리함/수신함 기능 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from routers import health, embeddings, classification, daily_report, rag
|
||||
from routers import health, embeddings, classification, daily_report, rag, chatbot
|
||||
from db.vector_store import vector_store
|
||||
from db.metadata_store import metadata_store
|
||||
from services.ollama_client import ollama_client
|
||||
@@ -64,6 +64,7 @@ app.include_router(embeddings.router, prefix="/api/ai")
|
||||
app.include_router(classification.router, prefix="/api/ai")
|
||||
app.include_router(daily_report.router, prefix="/api/ai")
|
||||
app.include_router(rag.router, prefix="/api/ai")
|
||||
app.include_router(chatbot.router, prefix="/api/ai")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
37
ai-service/routers/chatbot.py
Normal file
37
ai-service/routers/chatbot.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.chatbot_service import analyze_user_input, summarize_report
|
||||
|
||||
router = APIRouter(tags=["chatbot"])
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
user_text: str
|
||||
categories: dict = {}
|
||||
|
||||
|
||||
class SummarizeRequest(BaseModel):
|
||||
description: str = ""
|
||||
type: str = ""
|
||||
category: str = ""
|
||||
item: str = ""
|
||||
location: str = ""
|
||||
project: str = ""
|
||||
|
||||
|
||||
@router.post("/chatbot/analyze")
|
||||
async def chatbot_analyze(req: AnalyzeRequest):
|
||||
try:
|
||||
result = await analyze_user_input(req.user_text, req.categories)
|
||||
return {"success": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 분석 중 오류가 발생했습니다")
|
||||
|
||||
|
||||
@router.post("/chatbot/summarize")
|
||||
async def chatbot_summarize(req: SummarizeRequest):
|
||||
try:
|
||||
result = await summarize_report(req.model_dump())
|
||||
return {"success": True, **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="AI 요약 중 오류가 발생했습니다")
|
||||
110
ai-service/services/chatbot_service.py
Normal file
110
ai-service/services/chatbot_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
from services.ollama_client import ollama_client
|
||||
|
||||
|
||||
ANALYZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 접수를 도와주는 AI 도우미입니다.
|
||||
사용자가 현장에서 발견한 문제를 설명하면, 아래 카테고리 목록을 참고하여 가장 적합한 신고 유형과 카테고리를 제안해야 합니다.
|
||||
|
||||
신고 유형:
|
||||
- nonconformity (부적합): 제품/작업 품질 관련 문제
|
||||
- facility (시설설비): 시설, 설비, 장비 관련 문제
|
||||
- safety (안전): 안전 위험, 위험 요소 관련 문제
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요:
|
||||
{
|
||||
"organized_description": "정리된 설명 (1-2문장)",
|
||||
"suggested_type": "nonconformity 또는 facility 또는 safety",
|
||||
"suggested_category_id": 카테고리ID(숫자) 또는 null,
|
||||
"confidence": 0.0~1.0 사이의 확신도
|
||||
}"""
|
||||
|
||||
SUMMARIZE_SYSTEM_PROMPT = """당신은 공장 현장 신고 내용을 요약하는 AI 도우미입니다.
|
||||
주어진 신고 정보를 보기 좋게 정리하여 한국어로 요약해주세요.
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요:
|
||||
{
|
||||
"summary": "요약 텍스트"
|
||||
}"""
|
||||
|
||||
|
||||
async def analyze_user_input(user_text: str, categories: dict) -> dict:
|
||||
"""사용자 초기 입력을 분석하여 유형 제안 + 설명 정리"""
|
||||
category_context = ""
|
||||
for type_key, cats in categories.items():
|
||||
type_label = {"nonconformity": "부적합", "facility": "시설설비", "safety": "안전"}.get(type_key, type_key)
|
||||
cat_names = [f" - ID {c['id']}: {c['name']}" for c in cats]
|
||||
category_context += f"\n[{type_label} ({type_key})]\n" + "\n".join(cat_names) + "\n"
|
||||
|
||||
prompt = f"""카테고리 목록:
|
||||
{category_context}
|
||||
|
||||
사용자 입력: "{user_text}"
|
||||
|
||||
위 카테고리 목록을 참고하여 JSON으로 응답하세요."""
|
||||
|
||||
raw = await ollama_client.generate_text(prompt, system=ANALYZE_SYSTEM_PROMPT)
|
||||
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
result = json.loads(raw[start:end])
|
||||
# Validate required fields
|
||||
if "organized_description" not in result:
|
||||
result["organized_description"] = user_text
|
||||
if "suggested_type" not in result:
|
||||
result["suggested_type"] = "nonconformity"
|
||||
if "confidence" not in result:
|
||||
result["confidence"] = 0.5
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"organized_description": user_text,
|
||||
"suggested_type": "nonconformity",
|
||||
"suggested_category_id": None,
|
||||
"confidence": 0.3,
|
||||
}
|
||||
|
||||
|
||||
async def summarize_report(data: dict) -> dict:
|
||||
"""최종 신고 내용을 요약"""
|
||||
prompt = f"""신고 정보:
|
||||
- 설명: {data.get('description', '')}
|
||||
- 유형: {data.get('type', '')}
|
||||
- 카테고리: {data.get('category', '')}
|
||||
- 항목: {data.get('item', '')}
|
||||
- 위치: {data.get('location', '')}
|
||||
- 프로젝트: {data.get('project', '')}
|
||||
|
||||
위 정보를 보기 좋게 요약하여 JSON으로 응답하세요."""
|
||||
|
||||
raw = await ollama_client.generate_text(prompt, system=SUMMARIZE_SYSTEM_PROMPT)
|
||||
|
||||
try:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
result = json.loads(raw[start:end])
|
||||
if "summary" in result:
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback: construct summary manually
|
||||
parts = []
|
||||
if data.get("type"):
|
||||
parts.append(f"[{data['type']}]")
|
||||
if data.get("category"):
|
||||
parts.append(data["category"])
|
||||
if data.get("item"):
|
||||
parts.append(f"- {data['item']}")
|
||||
if data.get("location"):
|
||||
parts.append(f"\n위치: {data['location']}")
|
||||
if data.get("project"):
|
||||
parts.append(f"\n프로젝트: {data['project']}")
|
||||
if data.get("description"):
|
||||
parts.append(f"\n내용: {data['description']}")
|
||||
|
||||
return {"summary": " ".join(parts) if parts else "신고 내용 요약"}
|
||||
@@ -392,12 +392,16 @@ function openNewTbmModal() {
|
||||
sessionDateDisplay.textContent = `${year}년 ${parseInt(month)}월 ${parseInt(day)}일 (${dayName})`;
|
||||
}
|
||||
|
||||
// 입력자 자동 설정 (readonly)
|
||||
// 입력자 자동 설정
|
||||
if (currentUser && currentUser.user_id) {
|
||||
const worker = allWorkers.find(w => w.user_id === currentUser.user_id);
|
||||
if (worker) {
|
||||
document.getElementById('leaderName').textContent = worker.worker_name;
|
||||
document.getElementById('leaderId').value = worker.user_id;
|
||||
} else {
|
||||
// 어드민: 작업자 목록에 없음 → 이름 표시, leaderId 비움
|
||||
document.getElementById('leaderName').textContent = currentUser.name || '관리자';
|
||||
document.getElementById('leaderId').value = '';
|
||||
}
|
||||
} else if (currentUser && currentUser.name) {
|
||||
document.getElementById('leaderName').textContent = currentUser.name;
|
||||
@@ -534,20 +538,15 @@ function populateLeaderSelect() {
|
||||
if (!leaderSelect) return;
|
||||
|
||||
// 로그인한 사용자가 작업자와 연결되어 있는지 확인
|
||||
if (currentUser && currentUser.user_id) {
|
||||
const isWorker = currentUser && currentUser.user_id && allWorkers.find(w => w.user_id === currentUser.user_id);
|
||||
if (isWorker) {
|
||||
// 작업자와 연결된 경우: 자동으로 선택하고 비활성화
|
||||
const worker = allWorkers.find(w => w.user_id === currentUser.user_id);
|
||||
if (worker) {
|
||||
const jobTypeText = worker.job_type ? ` (${escapeHtml(worker.job_type)})` : '';
|
||||
leaderSelect.innerHTML = `<option value="${escapeHtml(worker.user_id)}" selected>${escapeHtml(worker.worker_name)}${jobTypeText}</option>`;
|
||||
leaderSelect.disabled = true;
|
||||
} else {
|
||||
// 작업자를 찾을 수 없는 경우
|
||||
leaderSelect.innerHTML = '<option value="">입력자를 찾을 수 없습니다</option>';
|
||||
leaderSelect.disabled = true;
|
||||
}
|
||||
const worker = isWorker;
|
||||
const jobTypeText = worker.job_type ? ` (${escapeHtml(worker.job_type)})` : '';
|
||||
leaderSelect.innerHTML = `<option value="${escapeHtml(worker.user_id)}" selected>${escapeHtml(worker.worker_name)}${jobTypeText}</option>`;
|
||||
leaderSelect.disabled = true;
|
||||
} else {
|
||||
// 관리자 계정 (user_id가 없음): 드롭다운으로 선택 가능
|
||||
// 관리자 또는 작업자 목록에 없는 계정: 드롭다운으로 선택 가능
|
||||
const leaders = allWorkers.filter(w =>
|
||||
w.job_type === 'leader' || w.job_type === '그룹장' || w.job_type === 'admin'
|
||||
);
|
||||
@@ -638,13 +637,8 @@ async function saveTbmSession() {
|
||||
let leaderId = parseInt(document.getElementById('leaderId').value);
|
||||
|
||||
if (!leaderId || isNaN(leaderId)) {
|
||||
if (!currentUser.user_id) {
|
||||
leaderId = null;
|
||||
} else {
|
||||
console.error(' 입력자 설정 오류');
|
||||
showToast('입력자 정보가 올바르지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
// 어드민이거나 작업자 목록에 없는 사용자: leaderId null로 저장 가능
|
||||
leaderId = null;
|
||||
}
|
||||
|
||||
const sessionData = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
RUN apk add --no-cache imagemagick libheif imagemagick-heic imagemagick-jpeg
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -199,6 +199,7 @@ exports.createReport = async (req, res) => {
|
||||
|
||||
// 부적합 유형이면 System 3(tkqc)으로 비동기 전달
|
||||
try {
|
||||
console.log(`[System3 연동] report_id=${reportId}, category_type=${categoryType}`);
|
||||
if (catInfo && catInfo.category_type === 'nonconformity') {
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
@@ -209,8 +210,9 @@ exports.createReport = async (req, res) => {
|
||||
const filePath = path.join(__dirname, '..', p);
|
||||
const buf = await fs.readFile(filePath);
|
||||
photoBase64List.push(`data:image/jpeg;base64,${buf.toString('base64')}`);
|
||||
console.log(`[System3 연동] 사진 읽기 성공: ${p} (${buf.length} bytes)`);
|
||||
} catch (readErr) {
|
||||
console.error('사진 파일 읽기 실패:', p, readErr.message);
|
||||
console.error('[System3 연동] 사진 파일 읽기 실패:', p, readErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,27 +225,32 @@ exports.createReport = async (req, res) => {
|
||||
if (locationParts.workplace_name) locationInfo += ` - ${locationParts.workplace_name}`;
|
||||
}
|
||||
} catch (locErr) {
|
||||
console.error('위치 정보 조회 실패:', locErr.message);
|
||||
console.error('[System3 연동] 위치 정보 조회 실패:', locErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
const originalToken = (req.headers['authorization'] || '').replace('Bearer ', '');
|
||||
console.log(`[System3 연동] sendToMProject 호출: category=${catInfo.category_name}, photos=${photoBase64List.length}장, reporter=${req.user.username}`);
|
||||
const result = await mProjectService.sendToMProject({
|
||||
category: catInfo.category_name,
|
||||
description: additional_description || catInfo.category_name,
|
||||
reporter_name: req.user.name || req.user.username,
|
||||
reporter_username: req.user.username || req.user.sub,
|
||||
reporter_role: req.user.role || 'user',
|
||||
tk_issue_id: reportId,
|
||||
project_id: project_id || null,
|
||||
location_info: locationInfo,
|
||||
photos: photoBase64List,
|
||||
ssoToken: originalToken
|
||||
});
|
||||
console.log(`[System3 연동] 결과: ${JSON.stringify(result)}`);
|
||||
if (result.success && result.mProjectId) {
|
||||
await workIssueModel.updateMProjectId(reportId, result.mProjectId);
|
||||
console.log(`[System3 연동] m_project_id=${result.mProjectId} 업데이트 완료`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[System3 연동] 부적합 아님, 건너뜀`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('System3 연동 실패 (신고는 정상 저장됨):', e.message);
|
||||
console.error('[System3 연동 실패]', e.message, e.stack);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('신고 생성 에러:', error);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
// sharp는 선택적으로 사용 (설치되어 있지 않으면 리사이징 없이 저장)
|
||||
let sharp;
|
||||
@@ -110,6 +111,7 @@ async function saveBase64Image(base64String, prefix = 'issue', category = 'issue
|
||||
const filepath = path.join(uploadDir, filename);
|
||||
|
||||
// sharp가 설치되어 있으면 리사이징 및 최적화
|
||||
let saved = false;
|
||||
if (sharp) {
|
||||
try {
|
||||
await sharp(buffer)
|
||||
@@ -119,13 +121,27 @@ async function saveBase64Image(base64String, prefix = 'issue', category = 'issue
|
||||
})
|
||||
.jpeg({ quality: QUALITY })
|
||||
.toFile(filepath);
|
||||
saved = true;
|
||||
} catch (sharpError) {
|
||||
console.error('sharp 처리 실패, 원본 저장:', sharpError.message);
|
||||
// sharp 실패 시 원본 저장
|
||||
await fs.writeFile(filepath, buffer);
|
||||
console.warn('sharp 처리 실패:', sharpError.message);
|
||||
}
|
||||
} else {
|
||||
// sharp가 없으면 원본 그대로 저장
|
||||
}
|
||||
|
||||
// sharp 실패 시 ImageMagick으로 변환 시도 (HEIC 등)
|
||||
if (!saved) {
|
||||
try {
|
||||
const tmpInput = filepath + '.tmp';
|
||||
await fs.writeFile(tmpInput, buffer);
|
||||
execFileSync('magick', [tmpInput, '-resize', `${MAX_SIZE.width}x${MAX_SIZE.height}>`, '-quality', String(QUALITY), filepath]);
|
||||
await fs.unlink(tmpInput).catch(() => {});
|
||||
saved = true;
|
||||
} catch (magickError) {
|
||||
console.warn('ImageMagick 변환 실패:', magickError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 모두 실패 시 원본 저장
|
||||
if (!saved) {
|
||||
await fs.writeFile(filepath, buffer);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// M-Project API 설정
|
||||
const M_PROJECT_CONFIG = {
|
||||
@@ -183,20 +184,25 @@ async function sendToMProject(issueData) {
|
||||
category,
|
||||
description,
|
||||
reporter_name,
|
||||
reporter_username = null,
|
||||
reporter_role = 'user',
|
||||
project_name,
|
||||
project_id = null,
|
||||
tk_issue_id,
|
||||
location_info = null,
|
||||
photos = [],
|
||||
ssoToken = null,
|
||||
} = issueData;
|
||||
|
||||
logger.info('M-Project 연동 시작', { tk_issue_id, category });
|
||||
|
||||
// SSO 토큰이 있으면 원래 사용자로 전송, 없으면 api_service 토큰
|
||||
// 신고자 정보로 SSO JWT 토큰 직접 생성 (api_service 대신 실제 신고자로)
|
||||
let token;
|
||||
if (ssoToken) {
|
||||
token = ssoToken;
|
||||
if (reporter_username && process.env.JWT_SECRET) {
|
||||
token = jwt.sign(
|
||||
{ sub: reporter_username, name: reporter_name || reporter_username, role: reporter_role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
} else {
|
||||
token = await getAuthToken();
|
||||
}
|
||||
|
||||
378
system2-report/web/css/chat-report.css
Normal file
378
system2-report/web/css/chat-report.css
Normal file
@@ -0,0 +1,378 @@
|
||||
/* chat-report.css — 챗봇 신고 접수 UI */
|
||||
|
||||
:root {
|
||||
--chat-primary: #0ea5e9;
|
||||
--chat-primary-dark: #0284c7;
|
||||
--chat-primary-light: #e0f2fe;
|
||||
--chat-bg: #f1f5f9;
|
||||
--chat-white: #ffffff;
|
||||
--chat-text: #1e293b;
|
||||
--chat-text-light: #64748b;
|
||||
--chat-border: #e2e8f0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
|
||||
background: var(--chat-bg);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.chat-header {
|
||||
background: linear-gradient(135deg, var(--chat-primary), var(--chat-primary-dark));
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));
|
||||
z-index: 10;
|
||||
}
|
||||
.chat-header-back {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.chat-header-title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chat-header-subtitle {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── Chat Area ── */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.chat-area::-webkit-scrollbar { width: 4px; }
|
||||
.chat-area::-webkit-scrollbar-track { background: transparent; }
|
||||
.chat-area::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; }
|
||||
|
||||
/* ── Message Bubbles ── */
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
max-width: 88%;
|
||||
animation: bubbleIn 0.25s ease-out;
|
||||
}
|
||||
.chat-msg.bot { align-self: flex-start; }
|
||||
.chat-msg.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
|
||||
.chat-avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-msg.bot .chat-avatar {
|
||||
background: var(--chat-primary-light);
|
||||
color: var(--chat-primary);
|
||||
}
|
||||
.chat-msg.user .chat-avatar {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 1.125rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
.chat-msg.bot .chat-bubble {
|
||||
background: var(--chat-white);
|
||||
color: var(--chat-text);
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
|
||||
}
|
||||
.chat-msg.user .chat-bubble {
|
||||
background: var(--chat-primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Typing Indicator ── */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.typing-indicator span {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
/* ── Option Buttons (chip style) ── */
|
||||
.chat-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
animation: bubbleIn 0.25s ease-out;
|
||||
}
|
||||
.chat-option-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1.5px solid var(--chat-border);
|
||||
border-radius: 2rem;
|
||||
background: var(--chat-white);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--chat-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-option-btn:active { transform: scale(0.97); }
|
||||
.chat-option-btn:hover { border-color: var(--chat-primary); color: var(--chat-primary); }
|
||||
.chat-option-btn.selected {
|
||||
border-color: var(--chat-primary);
|
||||
background: var(--chat-primary-light);
|
||||
color: var(--chat-primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chat-option-btn.suggested {
|
||||
border-color: var(--chat-primary);
|
||||
background: var(--chat-primary-light);
|
||||
position: relative;
|
||||
}
|
||||
.chat-option-btn.suggested::after {
|
||||
content: 'AI 추천';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 8px;
|
||||
font-size: 0.5625rem;
|
||||
background: var(--chat-primary);
|
||||
color: white;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Photo Thumbnails in Chat ── */
|
||||
.chat-photos {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.chat-photos img {
|
||||
width: 64px; height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--chat-border);
|
||||
}
|
||||
.chat-photos .photo-fallback {
|
||||
width: 64px; height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 1px dashed rgba(255,255,255,0.5);
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Summary Card ── */
|
||||
.summary-card {
|
||||
background: var(--chat-white);
|
||||
border: 1px solid var(--chat-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
animation: bubbleIn 0.25s ease-out;
|
||||
align-self: flex-start;
|
||||
max-width: 88%;
|
||||
}
|
||||
.summary-card .summary-title {
|
||||
font-weight: 700;
|
||||
color: var(--chat-primary-dark);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.summary-card .summary-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.summary-card .summary-row:last-child { border-bottom: none; }
|
||||
.summary-card .summary-label {
|
||||
color: var(--chat-text-light);
|
||||
flex-shrink: 0;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
.summary-card .summary-value {
|
||||
color: var(--chat-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
animation: bubbleIn 0.25s ease-out;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.summary-actions button {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.summary-actions .btn-submit {
|
||||
background: var(--chat-primary);
|
||||
color: white;
|
||||
}
|
||||
.summary-actions .btn-submit:active { background: var(--chat-primary-dark); }
|
||||
.summary-actions .btn-edit {
|
||||
background: #f1f5f9;
|
||||
color: var(--chat-text);
|
||||
}
|
||||
|
||||
/* ── Input Bar ── */
|
||||
.chat-input-bar {
|
||||
background: var(--chat-white);
|
||||
border-top: 1px solid var(--chat-border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-input-bar.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chat-photo-btn {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--chat-border);
|
||||
background: var(--chat-white);
|
||||
font-size: 1.125rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: relative;
|
||||
}
|
||||
.chat-photo-btn .photo-count {
|
||||
position: absolute;
|
||||
top: -4px; right: -4px;
|
||||
background: var(--chat-primary);
|
||||
color: white;
|
||||
font-size: 0.625rem;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chat-text-input {
|
||||
flex: 1;
|
||||
border: 1.5px solid var(--chat-border);
|
||||
border-radius: 1.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
max-height: 100px;
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
}
|
||||
.chat-text-input:focus { border-color: var(--chat-primary); }
|
||||
.chat-send-btn {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--chat-primary);
|
||||
color: white;
|
||||
font-size: 1.125rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.chat-send-btn:disabled { background: #cbd5e1; cursor: not-allowed; }
|
||||
.chat-send-btn:not(:disabled):active { background: var(--chat-primary-dark); }
|
||||
|
||||
/* ── Loading overlay ── */
|
||||
.chat-loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.chat-loading-inner {
|
||||
background: white;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--chat-text);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes bubbleIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (min-width: 480px) {
|
||||
body { max-width: 480px; margin: 0 auto; box-shadow: 0 0 20px rgba(0,0,0,0.1); }
|
||||
}
|
||||
847
system2-report/web/js/chat-report.js
Normal file
847
system2-report/web/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='https://tkqc.technicalkorea.net'">📋 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}` : '' };
|
||||
}
|
||||
@@ -53,6 +53,17 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# AI Service 프록시 (챗봇 등)
|
||||
location /ai-api/ {
|
||||
proxy_pass http://ai-service:8000/api/ai/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# System 2 API 프록시 (신고 관련)
|
||||
location /api/ {
|
||||
proxy_pass http://system2-api:3005;
|
||||
|
||||
37
system2-report/web/pages/safety/chat-report.html
Normal file
37
system2-report/web/pages/safety/chat-report.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>AI 신고 도우미 | (주)테크니컬코리아</title>
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<link rel="stylesheet" href="/css/chat-report.css?v=3">
|
||||
<script src="/js/api-base.js?v=20260309"></script>
|
||||
<script src="/js/app-init.js?v=20260309" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<div class="chat-header">
|
||||
<button class="chat-header-back" onclick="history.back()" aria-label="뒤로가기">←</button>
|
||||
<div>
|
||||
<div class="chat-header-title">AI 신고 도우미</div>
|
||||
<div class="chat-header-subtitle">대화형 신고 접수</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="chat-area" id="chatArea"></div>
|
||||
|
||||
<!-- Input Bar -->
|
||||
<div class="chat-input-bar">
|
||||
<button class="chat-photo-btn" id="photoBtn" aria-label="사진 첨부">📎</button>
|
||||
<textarea class="chat-text-input" id="textInput" rows="1" placeholder="문제 상황을 설명해주세요..." enterkeyhint="send"></textarea>
|
||||
<button class="chat-send-btn" id="sendBtn" disabled aria-label="전송">➤</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input type="file" id="chatPhotoInput" accept="image/*" style="display:none">
|
||||
|
||||
<script src="/js/chat-report.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>신고 등록 | (주)테크니컬코리아</title>
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="/js/api-base.js?v=20260309"></script>
|
||||
<script src="/js/app-init.js?v=20260309" defer></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
@@ -569,6 +569,13 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- AI 챗봇 신고 배너 -->
|
||||
<a href="/pages/safety/chat-report.html" style="display:flex;align-items:center;gap:0.625rem;margin:0.75rem;padding:0.875rem 1rem;background:linear-gradient(135deg,#0ea5e9,#0284c7);color:white;border-radius:0.75rem;text-decoration:none;box-shadow:0 2px 8px rgba(14,165,233,0.3);-webkit-tap-highlight-color:transparent;">
|
||||
<span style="font-size:1.5rem;">🤖</span>
|
||||
<span style="flex:1;"><strong style="font-size:0.875rem;">AI 신고 도우미</strong><br><span style="font-size:0.75rem;opacity:0.9;">사진+설명만으로 간편하게 신고하기</span></span>
|
||||
<span style="font-size:1.25rem;opacity:0.8;">→</span>
|
||||
</a>
|
||||
|
||||
<!-- Step Indicator (5 steps) -->
|
||||
<div class="step-indicator">
|
||||
<div class="step active"><span class="step-dot">1</span><span>유형</span></div>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<!-- 공통 스타일 및 페이지 전용 스타일 -->
|
||||
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=20260213">
|
||||
<link rel="stylesheet" href="/static/css/issues-management.css?v=20260213">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||
@@ -193,7 +194,7 @@
|
||||
<span class="text-xs text-gray-500">과거 사례 분석 중...</span>
|
||||
</div>
|
||||
<div id="aiSuggestResult" class="hidden mt-2 bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
||||
<div id="aiSuggestContent" class="text-sm text-gray-700 whitespace-pre-line"></div>
|
||||
<div id="aiSuggestContent" class="text-sm text-gray-700 prose prose-sm max-w-none"></div>
|
||||
<div id="aiSuggestSources" class="mt-2 text-xs text-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,20 +8,13 @@ let projects = [];
|
||||
let filteredIssues = [];
|
||||
|
||||
// 한국 시간(KST) 유틸리티 함수
|
||||
function getKSTDate(date) {
|
||||
const utcDate = new Date(date);
|
||||
// UTC + 9시간 = KST
|
||||
return new Date(utcDate.getTime() + (9 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
// DB에 KST로 저장된 naive datetime을 그대로 표시
|
||||
function formatKSTDate(date) {
|
||||
const kstDate = getKSTDate(date);
|
||||
return kstDate.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
return new Date(date).toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
}
|
||||
|
||||
function formatKSTTime(date) {
|
||||
const kstDate = getKSTDate(date);
|
||||
return kstDate.toLocaleTimeString('ko-KR', {
|
||||
return new Date(date).toLocaleTimeString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
@@ -30,8 +23,8 @@ function formatKSTTime(date) {
|
||||
|
||||
function getKSTToday() {
|
||||
const now = new Date();
|
||||
const kstNow = getKSTDate(now);
|
||||
return new Date(kstNow.getFullYear(), kstNow.getMonth(), kstNow.getDate());
|
||||
const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
return new Date(kst.getFullYear(), kst.getMonth(), kst.getDate());
|
||||
}
|
||||
|
||||
// 애니메이션 함수들
|
||||
@@ -365,7 +358,7 @@ async function loadStatistics() {
|
||||
|
||||
// 금일 신규: 오늘 올라온 목록 숫자 (확인된 것 포함) - KST 기준
|
||||
const todayNewCount = issues.filter(issue => {
|
||||
const reportDate = getKSTDate(new Date(issue.report_date));
|
||||
const reportDate = new Date(issue.report_date);
|
||||
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
|
||||
return reportDateOnly >= todayStart;
|
||||
}).length;
|
||||
@@ -389,7 +382,7 @@ async function loadStatistics() {
|
||||
|
||||
// 미해결: 오늘꺼 제외한 남아있는 것들 - KST 기준
|
||||
const unresolvedCount = issues.filter(issue => {
|
||||
const reportDate = getKSTDate(new Date(issue.report_date));
|
||||
const reportDate = new Date(issue.report_date);
|
||||
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
|
||||
return reportDateOnly < todayStart;
|
||||
}).length;
|
||||
@@ -852,9 +845,9 @@ async function confirmStatus() {
|
||||
// issue-helpers.js에서 제공됨
|
||||
|
||||
function getTimeAgo(date) {
|
||||
const now = getKSTDate(new Date());
|
||||
const kstDate = getKSTDate(date);
|
||||
const diffMs = now - kstDate;
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diffMs = now - target;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
@@ -461,7 +461,7 @@ function createInProgressRow(issue, project) {
|
||||
<i class="fas fa-lightbulb mr-2"></i>AI 해결방안 제안 (과거 사례 기반)
|
||||
</button>
|
||||
<div id="aiSuggestResult_${issue.id}" class="hidden mt-2 p-3 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<p id="aiSuggestContent_${issue.id}" class="text-sm text-gray-800 whitespace-pre-wrap"></p>
|
||||
<div id="aiSuggestContent_${issue.id}" class="text-sm text-gray-800 prose prose-sm max-w-none"></div>
|
||||
<p id="aiSuggestSources_${issue.id}" class="text-xs text-purple-600 mt-2"></p>
|
||||
<button onclick="applyAiSuggestion(${issue.id})" class="mt-2 text-xs px-2 py-1 bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors">
|
||||
<i class="fas fa-paste mr-1"></i>해결방안에 적용
|
||||
@@ -985,7 +985,15 @@ async function aiSuggestSolution() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (content) content.textContent = data.suggestion || '';
|
||||
if (content) {
|
||||
const raw = data.suggestion || '';
|
||||
content.dataset.raw = raw;
|
||||
if (typeof marked !== 'undefined') {
|
||||
content.innerHTML = marked.parse(raw);
|
||||
} else {
|
||||
content.textContent = raw;
|
||||
}
|
||||
}
|
||||
if (sources && data.referenced_issues) {
|
||||
const refs = data.referenced_issues
|
||||
.filter(r => r.has_solution)
|
||||
@@ -1017,7 +1025,15 @@ async function aiSuggestSolutionInline(issueId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (content) content.textContent = data.suggestion || '';
|
||||
if (content) {
|
||||
const raw = data.suggestion || '';
|
||||
content.dataset.raw = raw;
|
||||
if (typeof marked !== 'undefined') {
|
||||
content.innerHTML = marked.parse(raw);
|
||||
} else {
|
||||
content.textContent = raw;
|
||||
}
|
||||
}
|
||||
if (sources && data.referenced_issues) {
|
||||
const refs = data.referenced_issues
|
||||
.filter(r => r.has_solution)
|
||||
@@ -1033,7 +1049,7 @@ function applyAiSuggestion(issueId) {
|
||||
const content = document.getElementById(`aiSuggestContent_${issueId}`);
|
||||
const textarea = document.getElementById(`management_comment_${issueId}`);
|
||||
if (content && textarea) {
|
||||
textarea.value = content.textContent;
|
||||
textarea.value = content.dataset.raw || content.textContent;
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user