feat(system3): TKQC 모바일 전용 페이지 구현 및 데스크탑 관리함 반응형 개선

- 모바일 전용 페이지 신규: /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 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-05 13:34:52 +09:00
parent abd7564e6b
commit 9b81a52283
16 changed files with 2952 additions and 73 deletions

View File

@@ -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()

View File

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

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>부적합 현황판</title>
<script>if(window.innerWidth<=768)window.location.replace('/m/dashboard.html');</script>
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>수신함 - 작업보고서</title>
<script>if(window.innerWidth<=768)window.location.replace('/m/inbox.html');</script>
<!-- Tailwind CSS -->
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리함 - 작업보고서</title>
<script>if(window.innerWidth<=768)window.location.replace('/m/management.html');</script>
<!-- Tailwind CSS -->
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>부적합 현황판</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/m-common.css?v=20260305">
</head>
<body>
<!-- 로딩 -->
<div id="loadingOverlay" class="m-loading-overlay">
<div class="m-spinner"></div>
<p>현황판을 불러오는 중...</p>
</div>
<!-- 고정 상단 헤더 -->
<header class="m-header">
<div class="m-header-title">
<i class="fas fa-chart-line" style="color:#3b82f6"></i>
현황판
</div>
<div class="m-header-actions">
<button class="m-header-btn" onclick="refreshPage()"><i class="fas fa-sync-alt"></i></button>
</div>
</header>
<!-- 통계 바 -->
<div class="m-stats-bar" id="statsBar">
<div class="m-stat-pill blue"><span>전체</span><span class="m-stat-value" id="totalInProgress">0</span></div>
<div class="m-stat-pill green"><span>오늘 신규</span><span class="m-stat-value" id="todayNew">0</span></div>
<div class="m-stat-pill purple"><span>완료 대기</span><span class="m-stat-value" id="pendingCompletion">0</span></div>
<div class="m-stat-pill red"><span>지연</span><span class="m-stat-value" id="overdue">0</span></div>
</div>
<!-- 프로젝트 필터 -->
<div class="m-filter-bar">
<select class="m-filter-select" id="projectFilter" onchange="filterByProject()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 이슈 카드 리스트 -->
<div id="issuesList"></div>
<!-- 빈 상태 -->
<div id="emptyState" class="m-empty hidden">
<i class="fas fa-chart-line"></i>
<p>진행 중인 부적합이 없습니다</p>
</div>
<!-- ===== 바텀시트: 의견 제시 ===== -->
<div id="opinionOverlay" class="m-sheet-overlay" onclick="closeSheet('opinion')"></div>
<div id="opinionSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-comment-medical" style="color:#22c55e;margin-right:6px"></i>의견 제시</span>
<button class="m-sheet-close" onclick="closeSheet('opinion')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">의견 내용</label>
<textarea id="opinionText" class="m-textarea" rows="4" placeholder="해결 방안에 대한 의견을 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn green" onclick="submitOpinion()">
<i class="fas fa-paper-plane"></i>의견 제출
</button>
</div>
</div>
<!-- ===== 바텀시트: 댓글 추가 ===== -->
<div id="commentOverlay" class="m-sheet-overlay" onclick="closeSheet('comment')"></div>
<div id="commentSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-comment" style="color:#3b82f6;margin-right:6px"></i>댓글 추가</span>
<button class="m-sheet-close" onclick="closeSheet('comment')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">댓글 내용</label>
<textarea id="commentText" class="m-textarea" rows="3" placeholder="의견에 대한 댓글을 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="submitComment()">
<i class="fas fa-paper-plane"></i>댓글 추가
</button>
</div>
</div>
<!-- ===== 바텀시트: 답글 추가 ===== -->
<div id="replyOverlay" class="m-sheet-overlay" onclick="closeSheet('reply')"></div>
<div id="replySheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-reply" style="color:#3b82f6;margin-right:6px"></i>답글 추가</span>
<button class="m-sheet-close" onclick="closeSheet('reply')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">답글 내용</label>
<textarea id="replyText" class="m-textarea" rows="3" placeholder="댓글에 대한 답글을 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="submitReply()">
<i class="fas fa-paper-plane"></i>답글 추가
</button>
</div>
</div>
<!-- ===== 바텀시트: 의견/댓글/답글 수정 ===== -->
<div id="editOverlay" class="m-sheet-overlay" onclick="closeSheet('edit')"></div>
<div id="editSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title" id="editSheetTitle"><i class="fas fa-edit" style="color:#22c55e;margin-right:6px"></i>수정</span>
<button class="m-sheet-close" onclick="closeSheet('edit')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">내용</label>
<textarea id="editText" class="m-textarea" rows="4"></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn green" onclick="submitEdit()">
<i class="fas fa-save"></i>수정 완료
</button>
</div>
</div>
<!-- ===== 바텀시트: 완료 신청 ===== -->
<div id="completionOverlay" class="m-sheet-overlay" onclick="closeSheet('completion')"></div>
<div id="completionSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-check-circle" style="color:#22c55e;margin-right:6px"></i>완료 신청</span>
<button class="m-sheet-close" onclick="closeSheet('completion')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label"><i class="fas fa-camera" style="color:#22c55e;margin-right:4px"></i>완료 사진 (필수)</label>
<div class="m-photo-upload" onclick="document.getElementById('completionPhotoInput').click()">
<i class="fas fa-cloud-upload-alt"></i>
<p>탭하여 완료 사진 업로드</p>
</div>
<input type="file" id="completionPhotoInput" accept="image/*" capture="environment" class="hidden" onchange="handleCompletionPhoto(event)">
<img id="completionPhotoPreview" class="m-photo-preview hidden">
</div>
<div class="m-form-group">
<label class="m-label">완료 코멘트 (선택)</label>
<textarea id="completionComment" class="m-textarea" rows="3" placeholder="완료 상황에 대한 간단한 설명..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn green" onclick="submitCompletionRequest()">
<i class="fas fa-paper-plane"></i>완료 신청
</button>
</div>
</div>
<!-- ===== 바텀시트: 완료 반려 ===== -->
<div id="rejectionOverlay" class="m-sheet-overlay" onclick="closeSheet('rejection')"></div>
<div id="rejectionSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-times-circle" style="color:#ef4444;margin-right:6px"></i>완료 반려</span>
<button class="m-sheet-close" onclick="closeSheet('rejection')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">반려 사유</label>
<textarea id="rejectionReason" class="m-textarea" rows="4" placeholder="완료 신청을 반려하는 사유를 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn red" onclick="submitRejection()">
<i class="fas fa-times-circle"></i>반려하기
</button>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=20260305"></script>
<script src="/static/js/core/auth-manager.js?v=20260305"></script>
<script src="/static/js/core/permissions.js?v=20260305"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260305"></script>
<script src="/static/js/m/m-common.js?v=20260305"></script>
<script src="/static/js/m/m-dashboard.js?v=20260305"></script>
</body>
</html>

View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>수신함</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/m-common.css?v=20260305">
</head>
<body>
<!-- 로딩 -->
<div id="loadingOverlay" class="m-loading-overlay">
<div class="m-spinner"></div>
<p>수신함을 불러오는 중...</p>
</div>
<!-- 고정 상단 헤더 -->
<header class="m-header">
<div class="m-header-title">
<i class="fas fa-inbox" style="color:#3b82f6"></i>
수신함
</div>
<div class="m-header-actions">
<button class="m-header-btn" onclick="location.reload()"><i class="fas fa-sync-alt"></i></button>
</div>
</header>
<!-- 통계 바 -->
<div class="m-stats-bar" id="statsBar">
<div class="m-stat-pill green"><span>금일 신규</span><span class="m-stat-value" id="todayNewCount">0</span></div>
<div class="m-stat-pill blue"><span>금일 처리</span><span class="m-stat-value" id="todayProcessedCount">0</span></div>
<div class="m-stat-pill red"><span>미해결</span><span class="m-stat-value" id="unresolvedCount">0</span></div>
</div>
<!-- 프로젝트 필터 -->
<div class="m-filter-bar">
<select class="m-filter-select" id="projectFilter" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 이슈 카드 리스트 -->
<div id="issuesList"></div>
<!-- 빈 상태 -->
<div id="emptyState" class="m-empty hidden">
<i class="fas fa-inbox"></i>
<p>검토 대기 중인 부적합이 없습니다</p>
</div>
<!-- ===== 바텀시트: 폐기 ===== -->
<div id="disposeOverlay" class="m-sheet-overlay" onclick="closeSheet('dispose')"></div>
<div id="disposeSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-trash" style="color:#ef4444;margin-right:6px"></i>폐기</span>
<button class="m-sheet-close" onclick="closeSheet('dispose')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">폐기 사유</label>
<select id="disposalReason" class="m-select" onchange="toggleDisposalFields()">
<option value="duplicate">중복</option>
<option value="invalid_report">잘못된 신고</option>
<option value="not_applicable">해당 없음</option>
<option value="spam">스팸/오류</option>
<option value="custom">직접 입력</option>
</select>
</div>
<div id="customReasonDiv" class="m-form-group hidden">
<label class="m-label">사용자 정의 사유</label>
<textarea id="customReason" class="m-textarea" rows="2" placeholder="폐기 사유를 입력하세요..."></textarea>
</div>
<div id="duplicateDiv" class="m-form-group">
<label class="m-label">중복 대상 선택</label>
<div id="managementIssuesList" style="max-height:200px;overflow-y:auto;border:1px solid #e5e7eb;border-radius:10px">
<div class="m-loading"><div class="m-spinner"></div></div>
</div>
<input type="hidden" id="selectedDuplicateId">
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn red" onclick="confirmDispose()">
<i class="fas fa-trash"></i>폐기 확인
</button>
</div>
</div>
<!-- ===== 바텀시트: 검토 ===== -->
<div id="reviewOverlay" class="m-sheet-overlay" onclick="closeSheet('review')"></div>
<div id="reviewSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-edit" style="color:#3b82f6;margin-right:6px"></i>검토</span>
<button class="m-sheet-close" onclick="closeSheet('review')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div id="originalInfo" style="background:#f3f4f6;border-radius:10px;padding:12px;margin-bottom:16px;font-size:13px;color:#6b7280"></div>
<div class="m-form-group">
<label class="m-label">프로젝트</label>
<select id="reviewProjectId" class="m-select"></select>
</div>
<div class="m-form-group">
<label class="m-label">분류</label>
<select id="reviewCategory" class="m-select">
<option value="material_missing">자재 누락</option>
<option value="design_error">설계 오류</option>
<option value="incoming_defect">반입 불량</option>
<option value="inspection_miss">검사 누락</option>
<option value="quality">품질</option>
<option value="safety">안전</option>
<option value="environment">환경</option>
<option value="process">공정</option>
<option value="equipment">장비</option>
<option value="material">자재</option>
<option value="etc">기타</option>
</select>
</div>
<div class="m-form-group">
<label class="m-label">부적합명</label>
<input type="text" id="reviewTitle" class="m-input" placeholder="부적합명을 입력하세요">
</div>
<div class="m-form-group">
<label class="m-label">상세 설명</label>
<textarea id="reviewDescription" class="m-textarea" rows="3" placeholder="상세 설명을 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="saveReview()">
<i class="fas fa-save"></i>검토 완료
</button>
</div>
</div>
<!-- ===== 바텀시트: 확인 (상태 결정) ===== -->
<div id="statusOverlay" class="m-sheet-overlay" onclick="closeSheet('status')"></div>
<div id="statusSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-check" style="color:#22c55e;margin-right:6px"></i>확인</span>
<button class="m-sheet-close" onclick="closeSheet('status')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">상태 결정</label>
<div class="m-radio-group" id="statusRadioGroup">
<label class="m-radio-item" onclick="selectStatus('in_progress')">
<input type="radio" name="finalStatus" value="in_progress">
<div><div style="font-weight:600;font-size:14px">진행 중</div><div style="font-size:12px;color:#6b7280">관리함으로 이동하여 조치합니다</div></div>
</label>
<label class="m-radio-item" onclick="selectStatus('completed')">
<input type="radio" name="finalStatus" value="completed">
<div><div style="font-weight:600;font-size:14px">즉시 완료</div><div style="font-size:12px;color:#6b7280">바로 완료 처리합니다</div></div>
</label>
</div>
</div>
<div id="completionSection" class="hidden">
<div class="m-form-group">
<label class="m-label">해결방안</label>
<textarea id="solutionInput" class="m-textarea" rows="2" placeholder="해결방안을 입력하세요..."></textarea>
</div>
<div class="m-form-group">
<label class="m-label">담당부서</label>
<select id="responsibleDepartmentInput" class="m-select">
<option value="">선택하세요</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div class="m-form-group">
<label class="m-label">담당자</label>
<input type="text" id="responsiblePersonInput" class="m-input" placeholder="담당자 이름">
</div>
<div class="m-form-group">
<label class="m-label">완료 사진</label>
<div class="m-photo-upload" onclick="document.getElementById('statusPhotoInput').click()">
<i class="fas fa-cloud-upload-alt"></i>
<p>탭하여 사진 업로드 (선택)</p>
</div>
<input type="file" id="statusPhotoInput" accept="image/*" capture="environment" class="hidden" onchange="handleStatusPhoto(event)">
<img id="statusPhotoPreview" class="m-photo-preview hidden">
</div>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn green" onclick="confirmStatus()">
<i class="fas fa-check-circle"></i>확인
</button>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=20260305"></script>
<script src="/static/js/core/auth-manager.js?v=20260305"></script>
<script src="/static/js/core/permissions.js?v=20260305"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260305"></script>
<script src="/static/js/m/m-common.js?v=20260305"></script>
<script src="/static/js/m/m-inbox.js?v=20260305"></script>
</body>
</html>

View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>관리함</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/m-common.css?v=20260305">
</head>
<body>
<!-- 로딩 -->
<div id="loadingOverlay" class="m-loading-overlay">
<div class="m-spinner"></div>
<p>관리함을 불러오는 중...</p>
</div>
<!-- 고정 상단 헤더 -->
<header class="m-header">
<div class="m-header-title">
<i class="fas fa-tasks" style="color:#3b82f6"></i>
관리함
</div>
<div class="m-header-actions">
<button class="m-header-btn" id="additionalInfoBtn" onclick="openAdditionalInfoSheet()" style="display:none"><i class="fas fa-info-circle"></i></button>
<button class="m-header-btn" onclick="location.reload()"><i class="fas fa-sync-alt"></i></button>
</div>
</header>
<!-- 탭 바 -->
<div class="m-tab-bar" style="margin-top:48px;position:sticky;top:48px;z-index:50;background:#fff">
<button class="m-tab active" id="tabInProgress" onclick="switchTab('in_progress')">진행 중</button>
<button class="m-tab" id="tabCompleted" onclick="switchTab('completed')">완료됨</button>
</div>
<!-- 통계 바 -->
<div class="m-stats-bar" id="statsBar">
<div class="m-stat-pill blue"><span>전체</span><span class="m-stat-value" id="totalCount">0</span></div>
<div class="m-stat-pill amber"><span>진행 중</span><span class="m-stat-value" id="inProgressCount">0</span></div>
<div class="m-stat-pill purple"><span>완료 대기</span><span class="m-stat-value" id="pendingCompletionCount">0</span></div>
<div class="m-stat-pill green"><span>완료됨</span><span class="m-stat-value" id="completedCount">0</span></div>
</div>
<!-- 프로젝트 필터 -->
<div class="m-filter-bar">
<select class="m-filter-select" id="projectFilter" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 이슈 카드 리스트 -->
<div id="issuesList"></div>
<!-- 빈 상태 -->
<div id="emptyState" class="m-empty hidden">
<i class="fas fa-tasks"></i>
<p>해당하는 부적합이 없습니다</p>
</div>
<!-- ===== 바텀시트: 편집 (관리 필드) ===== -->
<div id="editMgmtOverlay" class="m-sheet-overlay" onclick="closeSheet('editMgmt')"></div>
<div id="editMgmtSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-edit" style="color:#3b82f6;margin-right:6px"></i>관리 정보 편집</span>
<button class="m-sheet-close" onclick="closeSheet('editMgmt')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label"><i class="fas fa-lightbulb" style="color:#eab308;margin-right:4px"></i>해결방안 (확정)</label>
<textarea id="editManagementComment" class="m-textarea" rows="3" placeholder="확정된 해결 방안을 입력하세요..."></textarea>
</div>
<div class="m-form-group">
<label class="m-label"><i class="fas fa-building" style="color:#3b82f6;margin-right:4px"></i>담당부서</label>
<select id="editResponsibleDept" class="m-select">
<option value="">선택하세요</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div class="m-form-group">
<label class="m-label"><i class="fas fa-user" style="color:#8b5cf6;margin-right:4px"></i>담당자</label>
<input type="text" id="editResponsiblePerson" class="m-input" placeholder="담당자 이름">
</div>
<div class="m-form-group">
<label class="m-label"><i class="fas fa-calendar-alt" style="color:#ef4444;margin-right:4px"></i>조치 예상일</label>
<input type="date" id="editExpectedDate" class="m-input">
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="saveManagementEdit()">
<i class="fas fa-save"></i>저장
</button>
</div>
</div>
<!-- ===== 바텀시트: 추가 정보 ===== -->
<div id="additionalOverlay" class="m-sheet-overlay" onclick="closeSheet('additional')"></div>
<div id="additionalSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-info-circle" style="color:#f59e0b;margin-right:6px"></i>추가 정보 입력</span>
<button class="m-sheet-close" onclick="closeSheet('additional')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">대상 이슈 선택</label>
<select id="additionalIssueSelect" class="m-select" onchange="loadAdditionalInfo()"></select>
</div>
<div class="m-form-group">
<label class="m-label">원인부서</label>
<select id="additionalCauseDept" class="m-select">
<option value="">선택하세요</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div class="m-form-group">
<label class="m-label">해당자</label>
<input type="text" id="additionalCausePerson" class="m-input" placeholder="해당자 이름">
</div>
<div class="m-form-group">
<label class="m-label">원인 상세</label>
<textarea id="additionalCauseDetail" class="m-textarea" rows="3" placeholder="원인을 상세히 기술하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn" onclick="saveAdditionalInfo()">
<i class="fas fa-save"></i>저장
</button>
</div>
</div>
<!-- ===== 바텀시트: 완료됨 상세보기 ===== -->
<div id="detailOverlay" class="m-sheet-overlay" onclick="closeSheet('detail')"></div>
<div id="detailSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title" id="detailSheetTitle">상세 정보</span>
<button class="m-sheet-close" onclick="closeSheet('detail')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body" id="detailSheetBody"></div>
</div>
<!-- ===== 바텀시트: 반려 ===== -->
<div id="rejectOverlay" class="m-sheet-overlay" onclick="closeSheet('reject')"></div>
<div id="rejectSheet" class="m-sheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<span class="m-sheet-title"><i class="fas fa-times-circle" style="color:#ef4444;margin-right:6px"></i>완료 반려</span>
<button class="m-sheet-close" onclick="closeSheet('reject')"><i class="fas fa-times"></i></button>
</div>
<div class="m-sheet-body">
<div class="m-form-group">
<label class="m-label">반려 사유</label>
<textarea id="rejectReason" class="m-textarea" rows="4" placeholder="반려 사유를 입력하세요..."></textarea>
</div>
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn red" onclick="submitReject()">
<i class="fas fa-times-circle"></i>반려하기
</button>
</div>
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=20260305"></script>
<script src="/static/js/core/auth-manager.js?v=20260305"></script>
<script src="/static/js/core/permissions.js?v=20260305"></script>
<script src="/static/js/utils/issue-helpers.js?v=20260305"></script>
<script src="/static/js/m/m-common.js?v=20260305"></script>
<script src="/static/js/m/m-management.js?v=20260305"></script>
</body>
</html>

View File

@@ -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/;

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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 = '<i class="fas ' + item.icon + '"></i><span>' + item.label + '</span>';
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 = '<button class="m-photo-modal-close" onclick="closePhotoModal()"><i class="fas fa-times"></i></button><img>';
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 '<div class="m-photo-row">' + photos.map(function (p, i) {
return '<img src="' + p + '" class="m-photo-thumb" onclick="openPhotoModal(\'' + p + '\')" alt="사진 ' + (i + 1) + '">';
}).join('') + '</div>';
}

View File

@@ -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 = '<option value="">전체 프로젝트</option>';
projects.forEach(function (p) {
sel.innerHTML += '<option value="' + p.id + '">' + p.project_name + '</option>';
});
}
} 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': '<span class="m-badge in-progress"><i class="fas fa-cog"></i> 진행 중</span>',
'urgent': '<span class="m-badge urgent"><i class="fas fa-exclamation-triangle"></i> 긴급</span>',
'overdue': '<span class="m-badge overdue"><i class="fas fa-clock"></i> 지연됨</span>',
'pending_completion': '<span class="m-badge pending-completion"><i class="fas fa-hourglass-half"></i> 완료 대기</span>',
'completed': '<span class="m-badge completed"><i class="fas fa-check-circle"></i> 완료됨</span>'
};
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 '<div class="m-date-group"><div class="m-date-header">' +
'<i class="fas fa-calendar-alt"></i>' +
'<span>' + dateKey + '</span>' +
'<span class="m-date-count">(' + issues.length + '건)</span></div>' +
issues.map(function (issue) { return renderIssueCard(issue); }).join('') +
'</div>';
}).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 = '<div style="background:#fff7ed;border-left:3px solid #f97316;border-radius:8px;padding:8px 10px;margin-top:8px;font-size:12px;">' +
'<div style="font-weight:600;color:#c2410c;margin-bottom:2px"><i class="fas fa-exclamation-triangle" style="margin-right:4px"></i>완료 반려</div>' +
'<div style="color:#9a3412">' + (rejAt ? '[' + rejAt + '] ' : '') + escapeHtml(issue.completion_rejection_reason) + '</div></div>';
}
// 완료 대기 정보
var completionInfoHtml = '';
if (isPending) {
var cPhotos = getCompletionPhotoPaths(issue);
completionInfoHtml = '<div class="m-completion-info">' +
'<div style="font-size:12px;font-weight:600;color:#6d28d9;margin-bottom:6px"><i class="fas fa-check-circle" style="margin-right:4px"></i>완료 신청 정보</div>' +
(cPhotos.length ? renderPhotoThumbs(cPhotos) : '<div style="font-size:12px;color:#9ca3af">완료 사진 없음</div>') +
'<div style="font-size:12px;color:#6b7280;margin-top:4px">' + (issue.completion_comment || '코멘트 없음') + '</div>' +
'<div style="font-size:11px;color:#9ca3af;margin-top:4px">신청: ' + formatKSTDateTime(issue.completion_requested_at) + '</div>' +
'</div>';
}
// 액션 버튼
var actionHtml = '';
if (isPending) {
actionHtml = '<div class="m-action-row">' +
'<button class="m-action-btn red" onclick="openRejectionSheet(' + issue.id + ')"><i class="fas fa-times"></i>반려</button>' +
'<button class="m-action-btn green" onclick="event.stopPropagation();openOpinionSheet(' + issue.id + ')"><i class="fas fa-comment-medical"></i>의견</button>' +
'</div>';
} else {
actionHtml = '<div class="m-action-row">' +
'<button class="m-action-btn green" onclick="openCompletionSheet(' + issue.id + ')"><i class="fas fa-check"></i>완료신청</button>' +
'<button class="m-action-btn blue" onclick="event.stopPropagation();openOpinionSheet(' + issue.id + ')"><i class="fas fa-comment-medical"></i>의견</button>' +
'</div>';
}
return '<div class="m-card border-blue">' +
'<div class="m-card-header">' +
'<div><span class="m-card-no">No.' + (issue.project_sequence_no || '-') + '</span>' +
'<span class="m-card-project">' + escapeHtml(projectName) + '</span></div>' +
getStatusBadgeHtml(status) +
'</div>' +
'<div class="m-card-title">' + escapeHtml(getIssueTitle(issue)) + '</div>' +
'<div class="m-card-body">' +
// 상세 내용
'<div style="font-size:13px;color:#6b7280;line-height:1.5;margin-bottom:8px" class="text-ellipsis-3">' + escapeHtml(getIssueDetail(issue)) + '</div>' +
// 정보행
'<div style="display:flex;gap:12px;font-size:12px;color:#9ca3af;margin-bottom:8px">' +
'<span><i class="fas fa-user" style="margin-right:3px"></i>' + escapeHtml(issue.reporter?.full_name || issue.reporter?.username || '-') + '</span>' +
'<span><i class="fas fa-tag" style="margin-right:3px"></i>' + getCategoryText(issue.category || issue.final_category) + '</span>' +
(issue.expected_completion_date ? '<span><i class="fas fa-calendar-alt" style="margin-right:3px"></i>' + formatKSTDate(issue.expected_completion_date) + '</span>' : '') +
'</div>' +
// 사진
(photos.length ? renderPhotoThumbs(photos) : '') +
// 해결방안 / 의견 섹션
'<div style="margin-top:8px">' +
renderManagementComment(issue) +
opinionsHtml +
'</div>' +
rejectionHtml +
completionInfoHtml +
'</div>' +
actionHtml +
'<div class="m-card-footer">' +
'<span>' + getTimeAgo(issue.report_date) + '</span>' +
'<span>ID: ' + issue.id + '</span>' +
'</div>' +
'</div>';
}
// ===== 관리 코멘트 (확정 해결방안) =====
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 '<div style="background:#fef2f2;border-left:3px solid #fca5a5;border-radius:8px;padding:8px 10px;margin-bottom:6px">' +
'<div style="font-size:' + (content ? '12' : '12') + 'px;color:' + (content ? '#991b1b' : '#d1d5db') + ';line-height:1.5;white-space:pre-wrap">' +
(content ? escapeHtml(content) : '확정된 해결 방안 없음') +
'</div></div>';
}
// ===== 의견/댓글/답글 렌더링 =====
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 = '<button class="m-opinions-toggle" onclick="toggleOpinions(\'' + toggleId + '\')">' +
'<i class="fas fa-comments"></i> 의견 ' + validOpinions.length + '개' +
'<i class="fas fa-chevron-down" style="font-size:10px;transition:transform 0.2s" id="chevron-' + toggleId + '"></i></button>' +
'<div id="' + toggleId + '" class="hidden" style="margin-top:6px">';
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 += '<div class="m-opinion-card">' +
'<div class="m-opinion-header">' +
'<div class="m-opinion-avatar">' + author.charAt(0) + '</div>' +
'<span class="m-opinion-author">' + escapeHtml(author) + '</span>' +
'<span class="m-opinion-time">' + escapeHtml(datetime) + '</span>' +
'</div>' +
'<div class="m-opinion-text">' + escapeHtml(mainContent) + '</div>' +
'<div class="m-opinion-actions">' +
'<button class="m-opinion-action-btn comment-btn" onclick="openCommentSheet(' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-comment"></i>댓글</button>' +
(isOwn ? '<button class="m-opinion-action-btn edit-btn" onclick="openEditSheet(\'opinion\',' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-edit"></i>수정</button>' +
'<button class="m-opinion-action-btn delete-btn" onclick="deleteOpinion(' + issue.id + ',' + opinionIndex + ')"><i class="fas fa-trash"></i>삭제</button>' : '') +
'</div>';
// 댓글
comments.forEach(function (comment, commentIndex) {
var isOwnComment = currentUser && (comment.author === currentUser.full_name || comment.author === currentUser.username);
html += '<div class="m-comment">' +
'<div class="m-comment-header">' +
'<div class="m-comment-avatar">' + comment.author.charAt(0) + '</div>' +
'<span style="font-weight:600;color:#111827;font-size:11px">' + escapeHtml(comment.author) + '</span>' +
'<span style="color:#9ca3af;font-size:10px">' + escapeHtml(comment.datetime) + '</span>' +
'</div>' +
'<div class="m-comment-text">' + escapeHtml(comment.content) + '</div>' +
'<div style="display:flex;gap:4px;margin-top:4px;padding-left:22px">' +
'<button class="m-opinion-action-btn reply-btn" onclick="openReplySheet(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-reply"></i>답글</button>' +
(isOwnComment ? '<button class="m-opinion-action-btn edit-btn" onclick="openEditSheet(\'comment\',' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-edit"></i></button>' +
'<button class="m-opinion-action-btn delete-btn" onclick="deleteComment(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ')"><i class="fas fa-trash"></i></button>' : '') +
'</div>';
// 답글
comment.replies.forEach(function (reply, replyIndex) {
var isOwnReply = currentUser && (reply.author === currentUser.full_name || reply.author === currentUser.username);
html += '<div class="m-reply">' +
'<div class="m-reply-header">' +
'<i class="fas fa-reply" style="color:#93c5fd;font-size:9px;margin-right:3px"></i>' +
'<span style="font-weight:600;color:#111827;font-size:10px">' + escapeHtml(reply.author) + '</span>' +
'<span style="color:#9ca3af;font-size:9px;margin-left:3px">' + escapeHtml(reply.datetime) + '</span>' +
(isOwnReply ? '<button class="m-opinion-action-btn edit-btn" style="margin-left:auto;padding:1px 4px" onclick="openEditSheet(\'reply\',' + issue.id + ',' + opinionIndex + ',' + commentIndex + ',' + replyIndex + ')"><i class="fas fa-edit" style="font-size:9px"></i></button>' +
'<button class="m-opinion-action-btn delete-btn" style="padding:1px 4px" onclick="deleteReply(' + issue.id + ',' + opinionIndex + ',' + commentIndex + ',' + replyIndex + ')"><i class="fas fa-trash" style="font-size:9px"></i></button>' : '') +
'</div>' +
'<div class="m-reply-text">' + escapeHtml(reply.content) + '</div>' +
'</div>';
});
html += '</div>'; // m-comment
});
html += '</div>'; // m-opinion-card
}
});
html += '</div>';
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 = '<i class="fas fa-edit" style="color:#22c55e;margin-right:6px"></i>' + 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);

View File

@@ -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 = '<option value="">전체 프로젝트</option>';
projects.forEach(function (p) {
sel.innerHTML += '<option value="' + p.id + '">' + escapeHtml(p.project_name) + '</option>';
});
}
} 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 '<div class="m-card border-blue">' +
'<div class="m-card-header">' +
'<div><span class="m-badge review"><i class="fas fa-clock"></i> 검토 대기</span>' +
(project ? '<span class="m-card-project">' + escapeHtml(project.project_name) + '</span>' : '') +
'</div>' +
'<span style="font-size:11px;color:#9ca3af">ID: ' + issue.id + '</span>' +
'</div>' +
'<div class="m-card-title text-ellipsis-3">' + escapeHtml(issue.final_description || issue.description) + '</div>' +
'<div class="m-card-body">' +
'<div style="display:flex;gap:12px;font-size:12px;color:#6b7280;margin-bottom:8px;flex-wrap:wrap">' +
'<span><i class="fas fa-user" style="color:#3b82f6;margin-right:3px"></i>' + escapeHtml(issue.reporter?.username || '알 수 없음') + '</span>' +
'<span><i class="fas fa-tag" style="color:#22c55e;margin-right:3px"></i>' + getCategoryText(issue.category || issue.final_category) + '</span>' +
'<span><i class="fas fa-camera" style="color:#8b5cf6;margin-right:3px"></i>' + (photoCount > 0 ? photoCount + '장' : '없음') + '</span>' +
'<span><i class="fas fa-clock" style="color:#f59e0b;margin-right:3px"></i>' + getTimeAgo(issue.report_date) + '</span>' +
'</div>' +
(photos.length ? renderPhotoThumbs(photos) : '') +
(issue.detail_notes ? '<div style="font-size:12px;color:#6b7280;margin-top:6px;font-style:italic">"' + escapeHtml(issue.detail_notes) + '"</div>' : '') +
'</div>' +
'<div class="m-action-row">' +
'<button class="m-action-btn red" onclick="openDisposeSheet(' + issue.id + ')"><i class="fas fa-trash"></i>폐기</button>' +
'<button class="m-action-btn blue" onclick="openReviewSheet(' + issue.id + ')"><i class="fas fa-edit"></i>검토</button>' +
'<button class="m-action-btn green" onclick="openStatusSheet(' + issue.id + ')"><i class="fas fa-check"></i>확인</button>' +
'</div>' +
'</div>';
}).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 = '<div style="padding:16px;text-align:center;color:#9ca3af;font-size:13px">동일 프로젝트의 관리함 이슈가 없습니다.</div>';
return;
}
container.innerHTML = list.map(function (mi) {
return '<div style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:13px" onclick="selectDuplicate(' + mi.id + ',this)">' +
'<div style="font-weight:500;color:#111827;margin-bottom:2px">' + escapeHtml(mi.description || mi.final_description) + '</div>' +
'<div style="display:flex;gap:6px;color:#9ca3af;font-size:11px">' +
'<span>' + getCategoryText(mi.category || mi.final_category) + '</span>' +
'<span>신고자: ' + escapeHtml(mi.reporter_name) + '</span>' +
'<span>ID: ' + mi.id + '</span>' +
'</div></div>';
}).join('');
} catch (e) {
document.getElementById('managementIssuesList').innerHTML = '<div style="padding:16px;text-align:center;color:#ef4444;font-size:13px">목록 로드 실패</div>';
}
}
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 =
'<div style="margin-bottom:4px"><strong>프로젝트:</strong> ' + (project ? escapeHtml(project.project_name) : '미지정') + '</div>' +
'<div style="margin-bottom:4px"><strong>신고자:</strong> ' + escapeHtml(issue.reporter?.username || '알 수 없음') + '</div>' +
'<div><strong>등록일:</strong> ' + formatKSTDate(issue.report_date) + '</div>';
// 프로젝트 select
var sel = document.getElementById('reviewProjectId');
sel.innerHTML = '<option value="">프로젝트 선택</option>';
projects.forEach(function (p) {
sel.innerHTML += '<option value="' + p.id + '"' + (p.id === issue.project_id ? ' selected' : '') + '>' + escapeHtml(p.project_name) + '</option>';
});
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);

View File

@@ -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 = '<option value="">전체 프로젝트</option>';
projects.forEach(function (p) {
sel.innerHTML += '<option value="' + p.id + '">' + escapeHtml(p.project_name) + '</option>';
});
}
} 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': '<span class="m-badge in-progress"><i class="fas fa-cog"></i> 진행 중</span>',
'urgent': '<span class="m-badge urgent"><i class="fas fa-exclamation-triangle"></i> 긴급</span>',
'overdue': '<span class="m-badge overdue"><i class="fas fa-clock"></i> 지연됨</span>',
'pending_completion': '<span class="m-badge pending-completion"><i class="fas fa-hourglass-half"></i> 완료 대기</span>',
'completed': '<span class="m-badge completed"><i class="fas fa-check-circle"></i> 완료됨</span>'
};
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 '<div class="m-date-group"><div class="m-date-header">' +
'<i class="fas fa-calendar-alt"></i>' +
'<span>' + dateKey + '</span>' +
'<span class="m-date-count">(' + issues.length + '건)</span>' +
'<span style="font-size:10px;padding:2px 6px;border-radius:8px;background:' +
(currentTab === 'in_progress' ? '#dbeafe;color:#1d4ed8' : '#dcfce7;color:#15803d') + '">' +
(currentTab === 'in_progress' ? '업로드일' : '완료일') + '</span>' +
'</div>' +
issues.map(function (issue) {
return currentTab === 'in_progress' ? renderInProgressCard(issue) : renderCompletedCard(issue);
}).join('') +
'</div>';
}).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 = '<div style="margin-top:8px">' +
'<div class="m-info-row"><i class="fas fa-lightbulb" style="color:#eab308"></i><span style="font-weight:600">해결방안:</span> <span>' + escapeHtml(cleanManagementComment(issue.management_comment) || '-') + '</span></div>' +
'<div class="m-info-row"><i class="fas fa-building" style="color:#3b82f6"></i><span style="font-weight:600">담당부서:</span> <span>' + getDepartmentText(issue.responsible_department) + '</span></div>' +
'<div class="m-info-row"><i class="fas fa-user" style="color:#8b5cf6"></i><span style="font-weight:600">담당자:</span> <span>' + escapeHtml(issue.responsible_person || '-') + '</span></div>' +
'<div class="m-info-row"><i class="fas fa-calendar-alt" style="color:#ef4444"></i><span style="font-weight:600">조치예상일:</span> <span>' + (issue.expected_completion_date ? formatKSTDate(issue.expected_completion_date) : '-') + '</span></div>' +
'</div>';
// 완료 대기 정보
var completionInfoHtml = '';
if (isPending) {
var cPhotos = getCompletionPhotoPaths(issue);
completionInfoHtml = '<div class="m-completion-info" style="margin-top:8px">' +
'<div style="font-size:12px;font-weight:600;color:#6d28d9;margin-bottom:4px"><i class="fas fa-check-circle" style="margin-right:4px"></i>완료 신청 정보</div>' +
(cPhotos.length ? renderPhotoThumbs(cPhotos) : '') +
'<div style="font-size:12px;color:#6b7280;margin-top:4px">' + escapeHtml(issue.completion_comment || '코멘트 없음') + '</div>' +
'<div style="font-size:11px;color:#9ca3af;margin-top:2px">신청: ' + formatKSTDateTime(issue.completion_requested_at) + '</div>' +
'</div>';
}
// 액션 버튼
var actionHtml = '';
if (isPending) {
actionHtml = '<div class="m-action-row">' +
'<button class="m-action-btn red" onclick="openRejectSheet(' + issue.id + ')"><i class="fas fa-times"></i>반려</button>' +
'<button class="m-action-btn green" onclick="confirmCompletion(' + issue.id + ')"><i class="fas fa-check-circle"></i>최종확인</button>' +
'</div>';
} else {
actionHtml = '<div class="m-action-row">' +
'<button class="m-action-btn blue" onclick="openEditMgmtSheet(' + issue.id + ')"><i class="fas fa-edit"></i>편집</button>' +
'<button class="m-action-btn green" onclick="confirmCompletion(' + issue.id + ')"><i class="fas fa-check"></i>완료처리</button>' +
'</div>';
}
return '<div class="m-card border-blue">' +
'<div class="m-card-header">' +
'<div><span class="m-card-no">No.' + (issue.project_sequence_no || '-') + '</span>' +
'<span class="m-card-project">' + escapeHtml(project ? project.project_name : '미지정') + '</span></div>' +
getStatusBadgeHtml(status) +
'</div>' +
'<div class="m-card-title">' + escapeHtml(getIssueTitle(issue)) + '</div>' +
'<div class="m-card-body">' +
'<div style="font-size:13px;color:#6b7280;line-height:1.5;margin-bottom:6px" class="text-ellipsis-3">' + escapeHtml(getIssueDetail(issue)) + '</div>' +
'<div style="display:flex;gap:8px;font-size:12px;color:#9ca3af;margin-bottom:6px">' +
'<span><i class="fas fa-tag" style="margin-right:3px"></i>' + getCategoryText(issue.category || issue.final_category) + '</span>' +
'<span><i class="fas fa-user" style="margin-right:3px"></i>' + escapeHtml(issue.reporter?.full_name || issue.reporter?.username || '-') + '</span>' +
'</div>' +
(photos.length ? renderPhotoThumbs(photos) : '') +
mgmtHtml +
completionInfoHtml +
'</div>' +
actionHtml +
'<div class="m-card-footer">' +
'<span>신고일: ' + formatKSTDate(issue.report_date) + '</span>' +
'<span>ID: ' + issue.id + '</span>' +
'</div>' +
'</div>';
}
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 '<div class="m-card border-green" onclick="openDetailSheet(' + issue.id + ')">' +
'<div class="m-card-header">' +
'<div><span class="m-card-no" style="color:#16a34a">No.' + (issue.project_sequence_no || '-') + '</span>' +
'<span class="m-card-project">' + escapeHtml(project ? project.project_name : '미지정') + '</span></div>' +
'<span class="m-badge completed"><i class="fas fa-check-circle"></i> 완료</span>' +
'</div>' +
'<div class="m-card-title">' + escapeHtml(getIssueTitle(issue)) + '</div>' +
'<div class="m-card-footer">' +
'<span>완료일: ' + completedDate + '</span>' +
'<span style="color:#3b82f6"><i class="fas fa-chevron-right" style="font-size:10px"></i> 상세보기</span>' +
'</div>' +
'</div>';
}
// ===== 편집 시트 =====
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 = '<option value="">이슈 선택</option>';
inProgressIssues.forEach(function (i) {
var p = projects.find(function (pr) { return pr.id === i.project_id; });
sel.innerHTML += '<option value="' + i.id + '">No.' + (i.project_sequence_no || '-') + ' ' + escapeHtml(getIssueTitle(i)) + '</option>';
});
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 =
'<span style="font-weight:800;color:#16a34a">No.' + (issue.project_sequence_no || '-') + '</span> 상세 정보';
document.getElementById('detailSheetBody').innerHTML =
// 기본 정보
'<div style="margin-bottom:16px">' +
'<div style="font-size:14px;font-weight:700;color:#111827;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #e5e7eb"><i class="fas fa-info-circle" style="color:#3b82f6;margin-right:6px"></i>기본 정보</div>' +
'<div class="m-info-row"><span style="font-weight:600">프로젝트:</span> <span>' + escapeHtml(project ? project.project_name : '-') + '</span></div>' +
'<div class="m-info-row"><span style="font-weight:600">부적합명:</span> <span>' + escapeHtml(getIssueTitle(issue)) + '</span></div>' +
'<div style="font-size:13px;color:#6b7280;line-height:1.5;margin:6px 0;white-space:pre-wrap">' + escapeHtml(getIssueDetail(issue)) + '</div>' +
'<div class="m-info-row"><span style="font-weight:600">분류:</span> <span>' + getCategoryText(issue.final_category || issue.category) + '</span></div>' +
'<div class="m-info-row"><span style="font-weight:600">확인자:</span> <span>' + escapeHtml(getReporterNames(issue)) + '</span></div>' +
(photos.length ? '<div style="margin-top:6px"><div style="font-size:12px;font-weight:600;color:#6b7280;margin-bottom:4px">업로드 사진</div>' + renderPhotoThumbs(photos) + '</div>' : '') +
'</div>' +
// 관리 정보
'<div style="margin-bottom:16px">' +
'<div style="font-size:14px;font-weight:700;color:#111827;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #e5e7eb"><i class="fas fa-cogs" style="color:#3b82f6;margin-right:6px"></i>관리 정보</div>' +
'<div class="m-info-row"><span style="font-weight:600">해결방안:</span> <span>' + escapeHtml(cleanManagementComment(issue.management_comment) || '-') + '</span></div>' +
'<div class="m-info-row"><span style="font-weight:600">담당부서:</span> <span>' + getDepartmentText(issue.responsible_department) + '</span></div>' +
'<div class="m-info-row"><span style="font-weight:600">담당자:</span> <span>' + escapeHtml(issue.responsible_person || '-') + '</span></div>' +
'<div class="m-info-row"><span style="font-weight:600">원인부서:</span> <span>' + getDepartmentText(issue.cause_department) + '</span></div>' +
'</div>' +
// 완료 정보
'<div>' +
'<div style="font-size:14px;font-weight:700;color:#111827;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #e5e7eb"><i class="fas fa-check-circle" style="color:#22c55e;margin-right:6px"></i>완료 정보</div>' +
(cPhotos.length ? '<div style="margin-bottom:6px"><div style="font-size:12px;font-weight:600;color:#6b7280;margin-bottom:4px">완료 사진</div>' + renderPhotoThumbs(cPhotos) + '</div>' : '<div class="m-info-row"><span>완료 사진 없음</span></div>') +
'<div class="m-info-row"><span style="font-weight:600">완료 코멘트:</span> <span>' + escapeHtml(issue.completion_comment || '-') + '</span></div>' +
(issue.completion_requested_at ? '<div class="m-info-row"><span style="font-weight:600">완료 신청일:</span> <span>' + formatKSTDateTime(issue.completion_requested_at) + '</span></div>' : '') +
(issue.completed_at ? '<div class="m-info-row"><span style="font-weight:600">최종 완료일:</span> <span>' + formatKSTDateTime(issue.completed_at) + '</span></div>' : '') +
'</div>';
openSheet('detail');
}
// ===== 시작 =====
document.addEventListener('DOMContentLoaded', initialize);

View File

@@ -326,47 +326,44 @@ function createInProgressRow(issue, project) {
return `
<div class="issue-card bg-white border border-gray-200 rounded-xl p-6 mb-4 shadow-sm hover:shadow-md transition-shadow" data-issue-id="${issue.id}">
<!-- 카드 헤더 -->
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col space-y-1">
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-2">
<span class="text-xl font-bold bg-gradient-to-r from-blue-600 to-blue-800 bg-clip-text text-transparent">No.${issue.project_sequence_no || '-'}</span>
<div class="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
<div class="issue-card-header">
<div class="header-top">
<div style="min-width:0; flex:1;">
<div class="header-meta">
<span class="text-xl font-bold bg-gradient-to-r from-blue-600 to-blue-800 bg-clip-text text-transparent" style="white-space:nowrap;">No.${issue.project_sequence_no || '-'}</span>
<div class="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style="flex-shrink:0;"></div>
<span class="text-sm text-gray-600" style="white-space:nowrap;">${project ? project.project_name : '프로젝트 미지정'}</span>
<div class="flex items-center space-x-2 ${statusConfig.bgColor} text-white px-3 py-1 rounded-full shadow-sm" style="white-space:nowrap;">
<div class="w-1.5 h-1.5 ${statusConfig.dotColor} rounded-full animate-pulse"></div>
<span class="text-xs font-bold">${statusConfig.text}</span>
<i class="${statusConfig.icon} text-xs"></i>
</div>
</div>
<span class="text-sm text-gray-600">${project ? project.project_name : '프로젝트 미지정'}</span>
<!-- 상태 표시 -->
<div class="flex items-center space-x-2 ${statusConfig.bgColor} text-white px-3 py-1 rounded-full shadow-sm">
<div class="w-1.5 h-1.5 ${statusConfig.dotColor} rounded-full animate-pulse"></div>
<span class="text-xs font-bold">${statusConfig.text}</span>
<i class="${statusConfig.icon} text-xs"></i>
<div class="bg-blue-50 px-3 py-2 rounded-lg border-l-4 border-blue-400 mt-1">
<h3 class="text-lg font-bold text-blue-900">${getIssueTitle(issue)}</h3>
</div>
</div>
<div class="bg-blue-50 px-3 py-2 rounded-lg border-l-4 border-blue-400">
<h3 class="text-lg font-bold text-blue-900">${getIssueTitle(issue)}</h3>
<div class="header-actions">
${isPendingCompletion ? `
<button onclick="rejectCompletion(${issue.id})" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-times mr-1"></i>반려
</button>
<button onclick="confirmCompletion(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check-circle mr-1"></i>최종확인
</button>
` : `
<button onclick="saveIssueChanges(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-save mr-1"></i>저장
</button>
<button onclick="confirmDeleteIssue(${issue.id})" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>삭제
</button>
<button onclick="confirmCompletion(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check mr-1"></i>완료처리
</button>
`}
</div>
</div>
<div class="flex space-x-2">
${isPendingCompletion ? `
<!-- 완료 대기 상태 버튼들 -->
<button onclick="rejectCompletion(${issue.id})" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-times mr-1"></i>반려
</button>
<button onclick="confirmCompletion(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check-circle mr-1"></i>최종확인
</button>
` : `
<!-- 일반 진행 중 상태 버튼들 -->
<button onclick="saveIssueChanges(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-save mr-1"></i>저장
</button>
<button onclick="confirmDeleteIssue(${issue.id})" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-1"></i>삭제
</button>
<button onclick="confirmCompletion(${issue.id})" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-check mr-1"></i>완료처리
</button>
`}
</div>
</div>
<!-- 카드 내용 -->
@@ -556,35 +553,34 @@ function createCompletedRow(issue, project) {
return `
<div class="issue-card bg-white border border-gray-200 rounded-xl p-6 mb-4 shadow-sm hover:shadow-md transition-shadow" data-issue-id="${issue.id}">
<!-- 카드 헤더 -->
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col space-y-1">
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-2">
<span class="text-xl font-bold bg-gradient-to-r from-green-600 to-green-800 bg-clip-text text-transparent">No.${issue.project_sequence_no || '-'}</span>
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<div class="issue-card-header">
<div class="header-top">
<div style="min-width:0; flex:1;">
<div class="header-meta">
<span class="text-xl font-bold bg-gradient-to-r from-green-600 to-green-800 bg-clip-text text-transparent" style="white-space:nowrap;">No.${issue.project_sequence_no || '-'}</span>
<div class="w-2 h-2 bg-green-500 rounded-full" style="flex-shrink:0;"></div>
<span class="text-sm text-gray-600" style="white-space:nowrap;">${project ? project.project_name : '프로젝트 미지정'}</span>
<div class="flex items-center space-x-2 bg-gradient-to-r from-green-500 to-green-600 text-white px-3 py-1 rounded-full shadow-sm" style="white-space:nowrap;">
<div class="w-1.5 h-1.5 bg-white rounded-full"></div>
<span class="text-xs font-bold">완료됨</span>
<i class="fas fa-check-circle text-xs"></i>
</div>
<span class="text-xs text-gray-500" style="white-space:nowrap;">완료일: ${completedDate}</span>
</div>
<span class="text-sm text-gray-600">${project ? project.project_name : '프로젝트 미지정'}</span>
<!-- 완료 상태 표시 -->
<div class="flex items-center space-x-2 bg-gradient-to-r from-green-500 to-green-600 text-white px-3 py-1 rounded-full shadow-sm">
<div class="w-1.5 h-1.5 bg-white rounded-full"></div>
<span class="text-xs font-bold">완료됨</span>
<i class="fas fa-check-circle text-xs"></i>
<div class="bg-green-50 px-3 py-2 rounded-lg border-l-4 border-green-400 mt-1">
<h3 class="text-lg font-bold text-green-900">${getIssueTitle(issue)}</h3>
</div>
<span class="text-xs text-gray-500">완료일: ${completedDate}</span>
</div>
<div class="bg-green-50 px-3 py-2 rounded-lg border-l-4 border-green-400">
<h3 class="text-lg font-bold text-green-900">${getIssueTitle(issue)}</h3>
<div class="header-actions">
<button onclick="openIssueDetailModal(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-eye mr-1"></i>상세보기
</button>
</div>
</div>
<div class="flex space-x-2">
<button onclick="openIssueDetailModal(${issue.id})" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-eye mr-1"></i>상세보기
</button>
</div>
</div>
<!-- 카드 본문 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="completed-card-grid" style="display:grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<!-- 기본 정보 -->
<div class="bg-gray-50 p-4 rounded-lg border border-gray-200">
<h4 class="font-semibold text-gray-800 mb-3 flex items-center">