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

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

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

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

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

View File

@@ -0,0 +1,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); }
}

View File

@@ -0,0 +1,300 @@
/* Common CSS - 공통 스타일 */
/* ========== 통일된 헤더 스타일 ========== */
.work-report-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.work-report-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 2rem 1.5rem;
margin-bottom: 0;
}
.work-report-header h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 700;
margin: 0 0 0.75rem 0;
text-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.3);
word-wrap: break-word;
overflow-wrap: break-word;
}
.work-report-header .subtitle {
font-size: clamp(0.875rem, 2vw, 1.1rem);
opacity: 0.9;
margin: 0;
font-weight: 300;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 90%;
margin-left: auto;
margin-right: auto;
}
.work-report-main {
background: #f8f9fa;
min-height: calc(100vh - 12rem);
padding-top: 2rem;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.9);
color: #495057;
text-decoration: none;
border-radius: 0.5rem;
font-weight: 500;
margin: 0 1.5rem 1.5rem 1.5rem;
transition: all 0.3s ease;
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.1);
white-space: nowrap;
}
.back-button:hover {
background: white;
color: #007bff;
transform: translateY(-0.0625rem);
box-shadow: 0 0.25rem 0.5rem rgba(0,0,0,0.15);
}
/* 반응형 헤더 */
@media (max-width: 768px) {
.work-report-header {
padding: 1.5rem 1rem;
}
.work-report-header h1 {
margin-bottom: 0.5rem;
}
.back-button {
margin: 0 1rem 1rem 1rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
}
}
@media (max-width: 480px) {
.work-report-header {
padding: 1.25rem 0.75rem;
}
.work-report-header .subtitle {
font-size: 0.8125rem;
}
.back-button {
margin: 0 0.75rem 0.75rem 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
}
}
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8fafc;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
margin-bottom: 0.5rem;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
h5 { font-size: 1rem; }
h6 { font-size: 0.875rem; }
/* ========== 헤더 액션 버튼 ========== */
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-right: 1rem;
}
.dashboard-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
background: rgba(255, 255, 255, 0.15);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.dashboard-btn:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.dashboard-btn .btn-icon {
font-size: 1rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn-primary {
background-color: #3b82f6;
color: white;
}
.btn-primary:hover {
background-color: #2563eb;
}
.btn-secondary {
background-color: #6b7280;
color: white;
}
.btn-secondary:hover {
background-color: #4b5563;
}
/* Utilities */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-5 { margin-bottom: 1.25rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mt-5 { margin-top: 1.25rem; }
.mt-6 { margin-top: 1.5rem; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-5 { padding: 1.25rem; }
.p-6 { padding: 1.5rem; }
/* Container */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Cards */
.card {
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
}
.card-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.card-body {
padding: 1rem;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
color: #374151;
}
.form-control {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Loading */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 0 0.5rem;
}
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
h3 { font-size: 1.125rem; }
}

View File

@@ -0,0 +1,477 @@
/* ✅ design-system.css - 한글 기반 모던 디자인 시스템 */
/* ========== 색상 시스템 ========== */
:root {
/* 주요 브랜드 색상 (하늘색 계열) */
--primary-50: #f0f9ff;
--primary-100: #e0f2fe;
--primary-200: #bae6fd;
--primary-300: #7dd3fc;
--primary-400: #38bdf8;
--primary-500: #0ea5e9;
--primary-600: #0284c7;
--primary-700: #0369a1;
--primary-800: #075985;
--primary-900: #0c4a6e;
/* 헤더 그라디언트 */
--header-gradient: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 50%, #7dd3fc 100%);
/* 보조 색상 */
--secondary-50: #f3e5f5;
--secondary-100: #e1bee7;
--secondary-200: #ce93d8;
--secondary-300: #ba68c8;
--secondary-400: #ab47bc;
--secondary-500: #9c27b0;
--secondary-600: #8e24aa;
--secondary-700: #7b1fa2;
--secondary-800: #6a1b9a;
--secondary-900: #4a148c;
/* 그레이 스케일 */
--gray-50: #fafafa;
--gray-100: #f5f5f5;
--gray-200: #eeeeee;
--gray-300: #e0e0e0;
--gray-400: #bdbdbd;
--gray-500: #9e9e9e;
--gray-600: #757575;
--gray-700: #616161;
--gray-800: #424242;
--gray-900: #212121;
/* 상태 색상 */
--success-50: #e8f5e8;
--success-500: #4caf50;
--success-700: #388e3c;
--warning-50: #fff8e1;
--warning-500: #ff9800;
--warning-700: #f57c00;
--error-50: #ffebee;
--error-500: #f44336;
--error-700: #d32f2f;
--info-50: #e1f5fe;
--info-500: #03a9f4;
--info-700: #0288d1;
/* 따뜻한 중성 색상 (베이지/크림) */
--warm-50: #fafaf9; /* 매우 밝은 크림 */
--warm-100: #f5f5f4; /* 밝은 크림 */
--warm-200: #e7e5e4; /* 베이지 */
--warm-300: #d6d3d1; /* 중간 베이지 */
--warm-400: #a8a29e; /* 진한 베이지 */
--warm-500: #78716c; /* 그레이 베이지 */
/* 부드러운 작업 상태 색상 (눈이 편한 톤) */
--status-success-bg: #dcfce7; /* 부드러운 초록 배경 */
--status-success-text: #16a34a; /* 부드러운 초록 텍스트 */
--status-info-bg: #e0f2fe; /* 부드러운 하늘색 배경 */
--status-info-text: #0284c7; /* 부드러운 하늘색 텍스트 */
--status-warning-bg: #fef3c7; /* 부드러운 노랑 배경 */
--status-warning-text: #ca8a04; /* 부드러운 노랑 텍스트 */
--status-error-bg: #fee2e2; /* 부드러운 빨강 배경 */
--status-error-text: #dc2626; /* 부드러운 빨강 텍스트 */
--status-critical-bg: #fecaca; /* 진한 빨강 배경 */
--status-critical-text: #b91c1c; /* 진한 빨강 텍스트 */
--status-vacation-bg: #fed7aa; /* 부드러운 주황 배경 */
--status-vacation-text: #ea580c; /* 부드러운 주황 텍스트 */
/* 배경 색상 */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--bg-overlay: rgba(0, 0, 0, 0.5);
/* 텍스트 색상 */
--text-primary: #1a202c;
--text-secondary: #4a5568;
--text-tertiary: #718096;
--text-inverse: #ffffff;
/* 경계선 */
--border-light: #e2e8f0;
--border-medium: #cbd5e0;
--border-dark: #a0aec0;
/* 그림자 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* 반경 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* 간격 */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* 폰트 크기 */
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--text-4xl: 36px;
--text-5xl: 48px;
/* 폰트 두께 */
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
/* 애니메이션 */
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
}
/* ========== 기본 리셋 ========== */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: 'Pretendard', 'Malgun Gothic', 'Apple SD Gothic Neo', system-ui, sans-serif;
font-size: var(--text-base);
line-height: 1.6;
color: var(--text-primary);
background-color: var(--bg-secondary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ========== 타이포그래피 ========== */
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-lg { font-size: var(--text-lg); }
.text-xl { font-size: var(--text-xl); }
.text-2xl { font-size: var(--text-2xl); }
.text-3xl { font-size: var(--text-3xl); }
.text-4xl { font-size: var(--text-4xl); }
.text-5xl { font-size: var(--text-5xl); }
.font-light { font-weight: var(--font-light); }
.font-normal { font-weight: var(--font-normal); }
.font-medium { font-weight: var(--font-medium); }
.font-semibold { font-weight: var(--font-semibold); }
.font-bold { font-weight: var(--font-bold); }
.font-extrabold { font-weight: var(--font-extrabold); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-inverse { color: var(--text-inverse); }
/* ========== 카드 컴포넌트 ========== */
.card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
transition: var(--transition-normal);
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.card-header {
padding: var(--space-6);
border-bottom: 1px solid var(--border-light);
}
.card-body {
padding: var(--space-6);
}
.card-footer {
padding: var(--space-6);
border-top: 1px solid var(--border-light);
background: var(--bg-tertiary);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
/* ========== 버튼 컴포넌트 ========== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--font-medium);
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: var(--transition-fast);
text-decoration: none;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary-500);
color: var(--text-inverse);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-600);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--gray-100);
color: var(--text-primary);
border: 1px solid var(--border-medium);
}
.btn-secondary:hover:not(:disabled) {
background: var(--gray-200);
}
.btn-success {
background: var(--success-500);
color: var(--text-inverse);
}
.btn-success:hover:not(:disabled) {
background: var(--success-700);
}
.btn-warning {
background: var(--warning-500);
color: var(--text-inverse);
}
.btn-warning:hover:not(:disabled) {
background: var(--warning-700);
}
.btn-error {
background: var(--error-500);
color: var(--text-inverse);
}
.btn-error:hover:not(:disabled) {
background: var(--error-700);
}
.btn-sm {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
}
.btn-lg {
padding: var(--space-4) var(--space-6);
font-size: var(--text-lg);
}
/* ========== 배지 컴포넌트 ========== */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
white-space: nowrap;
}
.badge-primary {
background: var(--primary-100);
color: var(--primary-800);
}
.badge-success {
background: var(--success-50);
color: var(--success-700);
}
.badge-warning {
background: var(--warning-50);
color: var(--warning-700);
}
.badge-error {
background: var(--error-50);
color: var(--error-700);
}
.badge-gray {
background: var(--gray-100);
color: var(--gray-700);
}
/* ========== 상태 표시기 ========== */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: var(--radius-full);
margin-right: var(--space-2);
}
.status-dot.active {
background: var(--success-500);
box-shadow: 0 0 0 2px var(--success-100);
}
.status-dot.inactive {
background: var(--gray-400);
}
.status-dot.warning {
background: var(--warning-500);
box-shadow: 0 0 0 2px var(--warning-100);
}
.status-dot.error {
background: var(--error-500);
box-shadow: 0 0 0 2px var(--error-100);
}
/* ========== 그리드 시스템 ========== */
.grid {
display: grid;
gap: var(--space-6);
}
.grid-cols-1 { grid-template-columns: repeat(1, 1fr); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) {
.grid-cols-2,
.grid-cols-3,
.grid-cols-4 {
grid-template-columns: 1fr;
}
}
/* ========== 플렉스 유틸리티 ========== */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
.gap-1 { gap: var(--space-1); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
/* ========== 간격 유틸리티 ========== */
.p-1 { padding: var(--space-1); }
.p-2 { padding: var(--space-2); }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
.p-6 { padding: var(--space-6); }
.p-8 { padding: var(--space-8); }
.m-1 { margin: var(--space-1); }
.m-2 { margin: var(--space-2); }
.m-3 { margin: var(--space-3); }
.m-4 { margin: var(--space-4); }
.m-6 { margin: var(--space-6); }
.m-8 { margin: var(--space-8); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
/* ========== 반응형 유틸리티 ========== */
@media (max-width: 640px) {
.sm\:hidden { display: none; }
.sm\:text-sm { font-size: var(--text-sm); }
.sm\:p-4 { padding: var(--space-4); }
}
@media (max-width: 768px) {
.md\:hidden { display: none; }
.md\:flex-col { flex-direction: column; }
}
@media (max-width: 1024px) {
.lg\:hidden { display: none; }
}
/* ========== 애니메이션 ========== */
.fade-in {
animation: fadeIn var(--transition-normal) ease-in-out;
}
.slide-up {
animation: slideUp var(--transition-normal) ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ========== 로딩 스피너 ========== */
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--gray-200);
border-top: 2px solid var(--primary-500);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
<!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=2026031401">
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<script src="/js/app-init.js?v=2026031401" 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/cross-nav.js?v=2026031401"></script>
<script src="/js/chat-report.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,478 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>신고 상세 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/common.css?v=2026031401">
<link rel="stylesheet" href="/css/project-management.css?v=2026031401">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<script src="/js/app-init.js?v=2026031401" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 상태 배지 */
.status-badge {
display: inline-block;
padding: 0.375rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
}
.status-badge.reported { background: #dbeafe; color: #1d4ed8; }
.status-badge.received { background: #fed7aa; color: #c2410c; }
.status-badge.in_progress { background: #e9d5ff; color: #7c3aed; }
.status-badge.completed { background: #d1fae5; color: #047857; }
.status-badge.closed { background: #f3f4f6; color: #4b5563; }
/* 유형 배지 */
.type-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.type-badge.nonconformity { background: #fff7ed; color: #c2410c; }
.type-badge.safety { background: #fef2f2; color: #b91c1c; }
.type-badge.facility { background: #eff6ff; color: #1d4ed8; }
/* 심각도 배지 */
.severity-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.severity-badge.critical { background: #fef2f2; color: #b91c1c; }
.severity-badge.high { background: #fff7ed; color: #c2410c; }
.severity-badge.medium { background: #fefce8; color: #a16207; }
.severity-badge.low { background: #f3f4f6; color: #4b5563; }
/* 상세 섹션 */
.detail-section {
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
/* 정보 그리드 */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.info-item {
padding: 0.875rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.info-label {
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.info-value {
font-size: 0.9375rem;
font-weight: 500;
color: #1f2937;
}
/* 사진 갤러리 */
.photo-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.photo-item {
aspect-ratio: 1;
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
border: 1px solid #e5e7eb;
}
.photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s;
}
.photo-item:hover img {
transform: scale(1.05);
}
/* 상태 타임라인 */
.status-timeline {
position: relative;
padding-left: 1.5rem;
}
.status-timeline::before {
content: '';
position: absolute;
left: 0.375rem;
top: 0;
bottom: 0;
width: 2px;
background: #e5e7eb;
}
.timeline-item {
position: relative;
padding-bottom: 1rem;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item::before {
content: '';
position: absolute;
left: -1.125rem;
top: 0.25rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: #3b82f6;
}
.timeline-status {
font-weight: 600;
margin-bottom: 0.25rem;
color: #1f2937;
}
.timeline-meta {
font-size: 0.875rem;
color: #6b7280;
}
/* 액션 버튼 */
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
.action-btn {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
border: 1px solid #d1d5db;
background: white;
transition: all 0.2s;
}
.action-btn:hover { background: #f9fafb; }
.action-btn.primary { background: #3b82f6; color: white; border-color: #3b82f6; }
.action-btn.primary:hover { background: #2563eb; }
.action-btn.success { background: #10b981; color: white; border-color: #10b981; }
.action-btn.success:hover { background: #059669; }
.action-btn.danger { background: #ef4444; color: white; border-color: #ef4444; }
.action-btn.danger:hover { background: #dc2626; }
/* 모달 */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.visible { display: flex; }
.modal-content {
background: white;
padding: 1.5rem;
border-radius: 0.75rem;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1.25rem;
}
.modal-form-group {
margin-bottom: 1rem;
}
.modal-form-group label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.modal-form-group input,
.modal-form-group select,
.modal-form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
}
.modal-form-group input:focus,
.modal-form-group select:focus,
.modal-form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.25rem;
}
/* 사진 확대 모달 */
.photo-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 1001;
align-items: center;
justify-content: center;
}
.photo-modal.visible { display: flex; }
.photo-modal img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
.photo-modal-close {
position: absolute;
top: 1.25rem;
right: 1.25rem;
color: white;
font-size: 2rem;
cursor: pointer;
}
/* 뒤로가기 링크 */
.back-link {
color: #3b82f6;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.back-link:hover {
text-decoration: underline;
}
/* 헤더 */
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.detail-id {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.5rem;
}
.detail-title {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
}
/* 반응형 */
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
}
.detail-header {
flex-direction: column;
gap: 1rem;
}
.action-buttons {
flex-direction: column;
}
.action-btn {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<div class="work-report-container">
<div id="navbar-container"></div>
<main class="work-report-main">
<div class="dashboard-main">
<a href="#" class="back-link" onclick="goBackToList(); return false;">
&#8592; 목록으로
</a>
<div class="detail-header">
<div>
<div class="detail-id" id="reportId"></div>
<h1 class="detail-title" id="reportTitle">로딩 중...</h1>
</div>
<span class="status-badge" id="statusBadge"></span>
</div>
<!-- 기본 정보 -->
<div class="detail-section">
<h2 class="section-title">신고 정보</h2>
<div class="info-grid" id="basicInfo"></div>
</div>
<!-- 신고 내용 -->
<div class="detail-section">
<h2 class="section-title">신고 내용</h2>
<div id="issueContent"></div>
</div>
<!-- 사진 -->
<div class="detail-section" id="photoSection" style="display: none;">
<h2 class="section-title">첨부 사진</h2>
<div class="photo-gallery" id="photoGallery"></div>
</div>
<!-- 처리 정보 -->
<div class="detail-section" id="processSection" style="display: none;">
<h2 class="section-title">처리 정보</h2>
<div id="processInfo"></div>
</div>
<!-- 상태 이력 -->
<div class="detail-section">
<h2 class="section-title">상태 변경 이력</h2>
<div class="status-timeline" id="statusTimeline"></div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons" id="actionButtons"></div>
</div>
</main>
<!-- 담당자 배정 모달 -->
<div class="modal-overlay" id="assignModal">
<div class="modal-content">
<h3 class="modal-title">담당자 배정</h3>
<div class="modal-form-group">
<label>담당 부서</label>
<input type="text" id="assignDepartment" placeholder="담당 부서 입력">
</div>
<div class="modal-form-group">
<label>담당자</label>
<select id="assignUser">
<option value="">담당자 선택</option>
</select>
</div>
<div class="modal-actions">
<button class="action-btn" onclick="closeAssignModal()">취소</button>
<button class="action-btn primary" onclick="submitAssign()">배정</button>
</div>
</div>
</div>
<!-- 처리 완료 모달 -->
<div class="modal-overlay" id="completeModal">
<div class="modal-content">
<h3 class="modal-title">처리 완료</h3>
<div class="modal-form-group">
<label>처리 내용</label>
<textarea id="resolutionNotes" rows="4" placeholder="처리 내용을 입력하세요"></textarea>
</div>
<div class="modal-actions">
<button class="action-btn" onclick="closeCompleteModal()">취소</button>
<button class="action-btn success" onclick="submitComplete()">완료 처리</button>
</div>
</div>
</div>
<!-- 유형 이관 모달 -->
<div class="modal-overlay" id="transferModal">
<div class="modal-content">
<h3 class="modal-title">유형 이관</h3>
<div class="modal-form-group">
<label>이관할 유형</label>
<select id="transferCategoryType">
<option value="">유형 선택</option>
<option value="safety">안전</option>
<option value="facility">시설설비</option>
<option value="nonconformity">부적합</option>
</select>
</div>
<p style="font-size: 0.8125rem; color: #6b7280; margin-top: 0.5rem;">
유형을 변경하면 해당 유형의 목록에서 조회됩니다. 원래 카테고리/항목 정보는 유지됩니다.
</p>
<div class="modal-actions">
<button class="action-btn" onclick="closeTransferModal()">취소</button>
<button class="action-btn primary" onclick="submitTransfer()">이관</button>
</div>
</div>
</div>
<!-- 사진 확대 모달 -->
<div class="photo-modal" id="photoModal" onclick="closePhotoModal()">
<span class="photo-modal-close">&times;</span>
<img id="photoModalImg" src="" alt="">
</div>
</div>
<script src="/js/issue-detail.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,788 @@
<!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>신고 등록 | (주)테크니컬코리아</title>
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<script src="/js/app-init.js?v=2026031401" defer></script>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
background: #f3f4f6;
margin: 0;
padding: 0;
padding-bottom: env(safe-area-inset-bottom);
-webkit-font-smoothing: antialiased;
}
/* Step indicator */
.step-indicator {
display: flex;
justify-content: center;
align-items: center;
padding: 0.75rem 0.5rem;
gap: 0;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.step {
display: flex;
align-items: center;
gap: 0.125rem;
font-size: 0.625rem;
color: #9ca3af;
}
.step .step-dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
background: #e5e7eb;
color: #9ca3af;
flex-shrink: 0;
}
.step.active .step-dot { background: #ef4444; color: white; }
.step.active { color: #ef4444; font-weight: 600; }
.step.completed .step-dot { background: #10b981; color: white; }
.step.completed { color: #10b981; }
.step-line {
width: 16px;
height: 2px;
background: #e5e7eb;
margin: 0 0.125rem;
flex-shrink: 0;
}
/* Sections */
.report-section {
margin: 0.75rem;
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.section-title {
font-size: 0.9375rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-title .sn {
width: 22px;
height: 22px;
border-radius: 50%;
background: #ef4444;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
/* Type buttons */
.type-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.625rem;
}
.type-btn {
padding: 1.125rem 0.5rem;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
background: white;
cursor: pointer;
text-align: center;
transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.type-btn:active { transform: scale(0.97); }
.type-btn .type-icon { font-size: 1.75rem; margin-bottom: 0.5rem; }
.type-btn .type-label { font-size: 0.8125rem; font-weight: 600; color: #374151; }
.type-btn.selected { border-color: #ef4444; background: #fef2f2; }
.type-btn.selected .type-label { color: #dc2626; }
/* Map */
.factory-select {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
margin-bottom: 0.75rem;
background: white;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
.map-container {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.75rem;
}
.map-container canvas { width: 100%; display: block; }
.location-info {
padding: 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
font-size: 0.8125rem;
color: #166534;
margin-bottom: 0.75rem;
line-height: 1.5;
}
.location-info.empty {
background: #f9fafb;
border-color: #e5e7eb;
color: #9ca3af;
}
.custom-location-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.custom-location-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #ef4444;
}
#customLocationInput {
display: none;
margin-top: 0.5rem;
}
#customLocationInput.visible { display: block; }
#customLocationInput input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
}
#customLocationInput input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239,68,68,0.1);
}
/* Project/Work selection - Accordion */
.project-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-group {
border: 1.5px solid #e5e7eb;
border-radius: 0.75rem;
overflow: hidden;
}
.project-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
background: #f9fafb;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.project-group-header:active { background: #f3f4f6; }
.project-group-header .group-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.project-group-header .group-icon {
font-size: 1.125rem;
flex-shrink: 0;
}
.project-group-header .group-title {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.project-group-header .group-count {
font-size: 0.6875rem;
background: #e5e7eb;
color: #6b7280;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-weight: 600;
}
.project-group-header .group-arrow {
font-size: 0.75rem;
color: #9ca3af;
transition: transform 0.2s;
}
.project-group.open .group-arrow { transform: rotate(180deg); }
.project-group.tbm-group { border-color: #93c5fd; }
.project-group.tbm-group .project-group-header { background: #eff6ff; }
.project-group.tbm-group .group-count { background: #dbeafe; color: #1d4ed8; }
.project-group-body {
display: none;
border-top: 1px solid #e5e7eb;
}
.project-group.open .project-group-body { display: block; }
.project-card {
padding: 0.875rem 1rem;
cursor: pointer;
transition: background 0.12s;
-webkit-tap-highlight-color: transparent;
border-bottom: 1px solid #f3f4f6;
}
.project-card:last-child { border-bottom: none; }
.project-card:active { background: #f9fafb; }
.project-card.selected { background: #f5f3ff; }
.project-card-title {
font-size: 0.8125rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.125rem;
}
.project-card-desc {
font-size: 0.75rem;
color: #6b7280;
}
.project-card .tbm-info {
font-size: 0.6875rem;
color: #2563eb;
margin-top: 0.25rem;
}
/* 프로젝트 모름 */
.project-skip {
padding: 0.875rem 1rem;
border: 1.5px dashed #d1d5db;
border-radius: 0.75rem;
text-align: center;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.project-skip:active { background: #f9fafb; }
.project-skip.selected {
border-color: #8b5cf6;
border-style: solid;
background: #f5f3ff;
color: #7c3aed;
font-weight: 600;
}
.project-empty {
text-align: center;
padding: 1.5rem 1rem;
color: #9ca3af;
font-size: 0.8125rem;
}
/* 선택된 항목 표시 */
.project-group-header .group-selected {
font-size: 0.6875rem;
color: #7c3aed;
font-weight: 600;
margin-left: 0.25rem;
}
/* Category & Item */
#categoryContainer { display: none; }
.subsection-title {
font-size: 0.8125rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.5rem;
}
.category-grid, #itemGrid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.category-btn, .item-btn {
padding: 0.5rem 0.875rem;
border: 1.5px solid #d1d5db;
border-radius: 2rem;
background: white;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.12s;
-webkit-tap-highlight-color: transparent;
white-space: nowrap;
}
.category-btn:active, .item-btn:active { transform: scale(0.97); }
.category-btn.selected {
border-color: #ef4444;
background: #fef2f2;
color: #dc2626;
font-weight: 600;
}
.item-btn.selected {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
.item-btn.custom-input-btn {
border-style: dashed;
color: #6b7280;
}
.item-section {
margin-top: 1rem;
padding-top: 0.875rem;
border-top: 1px solid #f3f4f6;
}
#customItemInput {
display: none;
margin-top: 0.75rem;
gap: 0.5rem;
align-items: center;
}
#customItemInput input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
}
#customItemInput input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239,68,68,0.1);
}
#customItemInput button {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.custom-confirm { background: #2563eb; color: white; }
.custom-cancel { background: #f3f4f6; color: #374151; }
/* Photo & Details */
.photo-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.photo-slot {
aspect-ratio: 1;
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
overflow: hidden;
background: #f9fafb;
-webkit-tap-highlight-color: transparent;
}
.photo-slot .add-icon { font-size: 1.25rem; color: #9ca3af; }
.photo-slot.has-photo { border-style: solid; border-color: #10b981; }
.photo-slot.has-photo .add-icon { display: none; }
.photo-slot img { width: 100%; height: 100%; object-fit: cover; }
.photo-slot .remove-btn {
display: none;
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0,0,0,0.6);
color: white;
border: none;
font-size: 0.625rem;
cursor: pointer;
align-items: center;
justify-content: center;
z-index: 2;
line-height: 1;
}
.photo-slot.has-photo .remove-btn { display: flex; }
.photo-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-bottom: 0.75rem;
}
.description-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.description-textarea:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239,68,68,0.1);
}
/* Submit */
.submit-section {
padding: 0.75rem;
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
#submitBtn {
width: 100%;
padding: 0.9375rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
-webkit-tap-highlight-color: transparent;
}
#submitBtn:disabled { background: #d1d5db; cursor: not-allowed; }
#submitBtn:not(:disabled):active { background: #dc2626; }
/* 가로모드 전체화면 지도 오버레이 */
.landscape-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
}
.landscape-inner {
display: flex;
flex-direction: column;
background: #fff;
overflow: hidden;
}
.landscape-inner.rotated {
width: 100vh;
height: 100vw;
transform: translate(-50%, -50%) rotate(90deg);
position: absolute;
top: 50%;
left: 50%;
}
.landscape-inner.no-rotate {
width: 100vw;
height: 100vh;
}
.landscape-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
flex-shrink: 0;
}
.landscape-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.landscape-close-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.landscape-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #f1f5f9;
padding: 0.5rem;
}
.landscape-canvas-wrap canvas {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.landscape-trigger-btn {
display: none;
}
@media (max-width: 768px) {
.landscape-trigger-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: white;
border: none;
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
margin-top: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.landscape-trigger-btn:active {
transform: scale(0.97);
opacity: 0.85;
}
}
/* 성공 모달 */
.success-modal-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 10001;
background: rgba(0,0,0,0.5);
align-items: center;
justify-content: center;
padding: 1rem;
}
.success-modal {
background: white;
border-radius: 1rem;
padding: 2rem 1.5rem;
text-align: center;
max-width: 320px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.success-modal .success-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: #d1fae5;
color: #059669;
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
}
.success-modal h3 {
font-size: 1.125rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 0.5rem;
}
.success-modal p {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 1.5rem;
}
.success-modal .modal-buttons {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.success-modal .btn-primary {
padding: 0.75rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.625rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.success-modal .btn-primary:active { background: #dc2626; }
.success-modal .btn-secondary {
padding: 0.75rem;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 0.625rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.success-modal .btn-secondary:active { background: #e5e7eb; }
/* Responsive */
@media (min-width: 480px) {
body { max-width: 480px; margin: 0 auto; min-height: 100vh; }
}
@media (max-width: 480px) {
.photo-grid { grid-template-columns: repeat(4, 1fr); }
.type-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 360px) {
.type-grid { gap: 0.5rem; }
.type-btn { padding: 0.875rem 0.375rem; }
.type-btn .type-icon { font-size: 1.5rem; }
.photo-grid { gap: 0.375rem; }
}
</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>
<div class="step-line"></div>
<div class="step"><span class="step-dot">2</span><span>위치</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">3</span><span>작업</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">4</span><span>항목</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">5</span><span>사진</span></div>
</div>
<!-- Step 1: Type Selection -->
<div class="report-section">
<div class="section-title"><span class="sn">1</span>신고 유형</div>
<div class="type-grid">
<button type="button" class="type-btn" data-type="nonconformity">
<div class="type-icon">&#128270;</div>
<div class="type-label">부적합</div>
</button>
<button type="button" class="type-btn" data-type="facility">
<div class="type-icon">&#128295;</div>
<div class="type-label">시설설비</div>
</button>
<button type="button" class="type-btn" data-type="safety">
<div class="type-icon">&#9888;&#65039;</div>
<div class="type-label">안전</div>
</button>
</div>
</div>
<!-- Step 2: Location -->
<div class="report-section">
<div class="section-title"><span class="sn">2</span>위치 선택</div>
<select id="factorySelect" class="factory-select">
<option value="">공장 선택</option>
</select>
<div class="map-container">
<canvas id="issueMapCanvas"></canvas>
</div>
<button type="button" class="landscape-trigger-btn" id="landscapeTriggerBtn" onclick="openLandscapeMap()" style="display:none;">
&#128250; 전체화면 지도로 선택
</button>
<div id="selectedLocationInfo" class="location-info empty">
지도에서 작업장을 클릭하여 위치를 선택하세요
</div>
<label class="custom-location-toggle">
<input type="checkbox" id="useCustomLocation">
기타 위치 직접 입력
</label>
<div id="customLocationInput">
<input type="text" id="customLocation" placeholder="위치를 입력하세요">
</div>
</div>
<!-- Step 3: Project/Work Selection -->
<div class="report-section" id="projectContainer">
<div class="section-title"><span class="sn">3</span>프로젝트/작업 선택</div>
<div id="projectList" class="project-list">
<div class="project-empty">위치를 먼저 선택하세요</div>
</div>
</div>
<!-- Step 4: Category & Item -->
<div class="report-section" id="categoryContainer">
<div class="section-title"><span class="sn">4</span>세부 항목</div>
<div class="subsection-title">카테고리</div>
<div class="category-grid" id="categoryGrid"></div>
<div class="item-section">
<div class="subsection-title">항목</div>
<div id="itemGrid"></div>
<div id="customItemInput">
<input type="text" id="customItemName" placeholder="항목명을 입력하세요">
<button type="button" class="custom-confirm" onclick="confirmCustomItem()">확인</button>
<button type="button" class="custom-cancel" onclick="cancelCustomItem()">취소</button>
</div>
</div>
</div>
<!-- Step 5: Photos + Description -->
<div class="report-section">
<div class="section-title"><span class="sn">5</span>사진 및 상세</div>
<div class="photo-grid">
<div class="photo-slot" data-index="0"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
<div class="photo-slot" data-index="1"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
<div class="photo-slot" data-index="2"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
<div class="photo-slot" data-index="3"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
<div class="photo-slot" data-index="4"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
</div>
<div class="photo-hint">* 사진 1장 이상 필수 (최대 5장, 카메라 촬영 또는 앨범에서 선택)</div>
<textarea id="additionalDescription" class="description-textarea" placeholder="추가 설명을 입력하세요 (선택사항)"></textarea>
</div>
<!-- Submit -->
<div class="submit-section">
<button type="button" id="submitBtn" disabled onclick="submitReport()">신고 제출</button>
</div>
<!-- 가로모드 전체화면 지도 오버레이 -->
<div id="landscapeOverlay" class="landscape-overlay" style="display:none;">
<div id="landscapeInner" class="landscape-inner">
<div class="landscape-header">
<h3>&#127981; 작업장 선택</h3>
<button type="button" class="landscape-close-btn" onclick="closeLandscapeMap()">×</button>
</div>
<div class="landscape-canvas-wrap">
<canvas id="landscapeCanvas"></canvas>
</div>
</div>
</div>
<!-- 성공 모달 -->
<div id="successModal" class="success-modal-overlay">
<div class="success-modal">
<div class="success-icon">&#10003;</div>
<h3>신고가 성공적으로 등록되었습니다!</h3>
<p>등록된 신고는 담당자가 확인 후 처리합니다.</p>
<div class="modal-buttons">
<button class="btn-primary" onclick="window.location.href='/pages/safety/report-status.html'">신고 현황 보기</button>
<button class="btn-secondary" onclick="window.location.href='/pages/safety/issue-report.html'">새 신고하기</button>
</div>
</div>
</div>
<!-- Hidden file input for camera/gallery -->
<input type="file" id="photoInput" accept="image/*" style="display:none">
<script src="/js/cross-nav.js?v=2026031401"></script>
<script src="/js/issue-report.js?v=2026040901"></script>
</body>
</html>

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>안전신고 현황 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/common.css?v=2026031401">
<link rel="stylesheet" href="/css/project-management.css?v=2026031401">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<script src="/js/app-init.js?v=2026031401" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: white;
padding: 1.25rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
}
.stat-card.reported .stat-number { color: #3b82f6; }
.stat-card.received .stat-number { color: #f97316; }
.stat-card.in_progress .stat-number { color: #8b5cf6; }
.stat-card.completed .stat-number { color: #10b981; }
/* 필터 바 */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.filter-bar select,
.filter-bar input {
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
}
.filter-bar select:focus,
.filter-bar input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.btn-new-report {
margin-left: auto;
padding: 0.625rem 1.25rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: background 0.2s;
}
.btn-new-report:hover {
background: #dc2626;
}
/* 신고 목록 */
.issue-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.issue-card {
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.issue-card:hover {
border-color: #fecaca;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.issue-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.issue-id {
font-size: 0.875rem;
color: #9ca3af;
}
.issue-status {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.issue-status.reported {
background: #dbeafe;
color: #1d4ed8;
}
.issue-status.received {
background: #fed7aa;
color: #c2410c;
}
.issue-status.in_progress {
background: #e9d5ff;
color: #7c3aed;
}
.issue-status.completed {
background: #d1fae5;
color: #047857;
}
.issue-status.closed {
background: #f3f4f6;
color: #4b5563;
}
.issue-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #1f2937;
}
.issue-category-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
margin-right: 0.5rem;
background: #fef2f2;
color: #b91c1c;
}
.issue-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: #6b7280;
}
.issue-meta-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
.issue-photos {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.issue-photos img {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
}
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 4rem 1.5rem;
color: #6b7280;
background: white;
border-radius: 0.75rem;
}
.empty-state-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #374151;
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.btn-new-report {
width: 100%;
justify-content: center;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</head>
<body>
<div class="work-report-container">
<div id="navbar-container"></div>
<main class="work-report-main">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">안전신고 현황</h1>
<p class="page-description">보호구 미착용, 위험구역 출입 등 안전 관련 신고 현황입니다.</p>
</div>
</div>
<!-- 통계 카드 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card reported">
<div class="stat-number" id="statReported">-</div>
<div class="stat-label">신고</div>
</div>
<div class="stat-card received">
<div class="stat-number" id="statReceived">-</div>
<div class="stat-label">접수</div>
</div>
<div class="stat-card in_progress">
<div class="stat-number" id="statProgress">-</div>
<div class="stat-label">처리중</div>
</div>
<div class="stat-card completed">
<div class="stat-number" id="statCompleted">-</div>
<div class="stat-label">완료</div>
</div>
</div>
<!-- 필터 바 -->
<div class="filter-bar">
<select id="filterStatus">
<option value="">전체 상태</option>
<option value="reported">신고</option>
<option value="received">접수</option>
<option value="in_progress">처리중</option>
<option value="completed">완료</option>
<option value="closed">종료</option>
</select>
<input type="date" id="filterStartDate" title="시작일">
<input type="date" id="filterEndDate" title="종료일">
<a href="/pages/safety/chat-report.html" class="btn-ai-report" style="padding:0.625rem 1rem;background:linear-gradient(135deg,#0ea5e9,#0284c7);color:white;border:none;border-radius:0.5rem;font-size:0.875rem;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:0.5rem;transition:opacity 0.2s;">🤖 AI 신고</a>
<a href="/pages/safety/issue-report.html" class="btn-new-report">+ 신고하기</a>
</div>
<!-- 신고 목록 -->
<div class="issue-list" id="issueList">
<div class="empty-state">
<div class="empty-state-title">로딩 중...</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/cross-nav.js?v=2026031401"></script>
<script src="/js/safety-report-list.js?v=2026031401"></script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
// Push Notification Service Worker
// 캐싱 없음 — Push 수신 전용
self.addEventListener('push', function(event) {
var data = { title: '알림', body: '새 알림이 있습니다.', url: '/' };
if (event.data) {
try { data = Object.assign(data, event.data.json()); } catch(e) {}
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/static/img/icon-192.png',
badge: '/static/img/badge-72.png',
data: { url: data.url || '/' },
tag: 'tk-notification-' + Date.now(),
renotify: true
})
);
// 메인 페이지에 뱃지 갱신 신호 전송
self.clients.matchAll({ type: 'window' }).then(function(clients) {
clients.forEach(function(client) {
client.postMessage({ type: 'NOTIFICATION_RECEIVED' });
});
});
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var url = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then(function(clients) {
for (var i = 0; i < clients.length; i++) {
if (clients[i].url.includes(self.location.origin)) {
clients[i].navigate(url);
return clients[i].focus();
}
}
return self.clients.openWindow(url);
})
);
});