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,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>