diff --git a/system3-nonconformance/api/database/database.py b/system3-nonconformance/api/database/database.py
index a647323..5a3a77f 100644
--- a/system3-nonconformance/api/database/database.py
+++ b/system3-nonconformance/api/database/database.py
@@ -14,7 +14,7 @@ DB_NAME = os.getenv("DB_NAME", "hyungi")
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
-engine = create_engine(DATABASE_URL, pool_recycle=3600)
+engine = create_engine(DATABASE_URL, pool_recycle=3600, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
diff --git a/system3-nonconformance/api/routers/issues.py b/system3-nonconformance/api/routers/issues.py
index 791d031..a8d1b75 100644
--- a/system3-nonconformance/api/routers/issues.py
+++ b/system3-nonconformance/api/routers/issues.py
@@ -140,23 +140,26 @@ async def update_issue(
# 업데이트
update_data = issue_update.dict(exclude_unset=True)
- # 사진 업데이트 처리 (최대 5장)
+ # 사진 업데이트 처리 (최대 5장) - 새 사진 저장 후 기존 사진 삭제 (안전)
+ old_photos_to_delete = []
for i in range(1, 6):
photo_field = f"photo{i if i > 1 else ''}"
path_field = f"photo_path{i if i > 1 else ''}"
if photo_field in update_data:
- # 기존 사진 삭제
existing_path = getattr(issue, path_field, None)
- if existing_path:
- delete_file(existing_path)
# 새 사진 저장
if update_data[photo_field]:
new_path = save_base64_image(update_data[photo_field])
update_data[path_field] = new_path
+ # 새 사진 저장 성공 시에만 기존 사진 삭제 예약
+ if new_path and existing_path:
+ old_photos_to_delete.append(existing_path)
else:
update_data[path_field] = None
+ if existing_path:
+ old_photos_to_delete.append(existing_path)
# photo 필드는 제거 (DB에는 photo_path만 저장)
del update_data[photo_field]
@@ -171,6 +174,14 @@ async def update_issue(
db.commit()
db.refresh(issue)
+
+ # DB 커밋 성공 후 기존 사진 파일 삭제
+ for old_path in old_photos_to_delete:
+ try:
+ delete_file(old_path)
+ except Exception:
+ pass
+
return issue
@router.delete("/{issue_id}")
@@ -456,15 +467,8 @@ async def reject_completion_request(
raise HTTPException(status_code=403, detail="완료 반려 권한이 없습니다.")
try:
- # 완료 사진 파일 삭제 (최대 5장)
- for i in range(1, 6):
- path_field = f"completion_photo_path{i if i > 1 else ''}"
- photo_path = getattr(issue, path_field, None)
- if photo_path:
- try:
- delete_file(photo_path)
- except Exception:
- pass
+ # 완료 사진은 파일 삭제하지 않음 (재신청 시 참고용으로 보존)
+ # DB 참조만 초기화
# 완료 신청 정보 초기화
issue.completion_requested_at = None
diff --git a/system3-nonconformance/web/issues-dashboard.html b/system3-nonconformance/web/issues-dashboard.html
index 263dce3..1348070 100644
--- a/system3-nonconformance/web/issues-dashboard.html
+++ b/system3-nonconformance/web/issues-dashboard.html
@@ -4,6 +4,7 @@
부적합 현황판
+
diff --git a/system3-nonconformance/web/issues-inbox.html b/system3-nonconformance/web/issues-inbox.html
index 8d11dda..dd02ecc 100644
--- a/system3-nonconformance/web/issues-inbox.html
+++ b/system3-nonconformance/web/issues-inbox.html
@@ -4,6 +4,7 @@
수신함 - 작업보고서
+
diff --git a/system3-nonconformance/web/issues-management.html b/system3-nonconformance/web/issues-management.html
index 3bf93de..8ffcfe7 100644
--- a/system3-nonconformance/web/issues-management.html
+++ b/system3-nonconformance/web/issues-management.html
@@ -4,6 +4,7 @@
관리함 - 작업보고서
+
diff --git a/system3-nonconformance/web/m/dashboard.html b/system3-nonconformance/web/m/dashboard.html
new file mode 100644
index 0000000..3775e42
--- /dev/null
+++ b/system3-nonconformance/web/m/dashboard.html
@@ -0,0 +1,195 @@
+
+
+
+
+
+ 부적합 현황판
+
+
+
+
+
+
+
+
+
+
+
+
+
전체 0
+
오늘 신규 0
+
완료 대기 0
+
지연 0
+
+
+
+
+
+ 전체 프로젝트
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/system3-nonconformance/web/m/inbox.html b/system3-nonconformance/web/m/inbox.html
new file mode 100644
index 0000000..5623584
--- /dev/null
+++ b/system3-nonconformance/web/m/inbox.html
@@ -0,0 +1,203 @@
+
+
+
+
+
+ 수신함
+
+
+
+
+
+
+
+
+
+
+
+
+
금일 신규 0
+
금일 처리 0
+
미해결 0
+
+
+
+
+
+ 전체 프로젝트
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 폐기 사유
+
+ 중복
+ 잘못된 신고
+ 해당 없음
+ 스팸/오류
+ 직접 입력
+
+
+
+ 사용자 정의 사유
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 프로젝트
+
+
+
+ 분류
+
+ 자재 누락
+ 설계 오류
+ 반입 불량
+ 검사 누락
+ 품질
+ 안전
+ 환경
+ 공정
+ 장비
+ 자재
+ 기타
+
+
+
+ 부적합명
+
+
+
+ 상세 설명
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 해결방안
+
+
+
+ 담당부서
+
+ 선택하세요
+ 생산
+ 품질
+ 구매
+ 설계
+ 영업
+
+
+
+ 담당자
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/system3-nonconformance/web/m/management.html b/system3-nonconformance/web/m/management.html
new file mode 100644
index 0000000..8281b19
--- /dev/null
+++ b/system3-nonconformance/web/m/management.html
@@ -0,0 +1,179 @@
+
+
+
+
+
+ 관리함
+
+
+
+
+
+
+
+
+
+
+
+
+ 진행 중
+ 완료됨
+
+
+
+
+
전체 0
+
진행 중 0
+
완료 대기 0
+
완료됨 0
+
+
+
+
+
+ 전체 프로젝트
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 해결방안 (확정)
+
+
+
+ 담당부서
+
+ 선택하세요
+ 생산
+ 품질
+ 구매
+ 설계
+ 영업
+
+
+
+ 담당자
+
+
+
+ 조치 예상일
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 대상 이슈 선택
+
+
+
+ 원인부서
+
+ 선택하세요
+ 생산
+ 품질
+ 구매
+ 설계
+ 영업
+
+
+
+ 해당자
+
+
+
+ 원인 상세
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/system3-nonconformance/web/nginx.conf b/system3-nonconformance/web/nginx.conf
index 9d16821..87f54ba 100644
--- a/system3-nonconformance/web/nginx.conf
+++ b/system3-nonconformance/web/nginx.conf
@@ -48,6 +48,16 @@ server {
proxy_buffering off;
}
+ # 모바일 전용 페이지
+ location /m/ {
+ alias /usr/share/nginx/html/m/;
+ try_files $uri $uri/ /m/dashboard.html;
+ location ~* \.html$ {
+ expires -1;
+ add_header Cache-Control "no-store, no-cache, must-revalidate";
+ }
+ }
+
# 업로드 파일
location /uploads/ {
alias /usr/share/nginx/html/uploads/;
diff --git a/system3-nonconformance/web/static/css/issues-management.css b/system3-nonconformance/web/static/css/issues-management.css
index f3ace95..352a8f7 100644
--- a/system3-nonconformance/web/static/css/issues-management.css
+++ b/system3-nonconformance/web/static/css/issues-management.css
@@ -91,13 +91,14 @@
/* 관리함 전용 collapse-content (max-height 기반 트랜지션) */
.collapse-content {
- max-height: 1000px;
- overflow: hidden;
+ max-height: 5000px;
+ overflow: visible;
transition: max-height 0.3s ease-out;
}
.collapse-content.collapsed {
max-height: 0;
+ overflow: hidden;
}
/* 관리함 전용 이슈 카드 오버라이드 */
@@ -121,3 +122,62 @@
width: 16px;
text-align: center;
}
+
+/* ===== 카드 헤더 반응형 ===== */
+.issue-card-header {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.issue-card-header .header-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.issue-card-header .header-meta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.issue-card-header .header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.issue-card-header .header-actions button {
+ white-space: nowrap;
+}
+
+/* 중간 화면에서 버튼 줄바꿈 */
+@media (max-width: 1280px) {
+ .issue-card-header .header-top {
+ flex-direction: column;
+ }
+
+ .issue-card-header .header-actions {
+ width: 100%;
+ justify-content: flex-end;
+ }
+}
+
+/* 완료됨 카드 3열 → 좁은 화면에서 적응 */
+@media (max-width: 1280px) and (min-width: 769px) {
+ .completed-card-grid {
+ grid-template-columns: 1fr 1fr !important;
+ }
+}
+
+@media (max-width: 960px) and (min-width: 769px) {
+ .completed-card-grid {
+ grid-template-columns: 1fr !important;
+ }
+}
diff --git a/system3-nonconformance/web/static/css/m-common.css b/system3-nonconformance/web/static/css/m-common.css
new file mode 100644
index 0000000..d131065
--- /dev/null
+++ b/system3-nonconformance/web/static/css/m-common.css
@@ -0,0 +1,489 @@
+/* m-common.css — TKQC 모바일 공통 스타일 */
+
+/* ===== Reset & Base ===== */
+*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+html { -webkit-text-size-adjust: 100%; }
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: #f3f4f6;
+ color: #1f2937;
+ padding-top: 48px;
+ padding-bottom: calc(64px + env(safe-area-inset-bottom));
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior-y: contain;
+}
+input, select, textarea, button { font-family: inherit; font-size: 16px; touch-action: manipulation; }
+button { cursor: pointer; border: none; background: none; padding: 0; }
+
+/* ===== Fixed Header ===== */
+.m-header {
+ position: fixed; top: 0; left: 0; right: 0;
+ height: 48px; z-index: 100;
+ background: #fff; border-bottom: 1px solid #e5e7eb;
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 0 16px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
+}
+.m-header-title {
+ font-size: 17px; font-weight: 700; color: #111827;
+ display: flex; align-items: center; gap: 8px;
+}
+.m-header-actions { display: flex; align-items: center; gap: 8px; }
+.m-header-btn {
+ width: 36px; height: 36px; border-radius: 8px;
+ display: flex; align-items: center; justify-content: center;
+ color: #6b7280; transition: background 0.15s;
+}
+.m-header-btn:active { background: #f3f4f6; }
+
+/* ===== Bottom Navigation ===== */
+.m-bottom-nav {
+ position: fixed; bottom: 0; left: 0; right: 0;
+ height: calc(64px + env(safe-area-inset-bottom));
+ background: #fff; border-top: 1px solid #e5e7eb;
+ display: flex; z-index: 100;
+ padding-bottom: env(safe-area-inset-bottom);
+ box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
+}
+.m-nav-item {
+ flex: 1; display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ text-decoration: none; color: #9ca3af;
+ font-size: 10px; font-weight: 500; gap: 2px;
+ position: relative; min-height: 44px;
+ transition: color 0.15s;
+}
+.m-nav-item i { font-size: 20px; }
+.m-nav-item.active { color: #2563eb; font-weight: 700; }
+.m-nav-item.active::before {
+ content: ''; position: absolute; top: 2px;
+ width: 4px; height: 4px; border-radius: 50%; background: #2563eb;
+}
+.m-nav-item.highlight { color: #f97316; }
+.m-nav-item.highlight.active { color: #f97316; }
+.m-nav-item:active { opacity: 0.7; }
+
+/* ===== Stats Bar ===== */
+.m-stats-bar {
+ display: flex; gap: 8px; padding: 12px 16px;
+ overflow-x: auto; -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+}
+.m-stats-bar::-webkit-scrollbar { display: none; }
+.m-stat-pill {
+ flex-shrink: 0; padding: 8px 14px; border-radius: 20px;
+ background: #fff; border: 1px solid #e5e7eb;
+ display: flex; align-items: center; gap: 6px;
+ font-size: 13px; font-weight: 500; color: #6b7280;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04);
+}
+.m-stat-pill .m-stat-value {
+ font-weight: 700; font-size: 15px; color: #111827;
+}
+.m-stat-pill.blue { border-color: #93c5fd; background: #eff6ff; }
+.m-stat-pill.blue .m-stat-value { color: #2563eb; }
+.m-stat-pill.green { border-color: #86efac; background: #f0fdf4; }
+.m-stat-pill.green .m-stat-value { color: #16a34a; }
+.m-stat-pill.amber { border-color: #fcd34d; background: #fffbeb; }
+.m-stat-pill.amber .m-stat-value { color: #d97706; }
+.m-stat-pill.red { border-color: #fca5a5; background: #fef2f2; }
+.m-stat-pill.red .m-stat-value { color: #dc2626; }
+.m-stat-pill.purple { border-color: #c4b5fd; background: #f5f3ff; }
+.m-stat-pill.purple .m-stat-value { color: #7c3aed; }
+.m-stat-pill.slate { border-color: #cbd5e1; background: #f8fafc; }
+.m-stat-pill.slate .m-stat-value { color: #475569; }
+
+/* ===== Project Filter ===== */
+.m-filter-bar {
+ padding: 0 16px 12px;
+}
+.m-filter-select {
+ width: 100%; padding: 10px 12px; border-radius: 10px;
+ border: 1px solid #d1d5db; background: #fff;
+ font-size: 14px; color: #374151;
+ appearance: none; -webkit-appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
+ background-repeat: no-repeat; background-position: right 12px center;
+}
+
+/* ===== Tab Bar ===== */
+.m-tab-bar {
+ display: flex; background: #fff; border-bottom: 1px solid #e5e7eb;
+ padding: 0 16px;
+}
+.m-tab {
+ flex: 1; text-align: center; padding: 12px 0;
+ font-size: 14px; font-weight: 600; color: #9ca3af;
+ border-bottom: 2px solid transparent;
+ transition: color 0.2s, border-color 0.2s;
+}
+.m-tab.active { color: #2563eb; border-bottom-color: #2563eb; }
+.m-tab:active { opacity: 0.7; }
+
+/* ===== Date Group ===== */
+.m-date-group { padding: 0 16px; margin-bottom: 8px; }
+.m-date-header {
+ display: flex; align-items: center; gap: 8px;
+ padding: 10px 0; font-size: 13px; font-weight: 600; color: #6b7280;
+}
+.m-date-header i { font-size: 10px; color: #9ca3af; transition: transform 0.2s; }
+.m-date-header .m-date-count {
+ font-size: 12px; font-weight: 400; color: #9ca3af;
+}
+.m-date-header.collapsed i { transform: rotate(-90deg); }
+
+/* ===== Issue Card ===== */
+.m-card {
+ background: #fff; border-radius: 12px; margin: 0 16px 10px;
+ border: 1px solid #e5e7eb; overflow: hidden;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
+ transition: transform 0.1s;
+}
+.m-card:active { transform: scale(0.985); }
+.m-card-header {
+ padding: 12px 14px 8px; display: flex; align-items: center;
+ justify-content: space-between;
+}
+.m-card-no {
+ font-size: 15px; font-weight: 800; color: #2563eb;
+}
+.m-card-project {
+ font-size: 12px; color: #6b7280; margin-left: 8px;
+}
+.m-card-title {
+ padding: 0 14px 8px; font-size: 15px; font-weight: 600; color: #111827;
+ line-height: 1.4;
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.m-card-body { padding: 0 14px 12px; }
+.m-card-footer {
+ padding: 8px 14px; border-top: 1px solid #f3f4f6;
+ display: flex; align-items: center; justify-content: space-between;
+ font-size: 12px; color: #9ca3af;
+}
+
+/* Card border accents */
+.m-card.border-blue { border-left: 4px solid #3b82f6; }
+.m-card.border-green { border-left: 4px solid #22c55e; }
+.m-card.border-red { border-left: 4px solid #ef4444; }
+.m-card.border-purple { border-left: 4px solid #8b5cf6; }
+.m-card.border-amber { border-left: 4px solid #f59e0b; }
+
+/* ===== Status Badge ===== */
+.m-badge {
+ display: inline-flex; align-items: center; gap: 4px;
+ padding: 3px 8px; border-radius: 10px;
+ font-size: 11px; font-weight: 600;
+}
+.m-badge.in-progress { background: #dbeafe; color: #1d4ed8; }
+.m-badge.pending-completion { background: #ede9fe; color: #6d28d9; }
+.m-badge.overdue { background: #fee2e2; color: #b91c1c; }
+.m-badge.completed { background: #dcfce7; color: #15803d; }
+.m-badge.review { background: #dbeafe; color: #1d4ed8; }
+.m-badge.urgent { background: #ffedd5; color: #c2410c; }
+
+/* ===== Photo Thumbnails ===== */
+.m-photo-row {
+ display: flex; gap: 6px; overflow-x: auto;
+ scrollbar-width: none; padding: 4px 0;
+}
+.m-photo-row::-webkit-scrollbar { display: none; }
+.m-photo-thumb {
+ width: 60px; height: 60px; flex-shrink: 0; border-radius: 8px;
+ object-fit: cover; border: 1px solid #e5e7eb;
+}
+
+/* ===== Action Buttons ===== */
+.m-action-row {
+ display: flex; gap: 8px; padding: 8px 14px 12px;
+}
+.m-action-btn {
+ flex: 1; padding: 10px 0; border-radius: 10px;
+ font-size: 13px; font-weight: 600; color: #fff;
+ text-align: center; min-height: 44px;
+ display: flex; align-items: center; justify-content: center; gap: 6px;
+ transition: opacity 0.15s;
+}
+.m-action-btn:active { opacity: 0.8; }
+.m-action-btn.red { background: #ef4444; }
+.m-action-btn.blue { background: #3b82f6; }
+.m-action-btn.green { background: #22c55e; }
+.m-action-btn.purple { background: #8b5cf6; }
+
+/* ===== Bottom Sheet ===== */
+.m-sheet-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.5);
+ z-index: 200; opacity: 0; visibility: hidden;
+ transition: opacity 0.25s, visibility 0.25s;
+}
+.m-sheet-overlay.open { opacity: 1; visibility: visible; }
+
+.m-sheet {
+ position: fixed; bottom: 0; left: 0; right: 0;
+ z-index: 201; background: #fff;
+ border-radius: 16px 16px 0 0;
+ max-height: 90vh; overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ transform: translateY(100%);
+ transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
+ padding-bottom: env(safe-area-inset-bottom);
+ box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
+}
+.m-sheet.open { transform: translateY(0); }
+
+.m-sheet-handle {
+ width: 36px; height: 4px; border-radius: 2px;
+ background: #d1d5db; margin: 10px auto 4px;
+}
+.m-sheet-header {
+ position: sticky; top: 0; background: #fff;
+ padding: 8px 16px 12px; z-index: 1;
+ border-radius: 16px 16px 0 0;
+ display: flex; align-items: center; justify-content: space-between;
+ border-bottom: 1px solid #f3f4f6;
+}
+.m-sheet-title { font-size: 17px; font-weight: 700; color: #111827; }
+.m-sheet-close {
+ width: 32px; height: 32px; border-radius: 16px;
+ display: flex; align-items: center; justify-content: center;
+ color: #9ca3af; font-size: 18px; background: #f3f4f6;
+}
+.m-sheet-close:active { background: #e5e7eb; }
+.m-sheet-body { padding: 16px; }
+.m-sheet-footer {
+ position: sticky; bottom: 0; background: #fff;
+ padding: 12px 16px; border-top: 1px solid #e5e7eb;
+}
+
+/* ===== Form Inputs ===== */
+.m-input, .m-select, .m-textarea {
+ width: 100%; padding: 12px; border-radius: 10px;
+ border: 1px solid #d1d5db; background: #fff;
+ font-size: 16px; color: #111827;
+ min-height: 44px;
+ transition: border-color 0.15s;
+}
+.m-input:focus, .m-select:focus, .m-textarea:focus {
+ outline: none; border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
+}
+.m-textarea { resize: vertical; min-height: 88px; }
+.m-label {
+ display: block; font-size: 13px; font-weight: 600;
+ color: #374151; margin-bottom: 6px;
+}
+.m-form-group { margin-bottom: 16px; }
+
+/* ===== Submit Button ===== */
+.m-submit-btn {
+ width: 100%; padding: 14px; border-radius: 12px;
+ font-size: 15px; font-weight: 700; color: #fff;
+ background: #3b82f6; min-height: 48px;
+ display: flex; align-items: center; justify-content: center; gap: 8px;
+ transition: opacity 0.15s;
+}
+.m-submit-btn:active { opacity: 0.8; }
+.m-submit-btn:disabled { opacity: 0.5; }
+.m-submit-btn.green { background: #22c55e; }
+.m-submit-btn.red { background: #ef4444; }
+
+/* ===== Toast ===== */
+.m-toast {
+ position: fixed; bottom: calc(80px + env(safe-area-inset-bottom));
+ left: 50%; transform: translateX(-50%) translateY(20px);
+ padding: 12px 20px; border-radius: 12px;
+ font-size: 14px; font-weight: 500; color: #fff;
+ background: #1f2937; z-index: 300;
+ opacity: 0; transition: opacity 0.3s, transform 0.3s;
+ max-width: calc(100vw - 32px); text-align: center;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+}
+.m-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+.m-toast.success { background: #16a34a; }
+.m-toast.error { background: #dc2626; }
+.m-toast.warning { background: #d97706; }
+
+/* ===== Loading ===== */
+.m-loading {
+ display: flex; align-items: center; justify-content: center;
+ padding: 40px; color: #9ca3af;
+}
+.m-loading .m-spinner {
+ width: 28px; height: 28px; border: 3px solid #e5e7eb;
+ border-top-color: #3b82f6; border-radius: 50%;
+ animation: m-spin 0.6s linear infinite;
+}
+@keyframes m-spin { to { transform: rotate(360deg); } }
+
+.m-loading-overlay {
+ position: fixed; inset: 0; background: #fff; z-index: 150;
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ transition: opacity 0.3s;
+}
+.m-loading-overlay.hide { opacity: 0; pointer-events: none; }
+.m-loading-overlay .m-spinner { width: 36px; height: 36px; }
+.m-loading-overlay p { margin-top: 12px; color: #6b7280; font-size: 14px; }
+
+/* ===== Empty State ===== */
+.m-empty {
+ text-align: center; padding: 60px 20px; color: #9ca3af;
+}
+.m-empty i { font-size: 48px; margin-bottom: 12px; display: block; }
+.m-empty p { font-size: 14px; line-height: 1.5; }
+
+/* ===== Opinion / Comment Section ===== */
+.m-opinions-toggle {
+ display: inline-flex; align-items: center; gap: 4px;
+ padding: 4px 10px; border-radius: 12px;
+ background: #f3f4f6; font-size: 12px; font-weight: 600;
+ color: #6b7280;
+}
+.m-opinions-toggle:active { background: #e5e7eb; }
+.m-opinion-card {
+ padding: 10px; margin: 6px 0; border-radius: 10px;
+ border-left: 3px solid #22c55e; background: #f0fdf4;
+}
+.m-opinion-header {
+ display: flex; align-items: center; gap: 6px;
+ margin-bottom: 4px; font-size: 12px;
+}
+.m-opinion-avatar {
+ width: 22px; height: 22px; border-radius: 11px;
+ background: #3b82f6; color: #fff;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 10px; font-weight: 700; flex-shrink: 0;
+}
+.m-opinion-author { font-weight: 600; color: #111827; }
+.m-opinion-time { color: #9ca3af; }
+.m-opinion-text { font-size: 13px; color: #374151; line-height: 1.5; padding-left: 28px; white-space: pre-wrap; }
+
+.m-opinion-actions {
+ display: flex; gap: 6px; padding-left: 28px; margin-top: 6px;
+}
+.m-opinion-action-btn {
+ padding: 3px 8px; border-radius: 6px;
+ font-size: 11px; font-weight: 500;
+ display: flex; align-items: center; gap: 3px;
+}
+.m-opinion-action-btn.comment-btn { background: #dbeafe; color: #1d4ed8; }
+.m-opinion-action-btn.edit-btn { background: #dcfce7; color: #15803d; }
+.m-opinion-action-btn.delete-btn { background: #fee2e2; color: #b91c1c; }
+.m-opinion-action-btn.reply-btn { background: #dbeafe; color: #1d4ed8; }
+
+/* Comment */
+.m-comment {
+ margin: 4px 0 4px 28px; padding: 8px 10px; border-radius: 8px;
+ background: #fff; border: 1px solid #e5e7eb; font-size: 12px;
+}
+.m-comment-header {
+ display: flex; align-items: center; gap: 4px; margin-bottom: 2px;
+}
+.m-comment-avatar {
+ width: 18px; height: 18px; border-radius: 9px;
+ background: #9ca3af; color: #fff;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 8px; font-weight: 700; flex-shrink: 0;
+}
+.m-comment-text { color: #374151; padding-left: 22px; }
+
+/* Reply */
+.m-reply {
+ margin: 3px 0 3px 50px; padding: 6px 8px; border-radius: 6px;
+ background: #eff6ff; font-size: 11px;
+ border-left: 2px solid #93c5fd;
+}
+.m-reply-header {
+ display: flex; align-items: center; gap: 3px; margin-bottom: 1px;
+}
+.m-reply-text { color: #374151; padding-left: 0; }
+
+/* ===== Info Row ===== */
+.m-info-row {
+ display: flex; align-items: center; gap: 6px;
+ font-size: 12px; color: #6b7280; margin: 2px 0;
+}
+.m-info-row i { width: 14px; text-align: center; }
+
+/* ===== Collapsible Detail ===== */
+.m-detail-toggle {
+ display: flex; align-items: center; gap: 4px;
+ font-size: 12px; color: #3b82f6; font-weight: 500;
+ padding: 4px 0;
+}
+.m-detail-content {
+ max-height: 0; overflow: hidden; transition: max-height 0.3s ease;
+}
+.m-detail-content.open { max-height: 2000px; }
+
+/* ===== Completion Section ===== */
+.m-completion-info {
+ background: #f5f3ff; border: 1px solid #ddd6fe;
+ border-radius: 10px; padding: 12px; margin-top: 8px;
+}
+
+/* ===== Management Fields (read-only display) ===== */
+.m-field-display {
+ padding: 8px 10px; background: #f9fafb; border-radius: 8px;
+ border: 1px solid #e5e7eb; font-size: 13px; color: #374151;
+ min-height: 36px;
+}
+.m-field-display.empty { color: #9ca3af; font-style: italic; }
+
+/* ===== Radio Group ===== */
+.m-radio-group { display: flex; flex-direction: column; gap: 8px; }
+.m-radio-item {
+ display: flex; align-items: center; gap: 10px;
+ padding: 12px; border-radius: 10px; border: 1px solid #e5e7eb;
+ background: #fff; min-height: 44px;
+ transition: border-color 0.15s, background 0.15s;
+}
+.m-radio-item.selected { border-color: #3b82f6; background: #eff6ff; }
+.m-radio-item input[type="radio"] { width: 18px; height: 18px; accent-color: #3b82f6; }
+
+/* ===== Photo Upload ===== */
+.m-photo-upload {
+ border: 2px dashed #d1d5db; border-radius: 12px;
+ padding: 20px; text-align: center; color: #9ca3af;
+ transition: border-color 0.15s;
+}
+.m-photo-upload:active { border-color: #3b82f6; }
+.m-photo-upload i { font-size: 28px; margin-bottom: 8px; }
+.m-photo-upload p { font-size: 13px; }
+.m-photo-preview {
+ width: 100%; max-height: 200px; object-fit: contain;
+ border-radius: 8px; margin-top: 8px;
+}
+
+/* ===== Fullscreen Photo Modal ===== */
+.m-photo-modal {
+ position: fixed; inset: 0; z-index: 300;
+ background: rgba(0,0,0,0.9);
+ display: flex; align-items: center; justify-content: center;
+ opacity: 0; visibility: hidden;
+ transition: opacity 0.2s, visibility 0.2s;
+}
+.m-photo-modal.open { opacity: 1; visibility: visible; }
+.m-photo-modal img {
+ max-width: 100%; max-height: 100%; object-fit: contain;
+}
+.m-photo-modal-close {
+ position: absolute; top: 12px; right: 12px;
+ width: 36px; height: 36px; border-radius: 18px;
+ background: rgba(0,0,0,0.5); color: #fff;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 18px;
+}
+
+/* ===== Utility ===== */
+.hidden { display: none !important; }
+.text-ellipsis-2 {
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.text-ellipsis-3 {
+ display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
+ overflow: hidden;
+}
diff --git a/system3-nonconformance/web/static/js/m/m-common.js b/system3-nonconformance/web/static/js/m/m-common.js
new file mode 100644
index 0000000..930f2d1
--- /dev/null
+++ b/system3-nonconformance/web/static/js/m/m-common.js
@@ -0,0 +1,195 @@
+/**
+ * m-common.js — TKQC 모바일 공통 JS
+ * 바텀 네비게이션, 바텀시트 엔진, 인증, 뷰포트 가드, 토스트
+ */
+
+/* ===== Viewport Guard: 데스크탑이면 리다이렉트 ===== */
+(function () {
+ if (window.innerWidth > 768) {
+ var page = location.pathname.replace('/m/', '').replace('.html', '');
+ var map = { dashboard: '/issues-dashboard.html', inbox: '/issues-inbox.html', management: '/issues-management.html' };
+ window.location.replace(map[page] || '/issues-dashboard.html');
+ }
+})();
+
+/* ===== KST Date Utilities ===== */
+function getKSTDate(date) {
+ var d = new Date(date);
+ return new Date(d.getTime() + 9 * 60 * 60 * 1000);
+}
+function formatKSTDate(date) {
+ return new Date(date).toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
+}
+function formatKSTTime(date) {
+ return new Date(date).toLocaleTimeString('ko-KR', { timeZone: 'Asia/Seoul', hour: '2-digit', minute: '2-digit' });
+}
+function formatKSTDateTime(date) {
+ return new Date(date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+}
+function getKSTToday() {
+ var now = new Date();
+ var kst = getKSTDate(now);
+ return new Date(kst.getFullYear(), kst.getMonth(), kst.getDate());
+}
+function getTimeAgo(date) {
+ var now = getKSTDate(new Date());
+ var d = getKSTDate(date);
+ var diff = now - d;
+ var mins = Math.floor(diff / 60000);
+ var hours = Math.floor(diff / 3600000);
+ var days = Math.floor(diff / 86400000);
+ if (mins < 1) return '방금 전';
+ if (mins < 60) return mins + '분 전';
+ if (hours < 24) return hours + '시간 전';
+ if (days < 7) return days + '일 전';
+ return formatKSTDate(date);
+}
+
+/* ===== Bottom Navigation ===== */
+function renderBottomNav(activePage) {
+ var nav = document.createElement('nav');
+ nav.className = 'm-bottom-nav';
+ var items = [
+ { icon: 'fa-chart-line', label: '현황판', href: '/m/dashboard.html', page: 'dashboard' },
+ { icon: 'fa-inbox', label: '수신함', href: '/m/inbox.html', page: 'inbox' },
+ { icon: 'fa-tasks', label: '관리함', href: '/m/management.html', page: 'management' },
+ { icon: 'fa-bullhorn', label: '신고', href: 'https://tkreport.technicalkorea.net', page: 'report', external: true, highlight: true }
+ ];
+ items.forEach(function (item) {
+ var a = document.createElement('a');
+ a.href = item.href;
+ a.className = 'm-nav-item';
+ if (item.page === activePage) a.classList.add('active');
+ if (item.highlight) a.classList.add('highlight');
+ if (item.external) { a.target = '_blank'; a.rel = 'noopener'; }
+ a.innerHTML = '' + item.label + ' ';
+ nav.appendChild(a);
+ });
+ document.body.appendChild(nav);
+}
+
+/* ===== Bottom Sheet Engine ===== */
+var _activeSheets = [];
+
+function openSheet(sheetId) {
+ var overlay = document.getElementById(sheetId + 'Overlay');
+ var sheet = document.getElementById(sheetId + 'Sheet');
+ if (!overlay || !sheet) return;
+ overlay.classList.add('open');
+ sheet.classList.add('open');
+ document.body.style.overflow = 'hidden';
+ _activeSheets.push(sheetId);
+}
+
+function closeSheet(sheetId) {
+ var overlay = document.getElementById(sheetId + 'Overlay');
+ var sheet = document.getElementById(sheetId + 'Sheet');
+ if (!overlay || !sheet) return;
+ overlay.classList.remove('open');
+ sheet.classList.remove('open');
+ _activeSheets = _activeSheets.filter(function (id) { return id !== sheetId; });
+ if (_activeSheets.length === 0) document.body.style.overflow = '';
+}
+
+function closeAllSheets() {
+ _activeSheets.slice().forEach(function (id) { closeSheet(id); });
+}
+
+// ESC key closes topmost sheet
+document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape' && _activeSheets.length) {
+ closeSheet(_activeSheets[_activeSheets.length - 1]);
+ }
+});
+
+/* ===== Toast ===== */
+var _toastTimer = null;
+function showToast(message, type, duration) {
+ type = type || 'info';
+ duration = duration || 3000;
+ var existing = document.querySelector('.m-toast');
+ if (existing) existing.remove();
+ clearTimeout(_toastTimer);
+
+ var toast = document.createElement('div');
+ toast.className = 'm-toast';
+ if (type !== 'info') toast.classList.add(type);
+ toast.textContent = message;
+ document.body.appendChild(toast);
+ requestAnimationFrame(function () { toast.classList.add('show'); });
+ _toastTimer = setTimeout(function () {
+ toast.classList.remove('show');
+ setTimeout(function () { toast.remove(); }, 300);
+ }, duration);
+}
+
+/* ===== Photo Modal ===== */
+function openPhotoModal(src) {
+ var modal = document.getElementById('photoModal');
+ if (!modal) {
+ modal = document.createElement('div');
+ modal.id = 'photoModal';
+ modal.className = 'm-photo-modal';
+ modal.innerHTML = ' ';
+ modal.addEventListener('click', function (e) { if (e.target === modal) closePhotoModal(); });
+ document.body.appendChild(modal);
+ }
+ modal.querySelector('img').src = src;
+ modal.classList.add('open');
+ document.body.style.overflow = 'hidden';
+}
+
+function closePhotoModal() {
+ var modal = document.getElementById('photoModal');
+ if (modal) {
+ modal.classList.remove('open');
+ if (!_activeSheets.length) document.body.style.overflow = '';
+ }
+}
+
+/* ===== Auth Helper ===== */
+async function mCheckAuth() {
+ var token = TokenManager.getToken();
+ if (!token) {
+ window.location.href = _getLoginUrl();
+ return null;
+ }
+ try {
+ var user = await AuthAPI.getCurrentUser();
+ return user;
+ } catch (e) {
+ TokenManager.removeToken();
+ TokenManager.removeUser();
+ window.location.href = _getLoginUrl();
+ return null;
+ }
+}
+
+/* ===== Loading Overlay ===== */
+function hideLoading() {
+ var el = document.getElementById('loadingOverlay');
+ if (el) { el.classList.add('hide'); setTimeout(function () { el.remove(); }, 300); }
+}
+
+/* ===== Helpers ===== */
+function escapeHtml(text) {
+ if (!text) return '';
+ var div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function getPhotoPaths(issue) {
+ return [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean);
+}
+
+function getCompletionPhotoPaths(issue) {
+ return [issue.completion_photo_path, issue.completion_photo_path2, issue.completion_photo_path3, issue.completion_photo_path4, issue.completion_photo_path5].filter(Boolean);
+}
+
+function renderPhotoThumbs(photos) {
+ if (!photos || !photos.length) return '';
+ return '' + photos.map(function (p, i) {
+ return '
';
+ }).join('') + '
';
+}
diff --git a/system3-nonconformance/web/static/js/m/m-dashboard.js b/system3-nonconformance/web/static/js/m/m-dashboard.js
new file mode 100644
index 0000000..83c2826
--- /dev/null
+++ b/system3-nonconformance/web/static/js/m/m-dashboard.js
@@ -0,0 +1,757 @@
+/**
+ * m-dashboard.js — 현황판 모바일 페이지 로직
+ */
+
+var currentUser = null;
+var allIssues = [];
+var projects = [];
+var filteredIssues = [];
+
+// 모달/시트 상태
+var selectedOpinionIssueId = null;
+var selectedCommentIssueId = null;
+var selectedCommentOpinionIndex = null;
+var selectedReplyIssueId = null;
+var selectedReplyOpinionIndex = null;
+var selectedReplyCommentIndex = null;
+var selectedCompletionIssueId = null;
+var selectedRejectionIssueId = null;
+var completionPhotoBase64 = null;
+
+// 수정 상태
+var editMode = null; // 'opinion', 'comment', 'reply'
+var editIssueId = null;
+var editOpinionIndex = null;
+var editCommentIndex = null;
+var editReplyIndex = null;
+
+// ===== 초기화 =====
+async function initialize() {
+ currentUser = await mCheckAuth();
+ if (!currentUser) return;
+
+ await Promise.all([loadProjects(), loadIssues()]);
+ updateStatistics();
+ renderIssues();
+ renderBottomNav('dashboard');
+ hideLoading();
+}
+
+async function loadProjects() {
+ try {
+ var resp = await fetch(API_BASE_URL + '/projects/', {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (resp.ok) {
+ projects = await resp.json();
+ var sel = document.getElementById('projectFilter');
+ sel.innerHTML = '전체 프로젝트 ';
+ projects.forEach(function (p) {
+ sel.innerHTML += '' + p.project_name + ' ';
+ });
+ }
+ } catch (e) { console.error('프로젝트 로드 실패:', e); }
+}
+
+async function loadIssues() {
+ try {
+ var resp = await fetch(API_BASE_URL + '/issues/admin/all', {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (resp.ok) {
+ var data = await resp.json();
+ allIssues = data.filter(function (i) { return i.review_status === 'in_progress'; });
+
+ // 프로젝트별 순번 재할당
+ allIssues.sort(function (a, b) { return new Date(a.reviewed_at) - new Date(b.reviewed_at); });
+ var groups = {};
+ allIssues.forEach(function (issue) {
+ if (!groups[issue.project_id]) groups[issue.project_id] = [];
+ groups[issue.project_id].push(issue);
+ });
+ Object.keys(groups).forEach(function (pid) {
+ groups[pid].forEach(function (issue, idx) { issue.project_sequence_no = idx + 1; });
+ });
+
+ filteredIssues = allIssues.slice();
+ }
+ } catch (e) { console.error('이슈 로드 실패:', e); }
+}
+
+function updateStatistics() {
+ var today = new Date().toDateString();
+ var todayIssues = allIssues.filter(function (i) { return i.reviewed_at && new Date(i.reviewed_at).toDateString() === today; });
+ var pending = allIssues.filter(function (i) { return i.completion_requested_at && i.review_status === 'in_progress'; });
+ var overdue = allIssues.filter(function (i) { return i.expected_completion_date && new Date(i.expected_completion_date) < new Date(); });
+
+ document.getElementById('totalInProgress').textContent = allIssues.length;
+ document.getElementById('todayNew').textContent = todayIssues.length;
+ document.getElementById('pendingCompletion').textContent = pending.length;
+ document.getElementById('overdue').textContent = overdue.length;
+}
+
+function filterByProject() {
+ var pid = document.getElementById('projectFilter').value;
+ filteredIssues = pid ? allIssues.filter(function (i) { return i.project_id == pid; }) : allIssues.slice();
+ renderIssues();
+}
+
+// ===== 이슈 상태 판별 =====
+function getIssueStatus(issue) {
+ if (issue.review_status === 'completed') return 'completed';
+ if (issue.completion_requested_at) return 'pending_completion';
+ if (issue.expected_completion_date) {
+ var diff = (new Date(issue.expected_completion_date) - new Date()) / 86400000;
+ if (diff < 0) return 'overdue';
+ if (diff <= 3) return 'urgent';
+ }
+ return 'in_progress';
+}
+
+function getStatusBadgeHtml(status) {
+ var map = {
+ 'in_progress': ' 진행 중 ',
+ 'urgent': ' 긴급 ',
+ 'overdue': ' 지연됨 ',
+ 'pending_completion': ' 완료 대기 ',
+ 'completed': ' 완료됨 '
+ };
+ return map[status] || map['in_progress'];
+}
+
+// ===== 렌더링 =====
+function renderIssues() {
+ var container = document.getElementById('issuesList');
+ var empty = document.getElementById('emptyState');
+
+ if (!filteredIssues.length) {
+ container.innerHTML = '';
+ empty.classList.remove('hidden');
+ return;
+ }
+ empty.classList.add('hidden');
+
+ // 날짜별 그룹화 (reviewed_at 기준)
+ var grouped = {};
+ var dateObjs = {};
+ filteredIssues.forEach(function (issue) {
+ var d = new Date(issue.reviewed_at || issue.report_date);
+ var key = d.toLocaleDateString('ko-KR');
+ if (!grouped[key]) { grouped[key] = []; dateObjs[key] = d; }
+ grouped[key].push(issue);
+ });
+
+ var html = Object.keys(grouped)
+ .sort(function (a, b) { return dateObjs[b] - dateObjs[a]; })
+ .map(function (dateKey) {
+ var issues = grouped[dateKey];
+ return '' +
+ issues.map(function (issue) { return renderIssueCard(issue); }).join('') +
+ '
';
+ }).join('');
+
+ container.innerHTML = html;
+}
+
+function renderIssueCard(issue) {
+ var project = projects.find(function (p) { return p.id === issue.project_id; });
+ var projectName = project ? project.project_name : '미지정';
+ var status = getIssueStatus(issue);
+ var photos = getPhotoPaths(issue);
+ var isPending = status === 'pending_completion';
+
+ // 의견/댓글 파싱
+ var opinionsHtml = renderOpinions(issue);
+
+ // 완료 반려 내용
+ var rejectionHtml = '';
+ if (issue.completion_rejection_reason) {
+ var rejAt = issue.completion_rejected_at ? formatKSTDateTime(issue.completion_rejected_at) : '';
+ rejectionHtml = '' +
+ '
완료 반려
' +
+ '
' + (rejAt ? '[' + rejAt + '] ' : '') + escapeHtml(issue.completion_rejection_reason) + '
';
+ }
+
+ // 완료 대기 정보
+ var completionInfoHtml = '';
+ if (isPending) {
+ var cPhotos = getCompletionPhotoPaths(issue);
+ completionInfoHtml = '' +
+ '
완료 신청 정보
' +
+ (cPhotos.length ? renderPhotoThumbs(cPhotos) : '
완료 사진 없음
') +
+ '
' + (issue.completion_comment || '코멘트 없음') + '
' +
+ '
신청: ' + formatKSTDateTime(issue.completion_requested_at) + '
' +
+ '
';
+ }
+
+ // 액션 버튼
+ var actionHtml = '';
+ if (isPending) {
+ actionHtml = '' +
+ ' 반려 ' +
+ '의견 ' +
+ '
';
+ } else {
+ actionHtml = '' +
+ ' 완료신청 ' +
+ '의견 ' +
+ '
';
+ }
+
+ return '' +
+ '' +
+ '
' + escapeHtml(getIssueTitle(issue)) + '
' +
+ '
' +
+ // 상세 내용
+ '
' + escapeHtml(getIssueDetail(issue)) + '
' +
+ // 정보행
+ '
' +
+ ' ' + escapeHtml(issue.reporter?.full_name || issue.reporter?.username || '-') + ' ' +
+ ' ' + getCategoryText(issue.category || issue.final_category) + ' ' +
+ (issue.expected_completion_date ? ' ' + formatKSTDate(issue.expected_completion_date) + ' ' : '') +
+ '
' +
+ // 사진
+ (photos.length ? renderPhotoThumbs(photos) : '') +
+ // 해결방안 / 의견 섹션
+ '
' +
+ renderManagementComment(issue) +
+ opinionsHtml +
+ '
' +
+ rejectionHtml +
+ completionInfoHtml +
+ '
' +
+ actionHtml +
+ '' +
+ '
';
+}
+
+// ===== 관리 코멘트 (확정 해결방안) =====
+function renderManagementComment(issue) {
+ var raw = issue.management_comment || issue.final_description || '';
+ raw = raw.replace(/\[완료 반려[^\]]*\][^\n]*/g, '').trim();
+ var defaults = ['중복작업 신고용', '상세 내용 없음', '자재 누락', '설계 오류', '반입 불량', '검사 누락', '기타', '부적합명', '상세내용', '상세 내용'];
+ var lines = raw.split('\n').filter(function (l) { var t = l.trim(); return t && defaults.indexOf(t) < 0; });
+ var content = lines.join('\n').trim();
+
+ return '' +
+ '
' +
+ (content ? escapeHtml(content) : '확정된 해결 방안 없음') +
+ '
';
+}
+
+// ===== 의견/댓글/답글 렌더링 =====
+function renderOpinions(issue) {
+ if (!issue.solution || !issue.solution.trim()) {
+ return '';
+ }
+
+ var opinions = issue.solution.split(/─{30,}/);
+ var validOpinions = opinions.filter(function (o) { return o.trim(); });
+ if (!validOpinions.length) return '';
+
+ var toggleId = 'opinions-' + issue.id;
+ var html = '' +
+ ' 의견 ' + validOpinions.length + '개' +
+ ' ' +
+ '';
+
+ validOpinions.forEach(function (opinion, opinionIndex) {
+ var trimmed = opinion.trim();
+ var headerMatch = trimmed.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
+
+ if (headerMatch) {
+ var author = headerMatch[1];
+ var datetime = headerMatch[2];
+ var rest = trimmed.substring(headerMatch[0].length).trim().split('\n');
+ var mainContent = '';
+ var comments = [];
+ var currentCommentIdx = -1;
+
+ rest.forEach(function (line) {
+ if (line.match(/^\s*└/)) {
+ var cm = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
+ if (cm) { comments.push({ author: cm[1], datetime: cm[2], content: cm[3], replies: [] }); currentCommentIdx = comments.length - 1; }
+ } else if (line.match(/^\s*↳/)) {
+ var rm = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
+ if (rm && currentCommentIdx >= 0) { comments[currentCommentIdx].replies.push({ author: rm[1], datetime: rm[2], content: rm[3] }); }
+ } else {
+ mainContent += (mainContent ? '\n' : '') + line;
+ }
+ });
+
+ var isOwn = currentUser && (author === currentUser.full_name || author === currentUser.username);
+
+ html += '
' +
+ '' +
+ '
' + escapeHtml(mainContent) + '
' +
+ '
' +
+ '' +
+ (isOwn ? ' 수정 ' +
+ ' 삭제 ' : '') +
+ '
';
+
+ // 댓글
+ comments.forEach(function (comment, commentIndex) {
+ var isOwnComment = currentUser && (comment.author === currentUser.full_name || comment.author === currentUser.username);
+ html += ''; // m-comment
+ });
+
+ html += '
'; // m-opinion-card
+ }
+ });
+
+ html += '
';
+ return html;
+}
+
+function toggleOpinions(id) {
+ var el = document.getElementById(id);
+ var chevron = document.getElementById('chevron-' + id);
+ if (el.classList.contains('hidden')) {
+ el.classList.remove('hidden');
+ if (chevron) chevron.style.transform = 'rotate(180deg)';
+ } else {
+ el.classList.add('hidden');
+ if (chevron) chevron.style.transform = '';
+ }
+}
+
+// ===== 의견 제시 =====
+function openOpinionSheet(issueId) {
+ selectedOpinionIssueId = issueId;
+ document.getElementById('opinionText').value = '';
+ openSheet('opinion');
+}
+
+async function submitOpinion() {
+ if (!selectedOpinionIssueId) return;
+ var text = document.getElementById('opinionText').value.trim();
+ if (!text) { showToast('의견을 입력해주세요.', 'warning'); return; }
+
+ try {
+ var issueResp = await fetch(API_BASE_URL + '/issues/' + selectedOpinionIssueId, {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (!issueResp.ok) throw new Error('이슈 조회 실패');
+ var issue = await issueResp.json();
+
+ var now = new Date();
+ var dateStr = now.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+ var newOpinion = '[' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + ')\n' + text;
+
+ var solution = issue.solution ? newOpinion + '\n' + '─'.repeat(50) + '\n' + issue.solution : newOpinion;
+
+ var resp = await fetch(API_BASE_URL + '/issues/' + selectedOpinionIssueId + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ solution: solution })
+ });
+
+ if (resp.ok) {
+ showToast('의견이 추가되었습니다.', 'success');
+ closeSheet('opinion');
+ await refreshData();
+ } else { throw new Error('저장 실패'); }
+ } catch (e) {
+ console.error('의견 제시 오류:', e);
+ showToast('오류: ' + e.message, 'error');
+ }
+}
+
+// ===== 댓글 추가 =====
+function openCommentSheet(issueId, opinionIndex) {
+ selectedCommentIssueId = issueId;
+ selectedCommentOpinionIndex = opinionIndex;
+ document.getElementById('commentText').value = '';
+ openSheet('comment');
+}
+
+async function submitComment() {
+ if (!selectedCommentIssueId || selectedCommentOpinionIndex === null) return;
+ var text = document.getElementById('commentText').value.trim();
+ if (!text) { showToast('댓글을 입력해주세요.', 'warning'); return; }
+
+ try {
+ var issue = await (await fetch(API_BASE_URL + '/issues/' + selectedCommentIssueId, {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ })).json();
+
+ var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
+ if (selectedCommentOpinionIndex >= opinions.length) throw new Error('잘못된 인덱스');
+
+ var dateStr = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+ opinions[selectedCommentOpinionIndex] = opinions[selectedCommentOpinionIndex].trim() + '\n └ [' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + '): ' + text;
+
+ var resp = await fetch(API_BASE_URL + '/issues/' + selectedCommentIssueId + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
+ });
+
+ if (resp.ok) { showToast('댓글이 추가되었습니다.', 'success'); closeSheet('comment'); await refreshData(); }
+ else throw new Error('저장 실패');
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 답글 추가 =====
+function openReplySheet(issueId, opinionIndex, commentIndex) {
+ selectedReplyIssueId = issueId;
+ selectedReplyOpinionIndex = opinionIndex;
+ selectedReplyCommentIndex = commentIndex;
+ document.getElementById('replyText').value = '';
+ openSheet('reply');
+}
+
+async function submitReply() {
+ if (!selectedReplyIssueId || selectedReplyOpinionIndex === null || selectedReplyCommentIndex === null) return;
+ var text = document.getElementById('replyText').value.trim();
+ if (!text) { showToast('답글을 입력해주세요.', 'warning'); return; }
+
+ try {
+ var issue = await (await fetch(API_BASE_URL + '/issues/' + selectedReplyIssueId, {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ })).json();
+
+ var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
+ var lines = opinions[selectedReplyOpinionIndex].trim().split('\n');
+ var dateStr = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+ var newReply = ' ↳ [' + (currentUser.full_name || currentUser.username) + '] (' + dateStr + '): ' + text;
+
+ var commentCount = -1;
+ var insertIndex = -1;
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].match(/^\s*└/)) {
+ commentCount++;
+ if (commentCount === selectedReplyCommentIndex) {
+ insertIndex = i + 1;
+ while (insertIndex < lines.length && lines[insertIndex].match(/^\s*↳/)) insertIndex++;
+ break;
+ }
+ }
+ }
+ if (insertIndex >= 0) lines.splice(insertIndex, 0, newReply);
+ opinions[selectedReplyOpinionIndex] = lines.join('\n');
+
+ var resp = await fetch(API_BASE_URL + '/issues/' + selectedReplyIssueId + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
+ });
+
+ if (resp.ok) { showToast('답글이 추가되었습니다.', 'success'); closeSheet('reply'); await refreshData(); }
+ else throw new Error('저장 실패');
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 수정 =====
+function openEditSheet(mode, issueId, opinionIndex, commentIndex, replyIndex) {
+ editMode = mode;
+ editIssueId = issueId;
+ editOpinionIndex = opinionIndex;
+ editCommentIndex = commentIndex !== undefined ? commentIndex : null;
+ editReplyIndex = replyIndex !== undefined ? replyIndex : null;
+
+ var titles = { opinion: '의견 수정', comment: '댓글 수정', reply: '답글 수정' };
+ document.getElementById('editSheetTitle').innerHTML = ' ' + titles[mode];
+
+ // 현재 내용 로드
+ fetch(API_BASE_URL + '/issues/' + issueId, {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ }).then(function (r) { return r.json(); }).then(function (issue) {
+ var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
+ var opinion = opinions[opinionIndex] || '';
+
+ if (mode === 'opinion') {
+ var hm = opinion.trim().match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
+ if (hm) {
+ var restLines = opinion.trim().substring(hm[0].length).trim().split('\n');
+ var main = restLines.filter(function (l) { return !l.match(/^\s*[└↳]/); }).join('\n');
+ document.getElementById('editText').value = main;
+ }
+ } else if (mode === 'comment') {
+ var cLines = opinion.trim().split('\n');
+ var cc = -1;
+ for (var i = 0; i < cLines.length; i++) {
+ if (cLines[i].match(/^\s*└/)) {
+ cc++;
+ if (cc === commentIndex) {
+ var m = cLines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
+ if (m) document.getElementById('editText').value = m[3];
+ break;
+ }
+ }
+ }
+ } else if (mode === 'reply') {
+ var rLines = opinion.trim().split('\n');
+ var rc = -1;
+ var ri = -1;
+ for (var j = 0; j < rLines.length; j++) {
+ if (rLines[j].match(/^\s*└/)) { rc++; ri = -1; }
+ else if (rLines[j].match(/^\s*↳/)) {
+ ri++;
+ if (rc === commentIndex && ri === replyIndex) {
+ var rm = rLines[j].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
+ if (rm) document.getElementById('editText').value = rm[3];
+ break;
+ }
+ }
+ }
+ }
+ openSheet('edit');
+ });
+}
+
+async function submitEdit() {
+ var newText = document.getElementById('editText').value.trim();
+ if (!newText) { showToast('내용을 입력해주세요.', 'warning'); return; }
+
+ try {
+ var issue = await (await fetch(API_BASE_URL + '/issues/' + editIssueId, {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ })).json();
+
+ var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
+ var lines = opinions[editOpinionIndex].trim().split('\n');
+
+ if (editMode === 'opinion') {
+ var hm = opinions[editOpinionIndex].trim().match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
+ if (hm) {
+ var commentLines = lines.filter(function (l) { return l.match(/^\s*[└↳]/); });
+ opinions[editOpinionIndex] = hm[0] + '\n' + newText + (commentLines.length ? '\n' + commentLines.join('\n') : '');
+ }
+ } else if (editMode === 'comment') {
+ var cc = -1;
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].match(/^\s*└/)) {
+ cc++;
+ if (cc === editCommentIndex) {
+ var m = lines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):/);
+ if (m) lines[i] = ' └ [' + m[1] + '] (' + m[2] + '): ' + newText;
+ break;
+ }
+ }
+ }
+ opinions[editOpinionIndex] = lines.join('\n');
+ } else if (editMode === 'reply') {
+ var rc = -1, ri = -1;
+ for (var j = 0; j < lines.length; j++) {
+ if (lines[j].match(/^\s*└/)) { rc++; ri = -1; }
+ else if (lines[j].match(/^\s*↳/)) {
+ ri++;
+ if (rc === editCommentIndex && ri === editReplyIndex) {
+ var rm = lines[j].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):/);
+ if (rm) lines[j] = ' ↳ [' + rm[1] + '] (' + rm[2] + '): ' + newText;
+ break;
+ }
+ }
+ }
+ opinions[editOpinionIndex] = lines.join('\n');
+ }
+
+ var resp = await fetch(API_BASE_URL + '/issues/' + editIssueId + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
+ });
+
+ if (resp.ok) { showToast('수정되었습니다.', 'success'); closeSheet('edit'); await refreshData(); }
+ else throw new Error('저장 실패');
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 삭제 =====
+async function deleteOpinion(issueId, opinionIndex) {
+ if (!confirm('이 의견을 삭제하시겠습니까?')) return;
+ try {
+ var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json();
+ var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
+ opinions.splice(opinionIndex, 1);
+ await fetch(API_BASE_URL + '/issues/' + issueId + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ solution: opinions.length ? opinions.join('\n' + '─'.repeat(50) + '\n') : null })
+ });
+ showToast('의견이 삭제되었습니다.', 'success');
+ await refreshData();
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+async function deleteComment(issueId, opinionIndex, commentIndex) {
+ if (!confirm('이 댓글을 삭제하시겠습니까?')) return;
+ try {
+ var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json();
+ var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
+ var lines = opinions[opinionIndex].trim().split('\n');
+ var cc = -1, start = -1, end = -1;
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].match(/^\s*└/)) { cc++; if (cc === commentIndex) { start = i; end = i + 1; while (end < lines.length && lines[end].match(/^\s*↳/)) end++; break; } }
+ }
+ if (start >= 0) { lines.splice(start, end - start); opinions[opinionIndex] = lines.join('\n'); }
+ await fetch(API_BASE_URL + '/issues/' + issueId + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
+ });
+ showToast('댓글이 삭제되었습니다.', 'success');
+ await refreshData();
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+async function deleteReply(issueId, opinionIndex, commentIndex, replyIndex) {
+ if (!confirm('이 답글을 삭제하시겠습니까?')) return;
+ try {
+ var issue = await (await fetch(API_BASE_URL + '/issues/' + issueId, { headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() } })).json();
+ var opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
+ var lines = opinions[opinionIndex].trim().split('\n');
+ var cc = -1, ri = -1;
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].match(/^\s*└/)) { cc++; ri = -1; }
+ else if (lines[i].match(/^\s*↳/)) { ri++; if (cc === commentIndex && ri === replyIndex) { lines.splice(i, 1); break; } }
+ }
+ opinions[opinionIndex] = lines.join('\n');
+ await fetch(API_BASE_URL + '/issues/' + issueId + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ solution: opinions.join('\n' + '─'.repeat(50) + '\n') })
+ });
+ showToast('답글이 삭제되었습니다.', 'success');
+ await refreshData();
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 완료 신청 =====
+function openCompletionSheet(issueId) {
+ selectedCompletionIssueId = issueId;
+ completionPhotoBase64 = null;
+ document.getElementById('completionPhotoInput').value = '';
+ document.getElementById('completionPhotoPreview').classList.add('hidden');
+ document.getElementById('completionComment').value = '';
+ openSheet('completion');
+}
+
+function handleCompletionPhoto(event) {
+ var file = event.target.files[0];
+ if (!file) return;
+ if (file.size > 5 * 1024 * 1024) { showToast('파일 크기는 5MB 이하여야 합니다.', 'warning'); event.target.value = ''; return; }
+ if (!file.type.startsWith('image/')) { showToast('이미지 파일만 가능합니다.', 'warning'); event.target.value = ''; return; }
+ var reader = new FileReader();
+ reader.onload = function (e) {
+ completionPhotoBase64 = e.target.result.split(',')[1];
+ var preview = document.getElementById('completionPhotoPreview');
+ preview.src = e.target.result;
+ preview.classList.remove('hidden');
+ };
+ reader.readAsDataURL(file);
+}
+
+async function submitCompletionRequest() {
+ if (!selectedCompletionIssueId) return;
+ if (!completionPhotoBase64) { showToast('완료 사진을 업로드해주세요.', 'warning'); return; }
+
+ try {
+ var body = { completion_photo: completionPhotoBase64 };
+ var comment = document.getElementById('completionComment').value.trim();
+ if (comment) body.completion_comment = comment;
+
+ var resp = await fetch(API_BASE_URL + '/issues/' + selectedCompletionIssueId + '/request-completion', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+
+ if (resp.ok) {
+ showToast('완료 신청이 접수되었습니다.', 'success');
+ closeSheet('completion');
+ await refreshData();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '완료 신청 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 완료 반려 =====
+function openRejectionSheet(issueId) {
+ selectedRejectionIssueId = issueId;
+ document.getElementById('rejectionReason').value = '';
+ openSheet('rejection');
+}
+
+async function submitRejection() {
+ if (!selectedRejectionIssueId) return;
+ var reason = document.getElementById('rejectionReason').value.trim();
+ if (!reason) { showToast('반려 사유를 입력해주세요.', 'warning'); return; }
+
+ try {
+ var resp = await fetch(API_BASE_URL + '/issues/' + selectedRejectionIssueId + '/reject-completion', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rejection_reason: reason })
+ });
+
+ if (resp.ok) {
+ showToast('완료 신청이 반려되었습니다.', 'success');
+ closeSheet('rejection');
+ await refreshData();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '반려 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 새로고침 =====
+async function refreshData() {
+ await loadIssues();
+ filterByProject();
+ updateStatistics();
+}
+
+function refreshPage() {
+ location.reload();
+}
+
+// ===== 시작 =====
+document.addEventListener('DOMContentLoaded', initialize);
diff --git a/system3-nonconformance/web/static/js/m/m-inbox.js b/system3-nonconformance/web/static/js/m/m-inbox.js
new file mode 100644
index 0000000..3b4f1bf
--- /dev/null
+++ b/system3-nonconformance/web/static/js/m/m-inbox.js
@@ -0,0 +1,352 @@
+/**
+ * m-inbox.js — 수신함 모바일 페이지 로직
+ */
+
+var currentUser = null;
+var issues = [];
+var projects = [];
+var filteredIssues = [];
+var currentIssueId = null;
+var statusPhotoBase64 = null;
+
+// ===== 초기화 =====
+async function initialize() {
+ currentUser = await mCheckAuth();
+ if (!currentUser) return;
+
+ await loadProjects();
+ await loadIssues();
+ renderBottomNav('inbox');
+ hideLoading();
+}
+
+async function loadProjects() {
+ try {
+ var resp = await fetch(API_BASE_URL + '/projects/', {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (resp.ok) {
+ projects = await resp.json();
+ var sel = document.getElementById('projectFilter');
+ sel.innerHTML = '전체 프로젝트 ';
+ projects.forEach(function (p) {
+ sel.innerHTML += '' + escapeHtml(p.project_name) + ' ';
+ });
+ }
+ } catch (e) { console.error('프로젝트 로드 실패:', e); }
+}
+
+async function loadIssues() {
+ try {
+ var pid = document.getElementById('projectFilter').value;
+ var url = API_BASE_URL + '/inbox/' + (pid ? '?project_id=' + pid : '');
+ var resp = await fetch(url, {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (resp.ok) {
+ issues = await resp.json();
+ filterIssues();
+ await loadStatistics();
+ }
+ } catch (e) { console.error('수신함 로드 실패:', e); }
+}
+
+async function loadStatistics() {
+ try {
+ var todayStart = getKSTToday();
+
+ var todayNewCount = issues.filter(function (i) {
+ var d = getKSTDate(new Date(i.report_date));
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()) >= todayStart;
+ }).length;
+
+ var todayProcessedCount = 0;
+ try {
+ var resp = await fetch(API_BASE_URL + '/inbox/statistics', {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (resp.ok) { var s = await resp.json(); todayProcessedCount = s.today_processed || 0; }
+ } catch (e) {}
+
+ var unresolvedCount = issues.filter(function (i) {
+ var d = getKSTDate(new Date(i.report_date));
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()) < todayStart;
+ }).length;
+
+ document.getElementById('todayNewCount').textContent = todayNewCount;
+ document.getElementById('todayProcessedCount').textContent = todayProcessedCount;
+ document.getElementById('unresolvedCount').textContent = unresolvedCount;
+ } catch (e) { console.error('통계 로드 오류:', e); }
+}
+
+function filterIssues() {
+ var pid = document.getElementById('projectFilter').value;
+ filteredIssues = pid ? issues.filter(function (i) { return i.project_id == pid; }) : issues.slice();
+ filteredIssues.sort(function (a, b) { return new Date(b.report_date) - new Date(a.report_date); });
+ renderIssues();
+}
+
+// ===== 렌더링 =====
+function renderIssues() {
+ var container = document.getElementById('issuesList');
+ var empty = document.getElementById('emptyState');
+
+ if (!filteredIssues.length) { container.innerHTML = ''; empty.classList.remove('hidden'); return; }
+ empty.classList.add('hidden');
+
+ container.innerHTML = filteredIssues.map(function (issue) {
+ var project = projects.find(function (p) { return p.id === issue.project_id; });
+ var photos = getPhotoPaths(issue);
+ var photoCount = photos.length;
+
+ return '' +
+ '' +
+ '
' + escapeHtml(issue.final_description || issue.description) + '
' +
+ '
' +
+ '
' +
+ ' ' + escapeHtml(issue.reporter?.username || '알 수 없음') + ' ' +
+ ' ' + getCategoryText(issue.category || issue.final_category) + ' ' +
+ ' ' + (photoCount > 0 ? photoCount + '장' : '없음') + ' ' +
+ ' ' + getTimeAgo(issue.report_date) + ' ' +
+ '
' +
+ (photos.length ? renderPhotoThumbs(photos) : '') +
+ (issue.detail_notes ? '
"' + escapeHtml(issue.detail_notes) + '"
' : '') +
+ '
' +
+ '
' +
+ ' 폐기 ' +
+ ' 검토 ' +
+ ' 확인 ' +
+ '
' +
+ '
';
+ }).join('');
+}
+
+// ===== 폐기 =====
+function openDisposeSheet(issueId) {
+ currentIssueId = issueId;
+ document.getElementById('disposalReason').value = 'duplicate';
+ document.getElementById('customReason').value = '';
+ document.getElementById('customReasonDiv').classList.add('hidden');
+ document.getElementById('selectedDuplicateId').value = '';
+ toggleDisposalFields();
+ openSheet('dispose');
+ loadManagementIssues();
+}
+
+function toggleDisposalFields() {
+ var reason = document.getElementById('disposalReason').value;
+ document.getElementById('customReasonDiv').classList.toggle('hidden', reason !== 'custom');
+ document.getElementById('duplicateDiv').classList.toggle('hidden', reason !== 'duplicate');
+ if (reason === 'duplicate') loadManagementIssues();
+}
+
+async function loadManagementIssues() {
+ var issue = issues.find(function (i) { return i.id === currentIssueId; });
+ var pid = issue ? issue.project_id : null;
+
+ try {
+ var resp = await fetch(API_BASE_URL + '/inbox/management-issues' + (pid ? '?project_id=' + pid : ''), {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (!resp.ok) throw new Error('로드 실패');
+ var list = await resp.json();
+ var container = document.getElementById('managementIssuesList');
+
+ if (!list.length) {
+ container.innerHTML = '동일 프로젝트의 관리함 이슈가 없습니다.
';
+ return;
+ }
+
+ container.innerHTML = list.map(function (mi) {
+ return '' +
+ '
' + escapeHtml(mi.description || mi.final_description) + '
' +
+ '
' +
+ '' + getCategoryText(mi.category || mi.final_category) + ' ' +
+ '신고자: ' + escapeHtml(mi.reporter_name) + ' ' +
+ 'ID: ' + mi.id + ' ' +
+ '
';
+ }).join('');
+ } catch (e) {
+ document.getElementById('managementIssuesList').innerHTML = '목록 로드 실패
';
+ }
+}
+
+function selectDuplicate(id, el) {
+ var items = document.getElementById('managementIssuesList').children;
+ for (var i = 0; i < items.length; i++) items[i].style.background = '';
+ el.style.background = '#eff6ff';
+ document.getElementById('selectedDuplicateId').value = id;
+}
+
+async function confirmDispose() {
+ if (!currentIssueId) return;
+ var reason = document.getElementById('disposalReason').value;
+ var customReason = document.getElementById('customReason').value;
+ var duplicateId = document.getElementById('selectedDuplicateId').value;
+
+ if (reason === 'custom' && !customReason.trim()) { showToast('폐기 사유를 입력해주세요.', 'warning'); return; }
+ if (reason === 'duplicate' && !duplicateId) { showToast('중복 대상을 선택해주세요.', 'warning'); return; }
+
+ try {
+ var body = { disposal_reason: reason, custom_disposal_reason: reason === 'custom' ? customReason : null };
+ if (reason === 'duplicate' && duplicateId) body.duplicate_of_issue_id = parseInt(duplicateId);
+
+ var resp = await fetch(API_BASE_URL + '/inbox/' + currentIssueId + '/dispose', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+
+ if (resp.ok) {
+ showToast('폐기 처리되었습니다.', 'success');
+ closeSheet('dispose');
+ await loadIssues();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '폐기 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 검토 =====
+function openReviewSheet(issueId) {
+ currentIssueId = issueId;
+ var issue = issues.find(function (i) { return i.id === issueId; });
+ if (!issue) return;
+
+ var project = projects.find(function (p) { return p.id === issue.project_id; });
+
+ // 원본 정보
+ document.getElementById('originalInfo').innerHTML =
+ '프로젝트: ' + (project ? escapeHtml(project.project_name) : '미지정') + '
' +
+ '신고자: ' + escapeHtml(issue.reporter?.username || '알 수 없음') + '
' +
+ '등록일: ' + formatKSTDate(issue.report_date) + '
';
+
+ // 프로젝트 select
+ var sel = document.getElementById('reviewProjectId');
+ sel.innerHTML = '프로젝트 선택 ';
+ projects.forEach(function (p) {
+ sel.innerHTML += '' + escapeHtml(p.project_name) + ' ';
+ });
+
+ document.getElementById('reviewCategory').value = issue.category || issue.final_category || 'etc';
+ var desc = issue.description || issue.final_description || '';
+ var lines = desc.split('\n');
+ document.getElementById('reviewTitle').value = lines[0] || '';
+ document.getElementById('reviewDescription').value = lines.slice(1).join('\n') || desc;
+
+ openSheet('review');
+}
+
+async function saveReview() {
+ if (!currentIssueId) return;
+ var projectId = document.getElementById('reviewProjectId').value;
+ var category = document.getElementById('reviewCategory').value;
+ var title = document.getElementById('reviewTitle').value.trim();
+ var description = document.getElementById('reviewDescription').value.trim();
+
+ if (!title) { showToast('부적합명을 입력해주세요.', 'warning'); return; }
+
+ var combined = title + (description ? '\n' + description : '');
+
+ try {
+ var resp = await fetch(API_BASE_URL + '/inbox/' + currentIssueId + '/review', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ project_id: projectId ? parseInt(projectId) : null,
+ category: category,
+ description: combined
+ })
+ });
+
+ if (resp.ok) {
+ showToast('검토가 완료되었습니다.', 'success');
+ closeSheet('review');
+ await loadIssues();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '검토 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 확인 (상태 결정) =====
+function openStatusSheet(issueId) {
+ currentIssueId = issueId;
+ document.querySelectorAll('input[name="finalStatus"]').forEach(function (r) { r.checked = false; });
+ document.querySelectorAll('.m-radio-item').forEach(function (el) { el.classList.remove('selected'); });
+ document.getElementById('completionSection').classList.add('hidden');
+ statusPhotoBase64 = null;
+ document.getElementById('statusPhotoInput').value = '';
+ document.getElementById('statusPhotoPreview').classList.add('hidden');
+ document.getElementById('solutionInput').value = '';
+ document.getElementById('responsibleDepartmentInput').value = '';
+ document.getElementById('responsiblePersonInput').value = '';
+ openSheet('status');
+}
+
+function selectStatus(value) {
+ document.querySelectorAll('.m-radio-item').forEach(function (el) { el.classList.remove('selected'); });
+ var radio = document.querySelector('input[name="finalStatus"][value="' + value + '"]');
+ if (radio) { radio.checked = true; radio.closest('.m-radio-item').classList.add('selected'); }
+ document.getElementById('completionSection').classList.toggle('hidden', value !== 'completed');
+}
+
+function handleStatusPhoto(event) {
+ var file = event.target.files[0];
+ if (!file) return;
+ if (file.size > 5 * 1024 * 1024) { showToast('5MB 이하 파일만 가능합니다.', 'warning'); event.target.value = ''; return; }
+ var reader = new FileReader();
+ reader.onload = function (e) {
+ statusPhotoBase64 = e.target.result.split(',')[1];
+ var preview = document.getElementById('statusPhotoPreview');
+ preview.src = e.target.result;
+ preview.classList.remove('hidden');
+ };
+ reader.readAsDataURL(file);
+}
+
+async function confirmStatus() {
+ if (!currentIssueId) return;
+ var selected = document.querySelector('input[name="finalStatus"]:checked');
+ if (!selected) { showToast('상태를 선택해주세요.', 'warning'); return; }
+
+ var reviewStatus = selected.value;
+ var body = { review_status: reviewStatus };
+
+ if (reviewStatus === 'completed') {
+ var solution = document.getElementById('solutionInput').value.trim();
+ var dept = document.getElementById('responsibleDepartmentInput').value;
+ var person = document.getElementById('responsiblePersonInput').value.trim();
+ if (solution) body.solution = solution;
+ if (dept) body.responsible_department = dept;
+ if (person) body.responsible_person = person;
+ if (statusPhotoBase64) body.completion_photo = statusPhotoBase64;
+ }
+
+ try {
+ var resp = await fetch(API_BASE_URL + '/inbox/' + currentIssueId + '/status', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+
+ if (resp.ok) {
+ showToast('상태가 변경되었습니다.', 'success');
+ closeSheet('status');
+ await loadIssues();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '상태 변경 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 시작 =====
+document.addEventListener('DOMContentLoaded', initialize);
diff --git a/system3-nonconformance/web/static/js/m/m-management.js b/system3-nonconformance/web/static/js/m/m-management.js
new file mode 100644
index 0000000..245376d
--- /dev/null
+++ b/system3-nonconformance/web/static/js/m/m-management.js
@@ -0,0 +1,436 @@
+/**
+ * m-management.js — 관리함 모바일 페이지 로직
+ */
+
+var currentUser = null;
+var issues = [];
+var projects = [];
+var filteredIssues = [];
+var currentTab = 'in_progress';
+var currentIssueId = null;
+var rejectIssueId = null;
+
+function cleanManagementComment(text) {
+ if (!text) return '';
+ return text.replace(/\[완료 반려[^\]]*\][^\n]*\n*/g, '').trim();
+}
+
+// ===== 초기화 =====
+async function initialize() {
+ currentUser = await mCheckAuth();
+ if (!currentUser) return;
+
+ await loadProjects();
+ await loadIssues();
+ renderBottomNav('management');
+ hideLoading();
+}
+
+async function loadProjects() {
+ try {
+ var resp = await fetch(API_BASE_URL + '/projects/', {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (resp.ok) {
+ projects = await resp.json();
+ var sel = document.getElementById('projectFilter');
+ sel.innerHTML = '전체 프로젝트 ';
+ projects.forEach(function (p) {
+ sel.innerHTML += '' + escapeHtml(p.project_name) + ' ';
+ });
+ }
+ } catch (e) { console.error('프로젝트 로드 실패:', e); }
+}
+
+async function loadIssues() {
+ try {
+ var resp = await fetch(API_BASE_URL + '/issues/admin/all', {
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken() }
+ });
+ if (resp.ok) {
+ var all = await resp.json();
+ var filtered = all.filter(function (i) { return i.review_status === 'in_progress' || i.review_status === 'completed'; });
+
+ // 프로젝트별 순번
+ filtered.sort(function (a, b) { return new Date(a.reviewed_at) - new Date(b.reviewed_at); });
+ var groups = {};
+ filtered.forEach(function (issue) {
+ if (!groups[issue.project_id]) groups[issue.project_id] = [];
+ groups[issue.project_id].push(issue);
+ });
+ Object.keys(groups).forEach(function (pid) {
+ groups[pid].forEach(function (issue, idx) { issue.project_sequence_no = idx + 1; });
+ });
+
+ issues = filtered;
+ filterIssues();
+ }
+ } catch (e) { console.error('이슈 로드 실패:', e); }
+}
+
+// ===== 탭 전환 =====
+function switchTab(tab) {
+ currentTab = tab;
+ document.getElementById('tabInProgress').classList.toggle('active', tab === 'in_progress');
+ document.getElementById('tabCompleted').classList.toggle('active', tab === 'completed');
+ document.getElementById('additionalInfoBtn').style.display = tab === 'in_progress' ? 'flex' : 'none';
+ filterIssues();
+}
+
+// ===== 통계 =====
+function updateStatistics() {
+ var pid = document.getElementById('projectFilter').value;
+ var pi = pid ? issues.filter(function (i) { return i.project_id == pid; }) : issues;
+
+ document.getElementById('totalCount').textContent = pi.length;
+ document.getElementById('inProgressCount').textContent = pi.filter(function (i) { return i.review_status === 'in_progress' && !i.completion_requested_at; }).length;
+ document.getElementById('pendingCompletionCount').textContent = pi.filter(function (i) { return i.review_status === 'in_progress' && i.completion_requested_at; }).length;
+ document.getElementById('completedCount').textContent = pi.filter(function (i) { return i.review_status === 'completed'; }).length;
+}
+
+// ===== 필터 =====
+function filterIssues() {
+ var pid = document.getElementById('projectFilter').value;
+ filteredIssues = issues.filter(function (i) {
+ if (i.review_status !== currentTab) return false;
+ if (pid && i.project_id != pid) return false;
+ return true;
+ });
+ filteredIssues.sort(function (a, b) { return new Date(b.report_date) - new Date(a.report_date); });
+ renderIssues();
+ updateStatistics();
+}
+
+// ===== 이슈 상태 =====
+function getIssueStatus(issue) {
+ if (issue.review_status === 'completed') return 'completed';
+ if (issue.completion_requested_at) return 'pending_completion';
+ if (issue.expected_completion_date) {
+ var diff = (new Date(issue.expected_completion_date) - new Date()) / 86400000;
+ if (diff < 0) return 'overdue';
+ if (diff <= 3) return 'urgent';
+ }
+ return 'in_progress';
+}
+
+function getStatusBadgeHtml(status) {
+ var map = {
+ 'in_progress': ' 진행 중 ',
+ 'urgent': ' 긴급 ',
+ 'overdue': ' 지연됨 ',
+ 'pending_completion': ' 완료 대기 ',
+ 'completed': ' 완료됨 '
+ };
+ return map[status] || map['in_progress'];
+}
+
+// ===== 렌더링 =====
+function renderIssues() {
+ var container = document.getElementById('issuesList');
+ var empty = document.getElementById('emptyState');
+
+ if (!filteredIssues.length) { container.innerHTML = ''; empty.classList.remove('hidden'); return; }
+ empty.classList.add('hidden');
+
+ // 날짜별 그룹
+ var grouped = {};
+ var dateObjs = {};
+ filteredIssues.forEach(function (issue) {
+ var dateToUse = currentTab === 'completed' ? (issue.actual_completion_date || issue.report_date) : issue.report_date;
+ var d = new Date(dateToUse);
+ var key = d.toLocaleDateString('ko-KR');
+ if (!grouped[key]) { grouped[key] = []; dateObjs[key] = d; }
+ grouped[key].push(issue);
+ });
+
+ var html = Object.keys(grouped)
+ .sort(function (a, b) { return dateObjs[b] - dateObjs[a]; })
+ .map(function (dateKey) {
+ var issues = grouped[dateKey];
+ return '' +
+ issues.map(function (issue) {
+ return currentTab === 'in_progress' ? renderInProgressCard(issue) : renderCompletedCard(issue);
+ }).join('') +
+ '
';
+ }).join('');
+
+ container.innerHTML = html;
+}
+
+function renderInProgressCard(issue) {
+ var project = projects.find(function (p) { return p.id === issue.project_id; });
+ var status = getIssueStatus(issue);
+ var isPending = status === 'pending_completion';
+ var photos = getPhotoPaths(issue);
+
+ // 관리 필드 표시
+ var mgmtHtml = '' +
+ '
해결방안: ' + escapeHtml(cleanManagementComment(issue.management_comment) || '-') + '
' +
+ '
담당부서: ' + getDepartmentText(issue.responsible_department) + '
' +
+ '
담당자: ' + escapeHtml(issue.responsible_person || '-') + '
' +
+ '
조치예상일: ' + (issue.expected_completion_date ? formatKSTDate(issue.expected_completion_date) : '-') + '
' +
+ '
';
+
+ // 완료 대기 정보
+ var completionInfoHtml = '';
+ if (isPending) {
+ var cPhotos = getCompletionPhotoPaths(issue);
+ completionInfoHtml = '' +
+ '
완료 신청 정보
' +
+ (cPhotos.length ? renderPhotoThumbs(cPhotos) : '') +
+ '
' + escapeHtml(issue.completion_comment || '코멘트 없음') + '
' +
+ '
신청: ' + formatKSTDateTime(issue.completion_requested_at) + '
' +
+ '
';
+ }
+
+ // 액션 버튼
+ var actionHtml = '';
+ if (isPending) {
+ actionHtml = '' +
+ ' 반려 ' +
+ ' 최종확인 ' +
+ '
';
+ } else {
+ actionHtml = '' +
+ ' 편집 ' +
+ ' 완료처리 ' +
+ '
';
+ }
+
+ return '' +
+ '' +
+ '
' + escapeHtml(getIssueTitle(issue)) + '
' +
+ '
' +
+ '
' + escapeHtml(getIssueDetail(issue)) + '
' +
+ '
' +
+ ' ' + getCategoryText(issue.category || issue.final_category) + ' ' +
+ ' ' + escapeHtml(issue.reporter?.full_name || issue.reporter?.username || '-') + ' ' +
+ '
' +
+ (photos.length ? renderPhotoThumbs(photos) : '') +
+ mgmtHtml +
+ completionInfoHtml +
+ '
' +
+ actionHtml +
+ '' +
+ '
';
+}
+
+function renderCompletedCard(issue) {
+ var project = projects.find(function (p) { return p.id === issue.project_id; });
+ var completedDate = issue.completed_at ? formatKSTDate(issue.completed_at) : '-';
+
+ return '' +
+ '' +
+ '
' + escapeHtml(getIssueTitle(issue)) + '
' +
+ '' +
+ '
';
+}
+
+// ===== 편집 시트 =====
+function openEditMgmtSheet(issueId) {
+ currentIssueId = issueId;
+ var issue = issues.find(function (i) { return i.id === issueId; });
+ if (!issue) return;
+
+ document.getElementById('editManagementComment').value = cleanManagementComment(issue.management_comment) || '';
+ document.getElementById('editResponsibleDept').value = issue.responsible_department || '';
+ document.getElementById('editResponsiblePerson').value = issue.responsible_person || '';
+ document.getElementById('editExpectedDate').value = issue.expected_completion_date ? issue.expected_completion_date.split('T')[0] : '';
+ openSheet('editMgmt');
+}
+
+async function saveManagementEdit() {
+ if (!currentIssueId) return;
+ try {
+ var updates = {
+ management_comment: document.getElementById('editManagementComment').value.trim() || null,
+ responsible_department: document.getElementById('editResponsibleDept').value || null,
+ responsible_person: document.getElementById('editResponsiblePerson').value.trim() || null,
+ expected_completion_date: document.getElementById('editExpectedDate').value ? document.getElementById('editExpectedDate').value + 'T00:00:00' : null
+ };
+
+ var resp = await fetch(API_BASE_URL + '/issues/' + currentIssueId + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify(updates)
+ });
+
+ if (resp.ok) {
+ showToast('저장되었습니다.', 'success');
+ closeSheet('editMgmt');
+ await loadIssues();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '저장 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 완료 처리 =====
+async function confirmCompletion(issueId) {
+ if (!confirm('완료 처리하시겠습니까?')) return;
+ try {
+ var resp = await fetch(API_BASE_URL + '/inbox/' + issueId + '/status', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ review_status: 'completed' })
+ });
+
+ if (resp.ok) {
+ showToast('완료 처리되었습니다.', 'success');
+ await loadIssues();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '완료 처리 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 반려 =====
+function openRejectSheet(issueId) {
+ rejectIssueId = issueId;
+ document.getElementById('rejectReason').value = '';
+ openSheet('reject');
+}
+
+async function submitReject() {
+ if (!rejectIssueId) return;
+ var reason = document.getElementById('rejectReason').value.trim();
+ if (!reason) { showToast('반려 사유를 입력해주세요.', 'warning'); return; }
+
+ try {
+ var resp = await fetch(API_BASE_URL + '/issues/' + rejectIssueId + '/reject-completion', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rejection_reason: reason })
+ });
+
+ if (resp.ok) {
+ showToast('반려 처리되었습니다.', 'success');
+ closeSheet('reject');
+ await loadIssues();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '반려 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 추가 정보 =====
+function openAdditionalInfoSheet() {
+ var inProgressIssues = issues.filter(function (i) { return i.review_status === 'in_progress'; });
+ var sel = document.getElementById('additionalIssueSelect');
+ sel.innerHTML = '이슈 선택 ';
+ inProgressIssues.forEach(function (i) {
+ var p = projects.find(function (pr) { return pr.id === i.project_id; });
+ sel.innerHTML += 'No.' + (i.project_sequence_no || '-') + ' ' + escapeHtml(getIssueTitle(i)) + ' ';
+ });
+ document.getElementById('additionalCauseDept').value = '';
+ document.getElementById('additionalCausePerson').value = '';
+ document.getElementById('additionalCauseDetail').value = '';
+ openSheet('additional');
+}
+
+function loadAdditionalInfo() {
+ var id = parseInt(document.getElementById('additionalIssueSelect').value);
+ if (!id) return;
+ var issue = issues.find(function (i) { return i.id === id; });
+ if (!issue) return;
+ document.getElementById('additionalCauseDept').value = issue.cause_department || '';
+ document.getElementById('additionalCausePerson').value = issue.cause_person || '';
+ document.getElementById('additionalCauseDetail').value = issue.cause_detail || '';
+}
+
+async function saveAdditionalInfo() {
+ var id = parseInt(document.getElementById('additionalIssueSelect').value);
+ if (!id) { showToast('이슈를 선택해주세요.', 'warning'); return; }
+
+ try {
+ var resp = await fetch(API_BASE_URL + '/issues/' + id + '/management', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + TokenManager.getToken(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ cause_department: document.getElementById('additionalCauseDept').value || null,
+ cause_person: document.getElementById('additionalCausePerson').value.trim() || null,
+ cause_detail: document.getElementById('additionalCauseDetail').value.trim() || null
+ })
+ });
+
+ if (resp.ok) {
+ showToast('추가 정보가 저장되었습니다.', 'success');
+ closeSheet('additional');
+ await loadIssues();
+ } else {
+ var err = await resp.json();
+ throw new Error(err.detail || '저장 실패');
+ }
+ } catch (e) { showToast('오류: ' + e.message, 'error'); }
+}
+
+// ===== 완료됨 상세보기 =====
+function openDetailSheet(issueId) {
+ var issue = issues.find(function (i) { return i.id === issueId; });
+ if (!issue) return;
+ var project = projects.find(function (p) { return p.id === issue.project_id; });
+ var photos = getPhotoPaths(issue);
+ var cPhotos = getCompletionPhotoPaths(issue);
+
+ document.getElementById('detailSheetTitle').innerHTML =
+ 'No.' + (issue.project_sequence_no || '-') + ' 상세 정보';
+
+ document.getElementById('detailSheetBody').innerHTML =
+ // 기본 정보
+ '' +
+ '
기본 정보
' +
+ '
프로젝트: ' + escapeHtml(project ? project.project_name : '-') + '
' +
+ '
부적합명: ' + escapeHtml(getIssueTitle(issue)) + '
' +
+ '
' + escapeHtml(getIssueDetail(issue)) + '
' +
+ '
분류: ' + getCategoryText(issue.final_category || issue.category) + '
' +
+ '
확인자: ' + escapeHtml(getReporterNames(issue)) + '
' +
+ (photos.length ? '
업로드 사진
' + renderPhotoThumbs(photos) + '
' : '') +
+ '
' +
+
+ // 관리 정보
+ '' +
+ '
관리 정보
' +
+ '
해결방안: ' + escapeHtml(cleanManagementComment(issue.management_comment) || '-') + '
' +
+ '
담당부서: ' + getDepartmentText(issue.responsible_department) + '
' +
+ '
담당자: ' + escapeHtml(issue.responsible_person || '-') + '
' +
+ '
원인부서: ' + getDepartmentText(issue.cause_department) + '
' +
+ '
' +
+
+ // 완료 정보
+ '' +
+ '
완료 정보
' +
+ (cPhotos.length ? '
완료 사진
' + renderPhotoThumbs(cPhotos) + '
' : '
완료 사진 없음
') +
+ '
완료 코멘트: ' + escapeHtml(issue.completion_comment || '-') + '
' +
+ (issue.completion_requested_at ? '
완료 신청일: ' + formatKSTDateTime(issue.completion_requested_at) + '
' : '') +
+ (issue.completed_at ? '
최종 완료일: ' + formatKSTDateTime(issue.completed_at) + '
' : '') +
+ '
';
+
+ openSheet('detail');
+}
+
+// ===== 시작 =====
+document.addEventListener('DOMContentLoaded', initialize);
diff --git a/system3-nonconformance/web/static/js/pages/issues-management.js b/system3-nonconformance/web/static/js/pages/issues-management.js
index 81bc8a1..26971ac 100644
--- a/system3-nonconformance/web/static/js/pages/issues-management.js
+++ b/system3-nonconformance/web/static/js/pages/issues-management.js
@@ -326,47 +326,44 @@ function createInProgressRow(issue, project) {
return `
-
-
-
-
-
No.${issue.project_sequence_no || '-'}
-
+