From 9b81a522834dbf3e1399084b85eb5b18e92a8bbf Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 5 Mar 2026 13:34:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(system3):=20TKQC=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=84=EC=9A=A9=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=8D=B0=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=ED=83=91=20=EA=B4=80=EB=A6=AC=ED=95=A8=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모바일 전용 페이지 신규: /m/dashboard, /m/inbox, /m/management - 공통 모바일 CSS/JS: m-common.css, m-common.js (바텀시트, 바텀네비, 터치 최적화) - nginx.conf에 /m/ location 블록 추가 - 데스크탑 HTML에 모바일 뷰포트 리다이렉트 추가 (<=768px) - 데스크탑 관리함 카드 헤더 반응형 레이아웃 (flex-wrap, 1280px 브레이크포인트) - collapse-content overflow:hidden → overflow:visible 수정 (내용 잘림 해결) Co-Authored-By: Claude Opus 4.6 --- .../api/database/database.py | 2 +- system3-nonconformance/api/routers/issues.py | 30 +- .../web/issues-dashboard.html | 1 + system3-nonconformance/web/issues-inbox.html | 1 + .../web/issues-management.html | 1 + system3-nonconformance/web/m/dashboard.html | 195 +++++ system3-nonconformance/web/m/inbox.html | 203 +++++ system3-nonconformance/web/m/management.html | 179 +++++ system3-nonconformance/web/nginx.conf | 10 + .../web/static/css/issues-management.css | 64 +- .../web/static/css/m-common.css | 489 +++++++++++ .../web/static/js/m/m-common.js | 195 +++++ .../web/static/js/m/m-dashboard.js | 757 ++++++++++++++++++ .../web/static/js/m/m-inbox.js | 352 ++++++++ .../web/static/js/m/m-management.js | 436 ++++++++++ .../web/static/js/pages/issues-management.js | 110 ++- 16 files changed, 2952 insertions(+), 73 deletions(-) create mode 100644 system3-nonconformance/web/m/dashboard.html create mode 100644 system3-nonconformance/web/m/inbox.html create mode 100644 system3-nonconformance/web/m/management.html create mode 100644 system3-nonconformance/web/static/css/m-common.css create mode 100644 system3-nonconformance/web/static/js/m/m-common.js create mode 100644 system3-nonconformance/web/static/js/m/m-dashboard.js create mode 100644 system3-nonconformance/web/static/js/m/m-inbox.js create mode 100644 system3-nonconformance/web/static/js/m/m-management.js 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 '사진 ' + (i + 1) + ''; + }).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 += ''; + }); + } + } 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 '
' + + '' + + '' + dateKey + '' + + '(' + issues.length + '건)
' + + 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 '
' + + '
' + + '
No.' + (issue.project_sequence_no || '-') + '' + + '' + escapeHtml(projectName) + '
' + + getStatusBadgeHtml(status) + + '
' + + '
' + 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 = '' + + ''; + 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 += ''; + }); + } + } 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 '
' + + '
' + + '
검토 대기' + + (project ? '' + escapeHtml(project.project_name) + '' : '') + + '
' + + 'ID: ' + issue.id + '' + + '
' + + '
' + 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 += ''; + }); + + 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 += ''; + }); + } + } 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 '
' + + '' + + '' + dateKey + '' + + '(' + issues.length + '건)' + + '' + + (currentTab === 'in_progress' ? '업로드일' : '완료일') + '' + + '
' + + 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 '
' + + '
' + + '
No.' + (issue.project_sequence_no || '-') + '' + + '' + escapeHtml(project ? project.project_name : '미지정') + '
' + + getStatusBadgeHtml(status) + + '
' + + '
' + 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 '
' + + '
' + + '
No.' + (issue.project_sequence_no || '-') + '' + + '' + escapeHtml(project ? project.project_name : '미지정') + '
' + + ' 완료' + + '
' + + '
' + 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 += ''; + }); + 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 || '-'} -
+
+
+
+
+ No.${issue.project_sequence_no || '-'} +
+ ${project ? project.project_name : '프로젝트 미지정'} +
+
+ ${statusConfig.text} + +
- ${project ? project.project_name : '프로젝트 미지정'} - -
-
- ${statusConfig.text} - +
+

${getIssueTitle(issue)}

-
-

${getIssueTitle(issue)}

+
+ ${isPendingCompletion ? ` + + + ` : ` + + + + `}
-
- ${isPendingCompletion ? ` - - - - ` : ` - - - - - `} -
@@ -556,35 +553,34 @@ function createCompletedRow(issue, project) { return `
-
-
-
-
- No.${issue.project_sequence_no || '-'} -
+
+
+
+
+ No.${issue.project_sequence_no || '-'} +
+ ${project ? project.project_name : '프로젝트 미지정'} +
+
+ 완료됨 + +
+ 완료일: ${completedDate}
- ${project ? project.project_name : '프로젝트 미지정'} - -
-
- 완료됨 - +
+

${getIssueTitle(issue)}

- 완료일: ${completedDate}
-
-

${getIssueTitle(issue)}

+
+
-
- -
-
+