Files
M-Project/frontend/index.html
hyungi c16fc53f3b feat: 프로젝트 관리 및 비밀번호 변경 기능 개선
주요 변경사항:
- 비활성화된 프로젝트 관리 기능 추가
  * 프로젝트 관리 페이지에 접을 수 있는 비활성 프로젝트 섹션 추가
  * 비활성화된 프로젝트 복구 기능 제공
  * 업로드 시에는 활성 프로젝트만 표시되도록 API 호출 분리

- 헤더 비밀번호 변경 기능 완전 구현
  * CommonHeader.js에 완전한 비밀번호 변경 모달 구현
  * ESC 키 지원, 실시간 유효성 검사, 토스트 메시지 추가
  * 중복 코드 제거 및 통일된 함수 호출 구조

- 수신함 수정 내용 표시 문제 해결
  * description 우선 표시로 최신 수정 내용 반영
  * 관리함에서 final_description/final_category 업데이트 로직 추가

- 현황판 날짜 그룹화 개선
  * 업로드일 기준에서 관리함 진입일(reviewed_at) 기준으로 변경
  * Invalid Date 오류 해결

- 프로젝트 관리 페이지 JavaScript 오류 수정
  * 중복 변수 선언 및 함수 참조 오류 해결
  * 페이지 초기화 로직 개선

기술적 개선:
- API 호출 최적화 (active_only 매개변수 명시적 전달)
- 프론트엔드 표시 우선순위 통일 (description || final_description)
- 백엔드 final_* 필드 업데이트 로직 추가
2025-10-26 15:28:23 +09:00

2136 lines
103 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업보고서 시스템</title>
<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">
<style>
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-primary:active {
transform: translateY(0);
}
.input-field {
border: 1px solid var(--gray-300);
background: white;
transition: all 0.2s;
}
.input-field:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.status-new {
background-color: #dbeafe;
color: #1e40af;
}
.status-progress {
background-color: #fef3c7;
color: #92400e;
}
.status-complete {
background-color: #d1fae5;
color: #065f46;
}
.nav-link {
color: #6b7280;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
}
.nav-link:hover {
background-color: var(--gray-100);
color: var(--primary);
}
.nav-link.active {
background-color: var(--primary);
color: white;
}
/* 모바일에서 프로젝트 드롭다운 강제 표시 */
@media (max-width: 768px) {
#projectSelect,
select[id="projectSelect"] {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
width: 100% !important;
min-height: 44px !important;
height: auto !important;
padding: 12px !important;
font-size: 16px !important;
border: 2px solid #3b82f6 !important;
border-radius: 8px !important;
background-color: white !important;
color: black !important;
-webkit-appearance: menulist !important;
-moz-appearance: menulist !important;
appearance: menulist !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
position: relative !important;
z-index: 1000 !important;
}
#projectSelect option,
select[id="projectSelect"] option {
display: block !important;
padding: 8px !important;
font-size: 16px !important;
color: black !important;
background-color: white !important;
}
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-overlay.active {
display: flex;
}
.loading-spinner {
background: white;
padding: 2rem;
border-radius: 1rem;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* 부드러운 페이드인 애니메이션 */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 헤더 전용 빠른 페이드인 */
.header-fade-in {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.header-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 본문 컨텐츠 지연 페이드인 */
.content-fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
transition-delay: 0.2s;
}
.content-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-spinner">
<div class="mb-4">
<i class="fas fa-spinner fa-spin text-5xl text-blue-500"></i>
</div>
<p class="text-gray-700 font-medium text-lg">처리 중입니다...</p>
<p class="text-gray-500 text-sm mt-2">잠시만 기다려주세요</p>
<div class="mt-4 w-full max-w-xs">
<div class="bg-gray-200 rounded-full h-2 overflow-hidden">
<div class="bg-blue-500 h-full rounded-full transition-all duration-300 upload-progress" style="width: 0%"></div>
</div>
</div>
</div>
</div>
<!-- 로그인 화면 (기본 숨김 - AuthManager가 결정) -->
<div id="loginScreen" class="min-h-screen flex items-center justify-center p-4 bg-gray-50 hidden">
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
<div class="text-center mb-6">
<i class="fas fa-clipboard-check text-5xl text-blue-500 mb-4"></i>
<h1 class="text-2xl font-bold text-gray-800">작업보고서 시스템</h1>
<p class="text-gray-600 text-sm mt-2">부적합 사항 관리 및 공수 계산</p>
</div>
<form id="loginForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">아이디</label>
<input
type="text"
id="userId"
class="input-field w-full px-4 py-2 rounded-lg"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
<input
type="password"
id="password"
class="input-field w-full px-4 py-2 rounded-lg"
required
>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded-lg font-medium">
로그인
</button>
</form>
</div>
</div>
<!-- 메인 화면 (기본 표시 - AuthManager가 결정) -->
<div id="mainScreen" class="min-h-screen bg-gray-50">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 (헤더는 별도 애니메이션) -->
<!-- 부적합 등록 섹션 (모바일 최적화) -->
<section id="reportSection" class="container mx-auto px-3 py-4 max-w-md content-fade-in">
<!-- 페이지 헤더 -->
<div class="mb-4">
<h1 class="text-xl font-bold text-gray-900 flex items-center">
<i class="fas fa-exclamation-triangle text-yellow-500 mr-3"></i>
부적합 등록
</h1>
<p class="text-sm text-gray-600 mt-1">현장에서 발견한 부적합 사항을 등록해주세요</p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-4">
<!-- 진행 상태 표시 -->
<div class="mb-6">
<div class="flex items-center justify-between text-xs text-gray-500 mb-2">
<span>등록 진행률</span>
<span id="progressText">0/6</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div id="progressBar" class="bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<form id="reportForm" class="space-y-4">
<!-- 사진 업로드 (선택사항, 최대 2장) -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700">
📸 사진 첨부
</label>
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
선택사항 • 최대 2장
</span>
</div>
<!-- 사진 미리보기 영역 -->
<div id="photoPreviewContainer" class="grid grid-cols-2 gap-2 mb-3" style="display: none;">
<!-- 첫 번째 사진 -->
<div id="photo1Container" class="relative hidden">
<img id="previewImg1" class="w-full h-24 object-cover rounded-xl border-2 border-gray-200">
<button type="button" onclick="removePhoto(0)" class="absolute -top-1 -right-1 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 flex items-center justify-center shadow-lg">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 두 번째 사진 -->
<div id="photo2Container" class="relative hidden">
<img id="previewImg2" class="w-full h-24 object-cover rounded-xl border-2 border-gray-200">
<button type="button" onclick="removePhoto(1)" class="absolute -top-1 -right-1 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 flex items-center justify-center shadow-lg">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 업로드 버튼들 -->
<div class="grid grid-cols-2 gap-3">
<!-- 카메라 촬영 버튼 -->
<button
type="button"
id="cameraUpload"
class="flex flex-col items-center justify-center p-4 border-2 border-dashed border-blue-300 rounded-xl text-center cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-all active:scale-95"
onclick="openCamera()"
>
<i class="fas fa-camera text-2xl text-blue-500 mb-2"></i>
<span class="text-sm font-medium text-gray-700">카메라</span>
<span class="text-xs text-gray-500">즉시 촬영</span>
</button>
<!-- 갤러리 선택 버튼 -->
<button
type="button"
id="galleryUpload"
class="flex flex-col items-center justify-center p-4 border-2 border-dashed border-green-300 rounded-xl text-center cursor-pointer hover:border-green-500 hover:bg-green-50 transition-all active:scale-95"
onclick="openGallery()"
>
<i class="fas fa-images text-2xl text-green-500 mb-2"></i>
<span class="text-sm font-medium text-gray-700">갤러리</span>
<span class="text-xs text-gray-500">사진 선택</span>
</button>
</div>
<!-- 현재 상태 표시 -->
<div class="text-center mt-2">
<p class="text-sm text-gray-500" id="photoUploadText">사진 추가 (0/2)</p>
</div>
<!-- 숨겨진 입력 필드들 -->
<input type="file" id="cameraInput" accept="image/*" capture="camera" class="hidden" multiple>
<input type="file" id="galleryInput" accept="image/*" class="hidden" multiple>
</div>
<!-- 프로젝트 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-folder-open mr-1"></i>프로젝트
</label>
<select id="projectSelect" class="input-field w-full px-4 py-2 rounded-lg relative z-10" required>
<option value="">프로젝트를 선택하세요</option>
<!-- 활성 프로젝트들이 여기에 로드됩니다 -->
</select>
</div>
<!-- 카테고리 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
<select id="category" class="input-field w-full px-4 py-2 rounded-lg" required>
<option value="">선택하세요</option>
<option value="material_missing">자재누락</option>
<option value="design_error">설계미스</option>
<option value="incoming_defect">입고자재 불량</option>
<option value="inspection_miss">검사미스</option>
<option value="etc">기타</option>
</select>
</div>
<!-- 간단 설명 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">간단 설명</label>
<textarea
id="description"
rows="3"
class="input-field w-full px-4 py-2 rounded-lg resize-none"
placeholder="발견된 문제를 간단히 설명하세요"
required
></textarea>
</div>
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
<i class="fas fa-check mr-2"></i>등록하기
</button>
</form>
</div>
</section>
<!-- 목록 관리 섹션 -->
<section id="listSection" class="hidden container mx-auto px-4 py-6 content-fade-in" style="padding-top: 80px;">
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">부적합 사항 목록</h2>
<button onclick="displayIssueList()" class="text-blue-600 hover:text-blue-800 text-sm">
<i class="fas fa-refresh mr-1"></i>새로고침
</button>
</div>
<!-- 필터 섹션 -->
<div class="bg-gray-50 p-4 rounded-lg mb-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<select id="listProjectFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="displayIssueList()">
<option value="">전체 프로젝트</option>
<!-- 프로젝트 옵션들이 여기에 로드됩니다 -->
</select>
</div>
<!-- 워크플로우 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">워크플로우 상태</label>
<select id="reviewStatusFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="displayIssueList()">
<option value="">전체</option>
<option value="pending_review">수신함 (검토 대기)</option>
<option value="in_progress">관리함 (진행 중)</option>
<option value="completed">관리함 (완료됨)</option>
<option value="disposed">폐기함 (폐기됨)</option>
</select>
</div>
<!-- 날짜 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜</label>
<select id="dateFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="displayIssueList()">
<option value="">전체</option>
<option value="today">오늘</option>
<option value="week">이번 주</option>
<option value="month">이번 달</option>
</select>
</div>
<!-- 사용자 정의 날짜 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">특정 날짜</label>
<input type="date" id="customDateFilter" class="w-full px-3 py-2 border border-gray-300 rounded text-sm" onchange="displayIssueList()">
</div>
</div>
</div>
</div>
<div id="issueList" class="space-y-4">
<!-- 목록이 여기에 표시됩니다 -->
</div>
</div>
</section>
<!-- 보고서 섹션 -->
<section id="summarySection" class="hidden container mx-auto px-4 py-6 content-fade-in" style="padding-top: 80px;">
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-semibold text-gray-800">작업 보고서</h2>
<div class="flex items-center gap-4">
<!-- 프로젝트 선택 -->
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">프로젝트:</label>
<select id="reportProjectFilter" class="px-3 py-2 border border-gray-300 rounded text-sm" onchange="generateReport()">
<option value="">전체 프로젝트</option>
<!-- 프로젝트 옵션들이 여기에 로드됩니다 -->
</select>
</div>
<button onclick="printReport()" class="btn-primary px-4 py-2 rounded-lg text-sm">
<i class="fas fa-print mr-2"></i>인쇄
</button>
</div>
</div>
<div id="reportContent">
<!-- 보고서 내용이 여기에 표시됩니다 -->
</div>
</div>
</section>
</div>
<script>
// 최강 캐시 무력화 (API URL 수정 반영)
const timestamp = new Date().getTime();
const random1 = Math.random() * 1000000;
const random2 = Math.floor(Math.random() * 1000000);
const cacheBuster = `${timestamp}-${random1}-${random2}`;
// 기존 api.js 스크립트 제거
const existingScripts = document.querySelectorAll('script[src*="api.js"]');
existingScripts.forEach(script => script.remove());
const script = document.createElement('script');
script.src = `/static/js/api.js?v=${timestamp}&cb=${random1}&bust=${random2}&force=${Date.now()}`;
script.onload = function() {
console.log('✅ API 스크립트 로드 완료');
console.log('🔍 API_BASE_URL:', typeof API_BASE_URL !== 'undefined' ? API_BASE_URL : 'undefined');
console.log('🌐 현재 hostname:', window.location.hostname);
console.log('🔗 현재 protocol:', window.location.protocol);
// initializeApp 함수가 정의되었는지 확인
if (typeof initializeApp === 'function') {
console.log('🚀 initializeApp 함수 호출 시작');
initializeApp();
} else {
console.error('❌ initializeApp 함수가 정의되지 않음');
// 대안: 직접 초기화 로직 실행
setTimeout(() => {
if (typeof initializeApp === 'function') {
console.log('🔄 지연된 initializeApp 함수 호출');
initializeApp();
} else {
console.error('❌ initializeApp 함수를 찾을 수 없음 - 수동 초기화');
manualInitialize();
}
}, 100);
}
};
script.setAttribute('cache-control', 'no-cache, no-store, must-revalidate');
script.setAttribute('pragma', 'no-cache');
script.setAttribute('expires', '0');
script.crossOrigin = 'anonymous';
document.head.appendChild(script);
// 모바일 디버깅용
console.log('📱 최강 캐시 버스터:', cacheBuster);
console.log('📱 화면 크기:', window.innerWidth, 'x', window.innerHeight);
console.log('📱 User Agent:', navigator.userAgent);
</script>
<script src="/static/js/image-utils.js?v=20250917"></script>
<script src="/static/js/date-utils.js?v=20250917"></script>
<script src="/static/js/core/auth-manager.js?v=20251025"></script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script src="/static/js/core/page-preloader.js?v=20251025"></script>
<script src="/static/js/core/keyboard-shortcuts.js?v=20251025"></script>
<script>
let currentUser = null;
let currentPhotos = [];
let issues = [];
// 공통 헤더 초기화 함수 (중복 방지)
async function initializeCommonHeader(user) {
console.log('🔧 공통 헤더 초기화 시작:', user.username);
if (window.commonHeader && typeof window.commonHeader.init === 'function') {
await window.commonHeader.init(user, 'issues_create');
window.commonHeaderInitialized = true;
console.log('✅ 공통 헤더 초기화 완료');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
} else {
console.error('❌ 공통 헤더 모듈이 로드되지 않음');
// 지연 재시도
setTimeout(async () => {
if (window.commonHeader && typeof window.commonHeader.init === 'function') {
console.log('🔄 지연된 공통 헤더 초기화');
await window.commonHeader.init(user, 'issues_create');
window.commonHeaderInitialized = true;
// 지연된 헤더도 애니메이션 적용
setTimeout(() => {
animateHeaderAppearance();
}, 100);
}
}, 200);
}
}
// 단계적 애니메이션 함수
function animateHeaderAppearance() {
console.log('🎨 헤더 애니메이션 시작');
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
if (headerElement) {
headerElement.classList.add('header-fade-in');
setTimeout(() => {
headerElement.classList.add('visible');
console.log('✨ 헤더 페이드인 완료');
// 헤더 애니메이션 완료 후 본문 애니메이션
setTimeout(() => {
animateContentAppearance();
}, 200);
}, 50);
} else {
// 헤더를 찾지 못했으면 바로 본문 애니메이션
console.log('⚠️ 헤더 요소를 찾지 못함 - 본문 애니메이션 시작');
animateContentAppearance();
}
}
// 본문 컨텐츠 애니메이션
function animateContentAppearance() {
console.log('🎨 본문 컨텐츠 애니메이션 시작');
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
const contentElements = document.querySelectorAll('.content-fade-in');
contentElements.forEach((element, index) => {
setTimeout(() => {
element.classList.add('visible');
console.log(`✨ 컨텐츠 ${index + 1} 페이드인 완료`);
}, index * 100); // 100ms씩 지연
});
}
// 수동 초기화 함수 (initializeApp 함수가 로드되지 않을 때 사용)
async function manualInitialize() {
console.log('🔧 수동 초기화 시작');
// 토큰이 있으면 사용자 정보 가져오기
const token = localStorage.getItem('access_token');
if (token) {
try {
// 토큰으로 사용자 정보 가져오기 (API 호출)
const user = await AuthAPI.getCurrentUser();
currentUser = user;
// localStorage에도 백업 저장
localStorage.setItem('currentUser', JSON.stringify(user));
// 공통 헤더 초기화
console.log('🔧 수동 초기화 - 공통 헤더 초기화 시작:', user);
console.log('window.commonHeader 존재:', !!window.commonHeader);
if (window.commonHeader && typeof window.commonHeader.init === 'function') {
await window.commonHeader.init(user, 'issues_create');
console.log('✅ 수동 초기화 - 공통 헤더 초기화 완료');
} else {
console.error('❌ 수동 초기화 - 공통 헤더 모듈이 로드되지 않음');
}
// 페이지 접근 권한 체크 (부적합 등록 페이지)
setTimeout(() => {
if (typeof canAccessPage === 'function' && !canAccessPage('issues_create')) {
alert('부적합 등록 페이지에 접근할 권한이 없습니다.');
window.location.href = '/issue-view.html';
return;
}
}, 500);
// 사용자 정보는 공통 헤더에서 표시됨
document.getElementById('loginScreen').classList.add('hidden');
document.getElementById('mainScreen').classList.remove('hidden');
// 프로젝트 로드
await loadProjects();
loadIssues();
// URL 해시 처리
handleUrlHash();
} catch (error) {
console.error('수동 초기화 실패:', error);
// 토큰이 유효하지 않으면 로그아웃
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
}
}
}
// API 로드 후 앱 초기화 (AuthManager 사용)
async function initializeApp() {
console.log('🚀 앱 초기화 시작 (AuthManager 사용)');
// AuthManager가 이미 사용자 정보를 가지고 있다면 즉시 헤더 표시
if (window.authManager.isLoggedIn()) {
const cachedUser = window.authManager.getCurrentUser();
console.log('⚡ 캐시된 사용자로 즉시 헤더 초기화:', cachedUser.username);
await initializeCommonHeader(cachedUser);
}
try {
// AuthManager를 통한 인증 체크 (캐시 우선, 필요시에만 API 호출)
const user = await window.authManager.checkAuth();
if (user) {
currentUser = user;
console.log('✅ currentUser 전역 변수 업데이트:', currentUser.username);
// 공통 헤더 초기화 (이미 초기화되었다면 스킵)
if (!window.commonHeaderInitialized) {
await initializeCommonHeader(user);
} else {
console.log('✅ 공통 헤더 이미 초기화됨 - 스킵');
}
// 페이지 접근 권한 체크
setTimeout(() => {
if (typeof canAccessPage === 'function' && !canAccessPage('issues_create')) {
alert('부적합 등록 페이지에 접근할 권한이 없습니다.');
window.location.href = '/issue-view.html';
return;
}
}, 500);
// 이미 로그인된 사용자는 메인 화면이 기본으로 표시됨
console.log('✅ 이미 로그인된 사용자 - 메인 화면 유지');
// 데이터 로드
await loadProjects();
loadIssues();
handleUrlHash();
} else {
console.log('❌ 인증되지 않은 사용자 - 로그인 화면 표시');
// 로그인이 필요한 경우 로그인 화면 표시
document.getElementById('loginScreen').classList.remove('hidden');
document.getElementById('mainScreen').classList.add('hidden');
}
} catch (error) {
console.error('❌ 앱 초기화 실패:', error);
// 에러 발생 시 로그인 화면 표시
document.getElementById('loginScreen').classList.remove('hidden');
document.getElementById('mainScreen').classList.add('hidden');
}
}
// DOM 로드 완료 시 즉시 헤더 표시 시도
window.addEventListener('DOMContentLoaded', async () => {
console.log('📄 DOM 로드 완료 - 즉시 헤더 표시 시도');
// AuthManager가 이미 로드되었고 사용자 정보가 있다면 즉시 헤더 표시
if (window.authManager && window.authManager.isLoggedIn()) {
const cachedUser = window.authManager.getCurrentUser();
console.log('⚡ DOM 로드 시점에서 즉시 헤더 표시:', cachedUser.username);
// 공통 헤더 모듈이 로드될 때까지 잠시 대기
let attempts = 0;
while (!window.commonHeader && attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 50));
attempts++;
}
if (window.commonHeader) {
await initializeCommonHeader(cachedUser);
}
}
});
// 로그인 (AuthManager 사용)
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userId = document.getElementById('userId').value;
const password = document.getElementById('password').value;
try {
console.log('🔑 AuthManager를 통한 로그인 시도');
const data = await window.authManager.login(userId, password);
currentUser = data.user;
console.log('✅ 로그인 성공 - 메인 화면 초기화');
// 공통 헤더 초기화
await initializeCommonHeader(currentUser);
// 로그인 성공 후 메인 화면 표시
document.getElementById('loginScreen').classList.add('hidden');
document.getElementById('mainScreen').classList.remove('hidden');
console.log('✅ 로그인 후 메인 화면 전환 완료');
// 데이터 로드
await loadProjects();
loadIssues();
handleUrlHash();
} catch (error) {
console.error('❌ 로그인 실패:', error);
alert(error.message || '로그인에 실패했습니다.');
}
});
// 로그아웃 (AuthManager 사용)
function logout() {
console.log('🚪 AuthManager를 통한 로그아웃');
window.authManager.logout();
}
// 네비게이션은 공통 헤더에서 처리됨
// 관리 버튼 클릭 처리
function handleAdminClick() {
if (currentUser.role === 'admin') {
// 관리자: 사용자 관리 페이지로 이동
window.location.href = 'admin.html';
} else {
// 일반 사용자: 비밀번호 변경 모달 표시
showPasswordChangeModal();
}
}
// 섹션 전환
// URL 해시 처리
function handleUrlHash() {
const hash = window.location.hash.substring(1); // # 제거
if (hash === 'list' || hash === 'summary') {
showSection(hash);
}
}
// 해시 변경 감지
window.addEventListener('hashchange', handleUrlHash);
function showSection(section) {
// 모든 섹션 숨기기 (애니메이션 리셋)
document.querySelectorAll('section').forEach(s => {
s.classList.add('hidden');
s.classList.remove('visible');
});
// 선택된 섹션 표시
const targetSection = document.getElementById(section + 'Section');
targetSection.classList.remove('hidden');
// 부드러운 페이드인 애니메이션
setTimeout(() => {
targetSection.classList.add('visible');
console.log(`${section} 섹션 페이드인 완료`);
}, 50);
// 공통 헤더 현재 페이지 업데이트
if (window.commonHeader && currentUser) {
let pageName = 'issues_create'; // 기본값
if (section === 'list') {
pageName = 'issues_manage';
} else if (section === 'summary') {
pageName = 'reports';
} else if (section === 'report') {
pageName = 'issues_create';
}
window.commonHeader.updateCurrentPage(pageName);
}
// 부적합 등록 섹션으로 전환 시 프로젝트 다시 로드 (모바일 대응)
if (section === 'report') {
setTimeout(async () => {
const projectSelect = document.getElementById('projectSelect');
if (!projectSelect || projectSelect.options.length <= 1) {
console.log('부적합 등록 섹션 - 프로젝트 다시 로드');
await loadProjects();
}
}, 100);
}
// 섹션별 초기화
if (section === 'list') {
// 데이터가 없으면 먼저 로드
if (issues.length === 0) {
loadIssues().then(() => displayIssueList());
} else {
displayIssueList();
}
} else if (section === 'summary') {
// 데이터가 없으면 먼저 로드
if (issues.length === 0) {
loadIssues().then(() => generateReport());
} else {
generateReport();
}
}
}
// 카메라 열기
function openCamera() {
if (currentPhotos.length >= 2) {
alert('최대 2장까지 업로드 가능합니다.');
return;
}
document.getElementById('cameraInput').click();
}
// 갤러리 열기
function openGallery() {
if (currentPhotos.length >= 2) {
alert('최대 2장까지 업로드 가능합니다.');
return;
}
document.getElementById('galleryInput').click();
}
// 사진 업로드 처리 함수
async function handlePhotoUpload(files) {
const filesArray = Array.from(files);
// 현재 사진 개수 확인
if (currentPhotos.length >= 2) {
alert('최대 2장까지 업로드 가능합니다.');
return;
}
// 추가 가능한 개수만큼만 처리
const availableSlots = 2 - currentPhotos.length;
const filesToProcess = filesArray.slice(0, availableSlots);
// 로딩 표시
showUploadProgress(true);
try {
for (const file of filesToProcess) {
if (currentPhotos.length >= 2) break;
// 원본 파일 크기 확인
const originalSize = file.size;
// 이미지 압축
const compressedImage = await ImageUtils.compressImage(file, {
maxWidth: 1280,
maxHeight: 1280,
quality: 0.75
});
currentPhotos.push(compressedImage);
updatePhotoPreview();
}
} catch (error) {
console.error('이미지 압축 실패:', error);
alert('이미지 처리 중 오류가 발생했습니다.');
} finally {
showUploadProgress(false);
}
}
// 업로드 진행 상태 표시
function showUploadProgress(show) {
const cameraBtn = document.getElementById('cameraUpload');
const galleryBtn = document.getElementById('galleryUpload');
const uploadText = document.getElementById('photoUploadText');
if (show) {
cameraBtn.classList.add('opacity-50', 'cursor-not-allowed');
galleryBtn.classList.add('opacity-50', 'cursor-not-allowed');
uploadText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>이미지 처리 중...';
uploadText.classList.add('text-blue-600');
} else {
if (currentPhotos.length < 2) {
cameraBtn.classList.remove('opacity-50', 'cursor-not-allowed');
galleryBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`;
uploadText.classList.remove('text-blue-600');
}
}
// 카메라 입력 이벤트
document.getElementById('cameraInput').addEventListener('change', (e) => {
if (e.target.files && e.target.files.length > 0) {
handlePhotoUpload(e.target.files);
}
e.target.value = ''; // 입력 초기화
});
// 갤러리 입력 이벤트
document.getElementById('galleryInput').addEventListener('change', (e) => {
if (e.target.files && e.target.files.length > 0) {
handlePhotoUpload(e.target.files);
}
e.target.value = ''; // 입력 초기화
});
// 사진 제거
function removePhoto(index) {
currentPhotos.splice(index, 1);
updatePhotoPreview();
}
// 사진 미리보기 업데이트
function updatePhotoPreview() {
const container = document.getElementById('photoPreviewContainer');
const photo1Container = document.getElementById('photo1Container');
const photo2Container = document.getElementById('photo2Container');
const uploadText = document.getElementById('photoUploadText');
const cameraUpload = document.getElementById('cameraUpload');
const galleryUpload = document.getElementById('galleryUpload');
// 텍스트 업데이트
uploadText.textContent = `사진 추가 (${currentPhotos.length}/2)`;
// 첫 번째 사진
if (currentPhotos[0]) {
document.getElementById('previewImg1').src = currentPhotos[0];
photo1Container.classList.remove('hidden');
container.style.display = 'grid';
} else {
photo1Container.classList.add('hidden');
}
// 두 번째 사진
if (currentPhotos[1]) {
document.getElementById('previewImg2').src = currentPhotos[1];
photo2Container.classList.remove('hidden');
container.style.display = 'grid';
} else {
photo2Container.classList.add('hidden');
}
// 미리보기 컨테이너 표시/숨김
if (currentPhotos.length === 0) {
container.style.display = 'none';
}
// 2장이 모두 업로드되면 업로드 버튼 스타일 변경
if (currentPhotos.length >= 2) {
cameraUpload.classList.add('opacity-50', 'cursor-not-allowed');
galleryUpload.classList.add('opacity-50', 'cursor-not-allowed');
uploadText.textContent = '최대 2장 업로드 완료';
uploadText.classList.add('text-green-600', 'font-medium');
} else {
cameraUpload.classList.remove('opacity-50', 'cursor-not-allowed');
galleryUpload.classList.remove('opacity-50', 'cursor-not-allowed');
uploadText.classList.remove('text-green-600', 'font-medium');
}
}
// 목록에서 사진 변경
const tempPhotoChanges = new Map();
function handlePhotoChange(issueId, input) {
const file = input.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
// 임시로 저장
tempPhotoChanges.set(issueId, e.target.result);
// 미리보기 업데이트
const photoElement = document.getElementById(`photo-${issueId}`);
if (photoElement.tagName === 'IMG') {
photoElement.src = e.target.result;
} else {
// div를 img로 교체
const img = document.createElement('img');
img.id = `photo-${issueId}`;
img.src = e.target.result;
img.className = 'w-32 h-32 object-cover rounded-lg shadow-sm';
photoElement.parentNode.replaceChild(img, photoElement);
}
// 수정 상태로 표시
markAsModified(issueId);
};
reader.readAsDataURL(file);
}
}
// 부적합 사항 등록
document.getElementById('reportForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = e.target.querySelector('button[type="submit"]');
const originalBtnContent = submitBtn.innerHTML;
const description = document.getElementById('description').value.trim();
const projectSelect = document.getElementById('projectSelect');
const projectId = projectSelect.value;
// 프로젝트 드롭다운이 비어있으면 다시 로드
if (projectSelect.options.length <= 1) {
console.log('프로젝트 드롭다운이 비어있음, 다시 로드 중...');
await loadProjects();
}
if (!projectId) {
alert('프로젝트를 선택해주세요.');
return;
}
if (!description) {
alert('설명을 입력해주세요.');
return;
}
// 로딩 오버레이 표시 및 상태 업데이트
const loadingOverlay = document.getElementById('loadingOverlay');
const loadingText = loadingOverlay.querySelector('p.text-gray-700');
const loadingSubtext = loadingOverlay.querySelector('p.text-gray-500');
loadingOverlay.classList.add('active');
// 단계별 메시지 표시
const updateLoadingMessage = (message, subMessage = '') => {
if (loadingText) loadingText.textContent = message;
if (loadingSubtext) loadingSubtext.textContent = subMessage;
};
// 로딩 상태 표시
submitBtn.disabled = true;
submitBtn.innerHTML = `
<i class="fas fa-spinner fa-spin mr-2"></i>
등록 중...
`;
submitBtn.classList.add('opacity-75', 'cursor-not-allowed');
// 전체 폼 비활성화
const formElements = e.target.querySelectorAll('input, textarea, select, button');
formElements.forEach(el => el.disabled = true);
try {
// 프로그레스바 업데이트 함수
const updateProgress = (percent) => {
const progressBar = loadingOverlay.querySelector('.upload-progress');
if (progressBar) {
progressBar.style.width = `${percent}%`;
}
};
// 이미지가 있는 경우 압축 상태 표시
if (currentPhotos.length > 0) {
updateLoadingMessage('이미지 최적화 중...', `${currentPhotos.length}장 처리 중`);
updateProgress(30);
await new Promise(resolve => setTimeout(resolve, 100)); // UI 업데이트를 위한 짧은 대기
}
updateLoadingMessage('서버로 전송 중...', '네트워크 상태에 따라 시간이 걸릴 수 있습니다');
updateProgress(60);
// 선택된 프로젝트 정보 가져오기
const selectedProject = getSelectedProject(projectId);
const issueData = {
photos: currentPhotos, // 배열로 전달
category: document.getElementById('category').value,
description: description,
project_id: parseInt(projectId)
};
console.log('DEBUG: 전송할 issueData:', issueData);
console.log('DEBUG: projectId 원본:', projectId);
console.log('DEBUG: parseInt(projectId):', parseInt(projectId));
const startTime = Date.now();
// 부적합 사항은 API로 직접 업로드 (정상 작동 중)
await IssuesAPI.create(issueData);
const uploadTime = Date.now() - startTime;
updateProgress(90);
updateLoadingMessage('완료 처리 중...', '거의 다 되었습니다');
updateProgress(100);
// 성공 메시지 (토스트)
showToastMessage('부적합 사항이 등록되었습니다!', 'success');
// 폼 초기화
document.getElementById('reportForm').reset();
currentPhotos = [];
updatePhotoPreview();
// 목록 다시 로드
await loadIssues();
if (document.getElementById('listSection').classList.contains('hidden') === false) {
displayIssueList();
}
} catch (error) {
alert(error.message || '등록에 실패했습니다.');
} finally {
// 로딩 오버레이 숨기기
setTimeout(() => {
document.getElementById('loadingOverlay').classList.remove('active');
// 프로그레스바 초기화
const progressBar = document.querySelector('.upload-progress');
if (progressBar) {
progressBar.style.width = '0%';
}
}, 300);
// 버튼 및 폼 원상 복구
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnContent;
submitBtn.classList.remove('opacity-75', 'cursor-not-allowed');
// 폼 요소 다시 활성화
formElements.forEach(el => {
if (el !== submitBtn) {
el.disabled = false;
}
});
}
});
// 데이터 로드
async function loadIssues() {
try {
issues = await IssuesAPI.getAll();
} catch (error) {
console.error('이슈 로드 실패:', error);
issues = [];
}
}
// 프로젝트 로드 (API 사용)
async function loadProjects() {
console.log('=== 프로젝트 로드 시작 (API) ===');
try {
// API에서 활성 프로젝트만 로드 (업로드용)
const apiProjects = await ProjectsAPI.getAll(true);
// API 데이터를 UI 형식으로 변환
const projects = apiProjects.map(p => ({
id: p.id,
jobNo: p.job_no,
projectName: p.project_name,
isActive: p.is_active,
createdAt: p.created_at || new Date().toISOString(),
createdByName: '관리자'
}));
console.log('✅ API에서 프로젝트 로드:', projects.length, '개');
displayProjectsInUI(projects);
} catch (error) {
console.error('❌ API 로드 실패:', error);
// API 실패 시 빈 배열 표시
console.log('❌ API 로드 실패 - 프로젝트 없음');
displayProjectsInUI([]);
}
}
function displayProjectsInUI(projects) {
const activeProjects = projects.filter(p => p.isActive);
console.log('displayProjectsInUI - 활성 프로젝트:', activeProjects);
// 부적합 등록 폼의 프로젝트 선택 (활성 프로젝트만)
const projectSelect = document.getElementById('projectSelect');
console.log('projectSelect 요소:', projectSelect);
if (projectSelect) {
projectSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
activeProjects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}`;
projectSelect.appendChild(option);
console.log('프로젝트 옵션 추가:', project.jobNo, project.projectName);
});
// 디버깅: 프로젝트 드롭다운 상태 확인
console.log('프로젝트 드롭다운 상태:');
console.log('- 현재 사용자:', window.currentUser);
console.log('- 드롭다운 표시 여부:', projectSelect.style.display);
console.log('- 드롭다운 옵션 수:', projectSelect.options.length);
console.log('- 부모 요소 표시 여부:', projectSelect.parentElement.style.display);
// 모바일에서 select 요소 강제 표시
if (window.innerWidth <= 768) {
// 모바일에서 강제 스타일 적용
projectSelect.style.display = 'block';
projectSelect.style.visibility = 'visible';
projectSelect.style.opacity = '1';
projectSelect.style.width = '100%';
projectSelect.style.height = 'auto';
projectSelect.style.minHeight = '40px';
// 부모 요소도 강제 표시
if (projectSelect.parentElement) {
projectSelect.parentElement.style.display = 'block';
projectSelect.parentElement.style.visibility = 'visible';
}
console.log('모바일 강제 스타일 적용 완료');
// 모바일에서 드롭다운 클릭 이벤트 강제 바인딩
projectSelect.addEventListener('click', async function() {
console.log('모바일 드롭다운 클릭됨, 옵션 수:', this.options.length);
// 옵션이 없으면 다시 로드
if (this.options.length <= 1) {
console.log('옵션이 없어서 프로젝트 다시 로드');
await loadProjects();
}
});
// 모바일에서 드롭다운 포커스 이벤트
projectSelect.addEventListener('focus', function() {
console.log('모바일 드롭다운 포커스, 옵션 수:', this.options.length);
});
}
} else {
console.error('projectSelect 요소를 찾을 수 없습니다!');
// 모바일에서 DOM이 준비되지 않았을 수 있으므로 재시도
setTimeout(() => {
console.log('projectSelect 재시도...');
displayProjectsInUI(projects);
}, 500);
}
// 목록 관리의 프로젝트 필터 (모든 프로젝트)
const listProjectFilter = document.getElementById('listProjectFilter');
if (listProjectFilter) {
listProjectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}${!project.isActive ? ' (비활성)' : ''}`;
listProjectFilter.appendChild(option);
});
}
// 보고서의 프로젝트 필터 (모든 프로젝트)
const reportProjectFilter = document.getElementById('reportProjectFilter');
if (reportProjectFilter) {
reportProjectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}${!project.isActive ? ' (비활성)' : ''}`;
reportProjectFilter.appendChild(option);
});
}
// 전역 캐시에 저장
window.projectsCache = projects;
}
// 선택된 프로젝트 정보 가져오기
function getSelectedProject(projectId) {
if (window.projectsCache) {
return window.projectsCache.find(p => p.id == projectId);
}
return null;
}
// 프로젝트 정보 표시용 함수
function getProjectInfo(projectId) {
if (!projectId) {
return '<span class="text-gray-500">프로젝트 미지정</span>';
}
const project = getSelectedProject(projectId);
if (project) {
return `${project.jobNo} - ${project.projectName}`;
} else {
return `<span class="text-red-500">프로젝트 ID: ${projectId} (정보 없음)</span>`;
}
}
// LocalStorage 관련 함수는 더 이상 사용하지 않음
function saveIssues() {
// Deprecated - API 사용
}
// 검토 상태 확인 함수
function isReviewCompleted(issue) {
return issue.status === 'complete' && issue.work_hours && issue.work_hours > 0;
}
// 날짜 필터링 함수
function filterByDate(issues, dateFilter, customDate) {
if (customDate) {
const targetDate = new Date(customDate);
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate.toDateString() === targetDate.toDateString();
});
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
switch (dateFilter) {
case 'today':
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= today;
});
case 'week':
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= weekStart;
});
case 'month':
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
return issues.filter(issue => {
const issueDate = new Date(issue.report_date);
return issueDate >= monthStart;
});
default:
return issues;
}
}
// 목록 표시
function displayIssueList() {
const container = document.getElementById('issueList');
container.innerHTML = '';
// 필터 값 가져오기
const selectedProjectId = document.getElementById('listProjectFilter').value;
const reviewStatusFilter = document.getElementById('reviewStatusFilter').value;
const dateFilter = document.getElementById('dateFilter').value;
const customDate = document.getElementById('customDateFilter').value;
let filteredIssues = [...issues];
// 프로젝트 필터 적용
if (selectedProjectId) {
filteredIssues = filteredIssues.filter(issue => {
const issueProjectId = issue.project_id || issue.projectId;
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
});
}
// 워크플로우 상태 필터 적용
if (reviewStatusFilter) {
filteredIssues = filteredIssues.filter(issue => {
// 새로운 워크플로우 시스템 사용
if (issue.review_status) {
return issue.review_status === reviewStatusFilter;
}
// 기존 데이터 호환성을 위한 폴백
else {
const isCompleted = isReviewCompleted(issue);
if (reviewStatusFilter === 'pending_review') return !isCompleted;
if (reviewStatusFilter === 'completed') return isCompleted;
return false;
}
});
}
// 날짜 필터 적용
if (dateFilter || customDate) {
filteredIssues = filterByDate(filteredIssues, dateFilter, customDate);
}
if (filteredIssues.length === 0) {
container.innerHTML = `<p class="text-gray-500 text-center py-8">조건에 맞는 부적합 사항이 없습니다.</p>`;
return;
}
// 워크플로우 상태별로 분류 및 정렬
const groupedIssues = {
pending_review: filteredIssues.filter(issue =>
issue.review_status === 'pending_review' || (!issue.review_status && !isReviewCompleted(issue))
),
in_progress: filteredIssues.filter(issue => issue.review_status === 'in_progress'),
completed: filteredIssues.filter(issue =>
issue.review_status === 'completed' || (!issue.review_status && isReviewCompleted(issue))
),
disposed: filteredIssues.filter(issue => issue.review_status === 'disposed')
};
const pendingIssues = groupedIssues.pending_review;
const completedIssues = [...groupedIssues.in_progress, ...groupedIssues.completed, ...groupedIssues.disposed];
// 검토 필요 항목을 먼저 표시
if (pendingIssues.length > 0) {
const pendingHeader = document.createElement('div');
pendingHeader.className = 'mb-4';
pendingHeader.innerHTML = `
<h3 class="text-md font-semibold text-orange-700 flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>검토 필요 (${pendingIssues.length}건)
</h3>
`;
container.appendChild(pendingHeader);
pendingIssues.forEach(issue => {
container.appendChild(createIssueCard(issue, false));
});
}
// 검토 완료 항목을 아래에 표시
if (completedIssues.length > 0) {
const completedHeader = document.createElement('div');
completedHeader.className = 'mb-4 mt-8';
completedHeader.innerHTML = `
<h3 class="text-md font-semibold text-green-700 flex items-center">
<i class="fas fa-check-circle mr-2"></i>검토 완료 (${completedIssues.length}건)
</h3>
`;
container.appendChild(completedHeader);
completedIssues.forEach(issue => {
container.appendChild(createIssueCard(issue, true));
});
}
}
// 부적합 사항 카드 생성 함수
function createIssueCard(issue, isCompleted) {
const categoryNames = {
material_missing: '자재누락',
design_error: '설계미스',
incoming_defect: '입고자재 불량',
inspection_miss: '검사미스'
};
const div = document.createElement('div');
// 검토 완료 상태에 따른 스타일링
const baseClasses = 'border rounded-lg p-6 transition-all duration-200';
const statusClasses = isCompleted
? 'bg-gray-100 opacity-75 border-gray-300'
: 'bg-gray-50 border-gray-200 hover:shadow-md';
div.className = `${baseClasses} ${statusClasses}`;
div.id = `issue-card-${issue.id}`;
// 프로젝트 정보 가져오기
const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
div.innerHTML = `
<div class="space-y-4">
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
${isCompleted ?
'<div class="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium"><i class="fas fa-check-circle mr-1"></i>검토완료</div>' :
'<div class="px-2 py-1 bg-orange-100 text-orange-800 rounded-full text-xs font-medium"><i class="fas fa-exclamation-triangle mr-1"></i>검토필요</div>'
}
</div>
<div class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
</div>
</div>
<!-- 사진과 기본 정보 -->
<div class="flex gap-4">
<!-- 사진들 표시 -->
<div class="flex gap-2">
${issue.photo_path || issue.photo_path2 ?
`${issue.photo_path ?
`<div class="relative">
<img id="photo-${issue.id}" src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path}')">
</div>` : ''
}
${issue.photo_path2 ?
`<div class="relative">
<img id="photo2-${issue.id}" src="${issue.photo_path2}" class="w-32 h-32 object-cover rounded-lg shadow-sm cursor-pointer" onclick="showImageModal('${issue.photo_path2}')">
</div>` : ''
}` :
`<div class="relative">
<div id="photo-${issue.id}" class="w-32 h-32 bg-gray-200 rounded-lg flex items-center justify-center">
<i class="fas fa-image text-gray-400 text-3xl"></i>
</div>
<button
type="button"
onclick="document.getElementById('photoEdit-${issue.id}').click()"
class="absolute bottom-1 right-1 p-1 bg-blue-500 text-white rounded-full hover:bg-blue-600 text-xs"
title="사진 추가"
>
<i class="fas fa-camera"></i>
</button>
</div>`
}
<input type="file" id="photoEdit-${issue.id}" accept="image/*" class="hidden" onchange="handlePhotoChange(${issue.id}, this)" multiple>
</div>
<div class="flex-1 space-y-3">
<!-- 카테고리 선택 -->
<div>
<label class="text-sm text-gray-600">카테고리</label>
<select
id="category-${issue.id}"
class="input-field w-full px-3 py-2 rounded-lg"
onchange="markAsModified(${issue.id})"
>
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
<option value="etc" ${issue.category === 'etc' ? 'selected' : ''}>기타</option>
</select>
</div>
<!-- 설명 -->
<div>
<label class="text-sm text-gray-600">설명</label>
<textarea
id="description-${issue.id}"
class="input-field w-full px-3 py-2 rounded-lg resize-none"
rows="2"
oninput="markAsModified(${issue.id})"
>${issue.description}</textarea>
</div>
</div>
</div>
<!-- 작업 시간 입력 및 버튼 -->
<div class="border-t pt-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- 작업 시간 입력 영역 -->
<div class="flex items-center gap-2 p-2 bg-blue-50 rounded-lg">
<label class="text-sm font-medium text-gray-700">
<i class="fas fa-wrench text-blue-500 mr-1"></i>해결 시간:
</label>
<input
id="workHours-${issue.id}"
type="number"
step="0.5"
min="0"
value="${issue.work_hours || ''}"
class="input-field px-3 py-1 w-20 rounded bg-white"
oninput="onWorkHoursChange(${issue.id})"
placeholder="0"
>
<span class="text-gray-600">시간</span>
<!-- 시간 입력 확인 버튼 -->
<button
id="workHours-confirm-${issue.id}"
onclick="confirmWorkHours(${issue.id})"
class="px-3 py-1 rounded transition-colors text-sm font-medium ${
isCompleted
? 'bg-green-500 text-white cursor-default'
: issue.work_hours
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}"
${isCompleted || !issue.work_hours ? 'disabled' : ''}
>
${isCompleted
? '<i class="fas fa-check-circle mr-1"></i>검토완료'
: issue.work_hours
? '<i class="fas fa-clock mr-1"></i>확인'
: '<i class="fas fa-clock mr-1"></i>확인'
}
</button>
</div>
<!-- 전체 저장/취소 버튼 (수정시 표시) -->
<div id="save-buttons-${issue.id}" class="hidden flex gap-2 ml-4 border-l pl-4">
<button
onclick="saveIssueChanges(${issue.id})"
class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors text-sm"
>
<i class="fas fa-save mr-1"></i>전체 저장
</button>
<button
onclick="cancelIssueChanges(${issue.id})"
class="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors text-sm"
>
<i class="fas fa-undo mr-1"></i>되돌리기
</button>
</div>
${currentUser && currentUser.role === 'admin' ? `
<button
onclick="deleteIssue(${issue.id})"
class="ml-auto px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm"
>
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span><i class="fas fa-user mr-1"></i>${issue.reporter.full_name || issue.reporter.username}</span>
<span><i class="fas fa-calendar mr-1"></i>${DateUtils.formatKST(issue.report_date)}</span>
<span class="px-2 py-1 rounded-full text-xs ${
issue.status === 'complete' ? 'bg-green-100 text-green-700' :
issue.status === 'progress' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-700'
}">
${issue.status === 'complete' ? '완료' : issue.status === 'progress' ? '진행중' : '신규'}
</span>
</div>
</div>
</div>
</div>
`;
return div;
}
// 수정 상태 표시
const modifiedIssues = new Set();
function markAsModified(issueId) {
modifiedIssues.add(issueId);
const saveButtons = document.getElementById(`save-buttons-${issueId}`);
if (saveButtons) {
saveButtons.classList.remove('hidden');
}
// 카드 배경색 변경으로 수정 중임을 표시
const card = document.getElementById(`issue-card-${issueId}`);
if (card) {
card.classList.add('bg-yellow-50', 'border-yellow-300');
}
}
// 작업 시간 변경 시
function onWorkHoursChange(issueId) {
const input = document.getElementById(`workHours-${issueId}`);
const confirmBtn = document.getElementById(`workHours-confirm-${issueId}`);
if (input && confirmBtn) {
const hasValue = input.value && parseFloat(input.value) > 0;
const issue = issues.find(i => i.id === issueId);
const isChanged = parseFloat(input.value) !== (issue?.work_hours || 0);
// 버튼 활성화/비활성화
confirmBtn.disabled = !hasValue || !isChanged;
// 버튼 스타일 업데이트
if (!hasValue) {
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-gray-300 text-gray-500 cursor-not-allowed';
confirmBtn.innerHTML = '<i class="fas fa-clock mr-1"></i>확인';
} else if (issue?.work_hours && !isChanged) {
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-green-100 text-green-700 cursor-default';
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-1"></i>완료';
} else {
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-blue-500 text-white hover:bg-blue-600';
confirmBtn.innerHTML = '<i class="fas fa-clock mr-1"></i>확인';
}
// 다른 필드도 수정된 경우를 위해 markAsModified 호출
if (isChanged) {
markAsModified(issueId);
}
}
}
// 작업 시간만 빠르게 저장
async function confirmWorkHours(issueId) {
const workHours = parseFloat(document.getElementById(`workHours-${issueId}`).value);
if (!workHours || workHours <= 0) {
alert('작업 시간을 입력해주세요.');
return;
}
try {
// 작업 시간 업데이트 및 검토 완료 상태로 변경
await IssuesAPI.update(issueId, {
work_hours: workHours,
status: 'complete' // 검토 완료 상태로 변경
});
// 로컬 데이터도 업데이트
const issue = issues.find(i => i.id === issueId);
if (issue) {
issue.work_hours = workHours;
issue.status = 'complete';
issue.reviewed_at = new Date().toISOString(); // 검토 완료 시간 기록
}
// 성공 시 목록 다시 표시
displayIssueList();
// 성공 메시지
showToastMessage(`${workHours}시간 저장 및 검토 완료!`, 'success');
} catch (error) {
alert(error.message || '저장에 실패했습니다.');
}
}
// 변경사항 저장
async function saveIssueChanges(issueId) {
try {
const category = document.getElementById(`category-${issueId}`).value;
const description = document.getElementById(`description-${issueId}`).value.trim();
const workHours = parseFloat(document.getElementById(`workHours-${issueId}`).value) || 0;
// 유효성 검사
if (!description) {
alert('설명은 필수 입력 항목입니다.');
return;
}
// 업데이트 데이터 준비
const updateData = {
category: category,
description: description,
work_hours: workHours
};
// 사진이 변경되었으면 추가
if (tempPhotoChanges.has(issueId)) {
updateData.photo = tempPhotoChanges.get(issueId);
}
// API 호출
await IssuesAPI.update(issueId, updateData);
// 성공 시 상태 초기화
modifiedIssues.delete(issueId);
tempPhotoChanges.delete(issueId);
// 데이터 다시 로드
await loadIssues();
displayIssueList();
// 성공 메시지 (작은 토스트 메시지)
showToastMessage('저장되었습니다!', 'success');
} catch (error) {
alert(error.message || '저장에 실패했습니다.');
}
}
// 변경사항 취소
async function cancelIssueChanges(issueId) {
// 수정 상태 초기화
modifiedIssues.delete(issueId);
tempPhotoChanges.delete(issueId);
// 원래 데이터로 복원
await loadIssues();
displayIssueList();
}
// 이미지 모달 표시
function showImageModal(imagePath) {
if (!imagePath) return;
// 모달 HTML 생성
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50';
modal.onclick = () => modal.remove();
modal.innerHTML = `
<div class="relative max-w-4xl max-h-[90vh]">
<img src="${imagePath}" class="max-w-full max-h-[90vh] object-contain rounded-lg">
<button
onclick="this.parentElement.parentElement.remove()"
class="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full hover:bg-red-600"
>
<i class="fas fa-times"></i>
</button>
</div>
`;
document.body.appendChild(modal);
}
// 비밀번호 변경 모달 표시
function showPasswordChangeModal() {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
modal.innerHTML = `
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">비밀번호 변경</h3>
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<form id="passwordChangeForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
<input type="password" id="currentPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
<input type="password" id="newPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required minlength="6">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
<input type="password" id="confirmPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
</div>
<div class="flex gap-2 pt-4">
<button type="button" onclick="this.closest('.fixed').remove()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
취소
</button>
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
변경
</button>
</div>
</form>
</div>
`;
document.body.appendChild(modal);
// 폼 제출 이벤트 처리
document.getElementById('passwordChangeForm').addEventListener('submit', handlePasswordChange);
}
// 비밀번호 변경 처리
async function handlePasswordChange(e) {
e.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// 새 비밀번호 확인
if (newPassword !== confirmPassword) {
alert('새 비밀번호가 일치하지 않습니다.');
return;
}
try {
// API를 통한 비밀번호 변경
await AuthAPI.changePassword(currentPassword, newPassword);
showToastMessage('비밀번호가 성공적으로 변경되었습니다.');
document.querySelector('.fixed').remove(); // 모달 닫기
} catch (error) {
console.error('비밀번호 변경 실패:', error);
showToastMessage('현재 비밀번호가 올바르지 않거나 변경에 실패했습니다.', 'error');
}
}
// 토스트 메시지 표시
function showToastMessage(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`;
toast.innerHTML = `<i class="fas fa-check-circle mr-2"></i>${message}`;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 2000);
}
// 이슈 삭제 함수 (관리자만)
async function deleteIssue(issueId) {
if (!currentUser || currentUser.role !== 'admin') {
alert('관리자만 삭제할 수 있습니다.');
return;
}
if (!confirm('정말로 이 부적합 사항을 삭제하시겠습니까?')) {
return;
}
try {
await IssuesAPI.delete(issueId);
// 성공 시 목록 다시 로드
await loadIssues();
displayIssueList();
alert('삭제되었습니다.');
} catch (error) {
alert(error.message || '삭제에 실패했습니다.');
}
}
// 보고서 생성
async function generateReport() {
const container = document.getElementById('reportContent');
// 선택된 프로젝트 가져오기
const selectedProjectId = document.getElementById('reportProjectFilter').value;
// 프로젝트별 필터링된 부적합 사항
let filteredIssues = issues;
if (selectedProjectId) {
filteredIssues = issues.filter(issue => {
const issueProjectId = issue.project_id || issue.projectId;
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
});
}
// 날짜 범위 계산
const dates = filteredIssues.map(i => new Date(i.report_date));
const startDate = dates.length > 0 ? new Date(Math.min(...dates)) : new Date();
const endDate = new Date();
// 프로젝트별 일일 공수 데이터 계산
let dailyWorkTotal = 0;
// API에서 일일 공수 데이터 가져오기
try {
if (selectedProjectId) {
// 선택된 프로젝트의 일일 공수만 가져오기
const projectDailyWork = await DailyWorkAPI.getAll({ project_id: selectedProjectId });
dailyWorkTotal = projectDailyWork.reduce((sum, work) => sum + (work.total_hours || 0), 0);
console.log(`프로젝트 ID ${selectedProjectId}의 총 일일공수:`, dailyWorkTotal);
} else {
// 전체 프로젝트의 일일 공수 합계
const apiDailyWork = await DailyWorkAPI.getAll();
dailyWorkTotal = apiDailyWork.reduce((sum, work) => sum + (work.total_hours || 0), 0);
console.log('API에서 가져온 전체 총 일일공수:', dailyWorkTotal);
}
} catch (error) {
console.error('일일 공수 API 호출 실패:', error);
dailyWorkTotal = 0;
}
// 부적합 사항 해결 시간 계산 (필터링된 이슈만)
const issueHours = filteredIssues.reduce((sum, issue) => sum + (issue.work_hours || 0), 0);
const categoryCount = {};
filteredIssues.forEach(issue => {
categoryCount[issue.category] = (categoryCount[issue.category] || 0) + 1;
});
// 부적합 시간 비율 계산
const issuePercentage = dailyWorkTotal > 0 ? ((issueHours / dailyWorkTotal) * 100).toFixed(1) : 0;
// 선택된 프로젝트 정보
let projectInfo = '전체 프로젝트';
if (selectedProjectId) {
try {
const selectedProject = await ProjectsAPI.get(selectedProjectId);
projectInfo = `${selectedProject.job_no} - ${selectedProject.project_name}`;
} catch (error) {
console.error('프로젝트 정보 로드 실패:', error);
projectInfo = `프로젝트 ID: ${selectedProjectId}`;
}
}
container.innerHTML = `
<div class="space-y-6">
<!-- 요약 페이지 -->
<div class="border-b pb-6">
<h3 class="text-2xl font-bold text-center mb-2">작업 보고서</h3>
<p class="text-center text-gray-600 mb-6">${projectInfo}</p>
<div class="grid md:grid-cols-3 gap-4">
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="font-semibold text-gray-700 mb-2">작업 기간</h4>
<p class="text-lg">${startDate.toLocaleDateString()} ~ ${endDate.toLocaleDateString()}</p>
</div>
<div class="bg-blue-50 rounded-lg p-4">
<h4 class="font-semibold text-blue-700 mb-2">총 작업 공수</h4>
<p class="text-3xl font-bold text-blue-600">${dailyWorkTotal}시간</p>
<p class="text-xs text-gray-600 mt-1">일일 공수 합계</p>
</div>
<div class="bg-red-50 rounded-lg p-4">
<h4 class="font-semibold text-red-700 mb-2">부적합 처리 시간</h4>
<p class="text-3xl font-bold text-red-600">${issueHours}시간</p>
<p class="text-xs text-gray-600 mt-1">전체 공수의 ${issuePercentage}%</p>
</div>
</div>
<!-- 효율성 지표 -->
<div class="mt-6">
<h4 class="font-semibold text-gray-700 mb-3">작업 효율성</h4>
<div class="bg-gradient-to-r from-green-50 to-blue-50 rounded-lg p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">정상 작업 시간</p>
<p class="text-2xl font-bold text-green-600">${dailyWorkTotal - issueHours}시간</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">작업 효율</p>
<p class="text-3xl font-bold ${100 - issuePercentage >= 90 ? 'text-green-600' : 100 - issuePercentage >= 80 ? 'text-yellow-600' : 'text-red-600'}">
${(100 - issuePercentage).toFixed(1)}%
</p>
</div>
<div>
<p class="text-sm text-gray-600">부적합 처리</p>
<p class="text-2xl font-bold text-red-600">${issueHours}시간</p>
</div>
</div>
</div>
</div>
<!-- 카테고리별 분석 -->
<div class="mt-6">
<h4 class="font-semibold text-gray-700 mb-3">부적합 카테고리별 분석</h4>
<div class="grid md:grid-cols-4 gap-4">
<div class="bg-yellow-50 rounded-lg p-4 text-center">
<p class="text-2xl font-bold text-yellow-600">${categoryCount.material_missing || 0}</p>
<p class="text-sm text-gray-600">자재누락</p>
</div>
<div class="bg-blue-50 rounded-lg p-4 text-center">
<p class="text-2xl font-bold text-blue-600">${categoryCount.design_error || 0}</p>
<p class="text-sm text-gray-600">설계미스</p>
</div>
<div class="bg-red-50 rounded-lg p-4 text-center">
<p class="text-2xl font-bold text-red-600">${categoryCount.incoming_defect || 0}</p>
<p class="text-sm text-gray-600">입고자재 불량</p>
</div>
<div class="bg-purple-50 rounded-lg p-4 text-center">
<p class="text-2xl font-bold text-purple-600">${categoryCount.inspection_miss || 0}</p>
<p class="text-sm text-gray-600">검사미스</p>
</div>
</div>
</div>
</div>
<!-- 상세 내역 -->
<div>
<h4 class="font-semibold text-gray-700 mb-4">부적합 사항 상세</h4>
${filteredIssues.map(issue => {
const categoryNames = {
material_missing: '자재누락',
design_error: '설계미스',
incoming_defect: '입고자재 불량',
inspection_miss: '검사미스'
};
return `
<div class="border rounded-lg p-4 mb-4 break-inside-avoid">
<div class="flex gap-4">
<div class="flex gap-2">
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg">` : ''}
${issue.photo_path2 ? `<img src="${issue.photo_path2}" class="w-32 h-32 object-cover rounded-lg">` : ''}
</div>
<div class="flex-1">
<h5 class="font-semibold text-gray-800 mb-2">${categoryNames[issue.category] || issue.category}</h5>
<p class="text-gray-600 mb-2">${issue.description}</p>
<div class="grid grid-cols-2 gap-2 text-sm">
<p><span class="font-medium">상태:</span> ${
issue.status === 'new' ? '신규' :
issue.status === 'progress' ? '진행중' : '완료'
}</p>
<p><span class="font-medium">작업시간:</span> ${issue.work_hours}</p>
<p><span class="font-medium">보고자:</span> ${issue.reporter.full_name || issue.reporter.username}</p>
<p><span class="font-medium">보고일:</span> ${DateUtils.formatKST(issue.report_date, true)}</p>
</div>
${issue.detail_notes ? `
<div class="mt-2 p-2 bg-gray-50 rounded">
<p class="text-sm text-gray-600">${issue.detail_notes}</p>
</div>
` : ''}
</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
}
// 인쇄
function printReport() {
window.print();
}
</script>
</body>
</html>