feat: 현황판 진행 상태 세분화 및 완료 신청 기능 구현

🎯 진행 상태 4단계 세분화:
- 진행 중 (파란색): 일반적인 진행 상태
- 긴급 (주황색): 마감 3일 이내
- 지연됨 (빨간색): 마감시간 초과
- 완료 대기 (보라색): 완료 신청 후 승인 대기

🔧 상태 판별 로직:
- 마감시간 기준 자동 상태 변경
- completion_requested_at 필드로 완료 대기 상태 판별
- 각 상태별 고유 색상, 아이콘, 텍스트

📱 완료 신청 기능:
- 마감시간 카드 우하단에 '완료신청' 버튼
- 완료 사진 업로드 (필수, 5MB 제한)
- 완료 코멘트 입력 (선택사항)
- 실시간 이미지 미리보기

🗄️ DB 구조 확장:
- completion_requested_at: 완료 신청 시간
- completion_requested_by_id: 신청자 ID
- completion_photo_path: 완료 사진 경로
- completion_comment: 완료 코멘트

🎨 UI/UX 개선:
- 상태별 그라데이션 배경색
- 애니메이션 아이콘 (톱니바퀴, 경고, 시계 등)
- 드래그 앤 드롭 사진 업로드
- 모달 기반 완료 신청 폼

💡 워크플로우:
1. 담당자가 작업 완료 후 '완료신청' 클릭
2. 완료 사진과 코멘트 업로드
3. 상태가 '완료 대기'로 변경
4. 관리자 승인 후 '완료됨'으로 최종 처리

🔐 보안 및 검증:
- 이미지 파일 타입 검증
- 파일 크기 제한 (5MB)
- Base64 인코딩으로 안전한 전송
- 사용자 인증 및 권한 확인

Expected Result:
 진행 상황을 한눈에 파악 가능한 색상 코딩
 마감 관리 자동화 (긴급/지연 상태)
 완료 신청 프로세스로 품질 관리 강화
 직관적인 UI로 사용자 경험 향상
This commit is contained in:
Hyungi Ahn
2025-10-26 12:50:33 +09:00
parent b090ff8f29
commit b836b010b9
2 changed files with 277 additions and 13 deletions

View File

@@ -0,0 +1,37 @@
-- 완료 신청 관련 필드 추가
-- 마이그레이션: 019_add_completion_request_fields.sql
DO $$
BEGIN
-- 완료 신청 관련 필드들 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_requested_at') THEN
ALTER TABLE issues ADD COLUMN completion_requested_at TIMESTAMP WITH TIME ZONE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_requested_by_id') THEN
ALTER TABLE issues ADD COLUMN completion_requested_by_id INTEGER REFERENCES users(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path') THEN
ALTER TABLE issues ADD COLUMN completion_photo_path VARCHAR(500);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_comment') THEN
ALTER TABLE issues ADD COLUMN completion_comment TEXT;
END IF;
-- 마이그레이션 로그 기록
INSERT INTO migration_log (migration_file, executed_at, status, notes)
VALUES ('019_add_completion_request_fields.sql', NOW(), 'SUCCESS', 'Added completion request fields: completion_requested_at, completion_requested_by_id, completion_photo_path, completion_comment');
RAISE NOTICE '✅ 완료 신청 관련 필드 추가 완료';
RAISE NOTICE '📝 완료 신청 필드 마이그레이션 완료 - 019_add_completion_request_fields.sql';
EXCEPTION
WHEN OTHERS THEN
-- 오류 발생 시 로그 기록
INSERT INTO migration_log (migration_file, executed_at, status, notes)
VALUES ('019_add_completion_request_fields.sql', NOW(), 'FAILED', 'Error: ' || SQLERRM);
RAISE EXCEPTION '❌ 마이그레이션 실패: %', SQLERRM;
END $$;

View File

@@ -517,15 +517,63 @@
}); });
}; };
// 긴급도 체크 (예상완료일 기준) // 상태 체크 함수들
const isUrgent = () => { const getIssueStatus = () => {
if (!issue.expected_completion_date) return false; if (issue.review_status === 'completed') return 'completed';
const expectedDate = new Date(issue.expected_completion_date); if (issue.completion_requested_at) return 'pending_completion'; // 완료 대기
const now = new Date();
const diffDays = (expectedDate - now) / (1000 * 60 * 60 * 24); if (issue.expected_completion_date) {
return diffDays <= 3; // 3일 이내 또는 지연 const expectedDate = new Date(issue.expected_completion_date);
const now = new Date();
const diffDays = (expectedDate - now) / (1000 * 60 * 60 * 24);
if (diffDays < 0) return 'overdue'; // 지연됨
if (diffDays <= 3) return 'urgent'; // 긴급
}
return 'in_progress'; // 진행 중
}; };
const getStatusConfig = (status) => {
const configs = {
'in_progress': {
text: '진행 중',
bgColor: 'bg-gradient-to-r from-blue-500 to-blue-600',
icon: 'fas fa-cog fa-spin',
dotColor: 'bg-white'
},
'urgent': {
text: '긴급',
bgColor: 'bg-gradient-to-r from-orange-500 to-orange-600',
icon: 'fas fa-exclamation-triangle',
dotColor: 'bg-white'
},
'overdue': {
text: '지연됨',
bgColor: 'bg-gradient-to-r from-red-500 to-red-600',
icon: 'fas fa-clock',
dotColor: 'bg-white'
},
'pending_completion': {
text: '완료 대기',
bgColor: 'bg-gradient-to-r from-purple-500 to-purple-600',
icon: 'fas fa-hourglass-half',
dotColor: 'bg-white'
},
'completed': {
text: '완료됨',
bgColor: 'bg-gradient-to-r from-green-500 to-green-600',
icon: 'fas fa-check-circle',
dotColor: 'bg-white'
}
};
return configs[status] || configs['in_progress'];
};
const currentStatus = getIssueStatus();
const statusConfig = getStatusConfig(currentStatus);
const isUrgent = () => currentStatus === 'urgent';
return ` return `
<div class="issue-card bg-white rounded-xl border border-gray-200 p-5 hover:shadow-xl hover:border-blue-300 transition-all duration-300 transform hover:-translate-y-1"> <div class="issue-card bg-white rounded-xl border border-gray-200 p-5 hover:shadow-xl hover:border-blue-300 transition-all duration-300 transform hover:-translate-y-1">
<!-- 헤더 --> <!-- 헤더 -->
@@ -542,11 +590,10 @@
</div> </div>
<div class="flex flex-col items-end space-y-2 ml-4"> <div class="flex flex-col items-end space-y-2 ml-4">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
${isUrgent() ? '<span class="bg-gradient-to-r from-red-500 to-red-600 text-white text-xs font-bold px-4 py-1.5 rounded-full shadow-md">🔥 긴급</span>' : ''} <div class="flex items-center space-x-2 ${statusConfig.bgColor} text-white px-4 py-1.5 rounded-full shadow-md">
<div class="flex items-center space-x-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 py-1.5 rounded-full shadow-md"> <div class="w-2 h-2 ${statusConfig.dotColor} rounded-full animate-pulse"></div>
<div class="w-2 h-2 bg-white rounded-full animate-pulse"></div> <span class="text-xs font-bold">${statusConfig.text}</span>
<span class="text-xs font-bold">진행 중</span> <i class="${statusConfig.icon} text-xs"></i>
<i class="fas fa-cog fa-spin text-xs"></i>
</div> </div>
<div class="bg-gray-100 px-3 py-1 rounded-full"> <div class="bg-gray-100 px-3 py-1 rounded-full">
<span class="text-xs text-gray-600 font-medium">발생: ${formatKSTDate(issue.report_date)}</span> <span class="text-xs text-gray-600 font-medium">발생: ${formatKSTDate(issue.report_date)}</span>
@@ -635,12 +682,18 @@
</div> </div>
<div class="text-purple-800 font-semibold text-sm mt-1">${issue.responsible_person || '-'}</div> <div class="text-purple-800 font-semibold text-sm mt-1">${issue.responsible_person || '-'}</div>
</div> </div>
<div class="bg-orange-50 rounded-lg p-3 border border-orange-200"> <div class="bg-orange-50 rounded-lg p-3 border border-orange-200 relative">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-orange-600 text-xs font-medium">마감시간</span> <span class="text-orange-600 text-xs font-medium">마감시간</span>
<i class="fas fa-calendar-alt text-orange-500 text-xs"></i> <i class="fas fa-calendar-alt text-orange-500 text-xs"></i>
</div> </div>
<div class="text-orange-800 font-semibold text-sm mt-1">${formatKSTDate(issue.expected_completion_date)}</div> <div class="text-orange-800 font-semibold text-sm mt-1">${formatKSTDate(issue.expected_completion_date)}</div>
${currentStatus === 'in_progress' || currentStatus === 'urgent' || currentStatus === 'overdue' ? `
<button onclick="openCompletionRequestModal(${issue.id})"
class="absolute bottom-1 right-1 bg-green-500 hover:bg-green-600 text-white text-xs px-2 py-1 rounded-full transition-colors shadow-sm">
<i class="fas fa-check text-xs mr-1"></i>완료신청
</button>
` : ''}
</div> </div>
</div> </div>
</div> </div>
@@ -775,11 +828,185 @@
console.log('✅ API 스크립트 로드 완료 (issues-dashboard.html)'); console.log('✅ API 스크립트 로드 완료 (issues-dashboard.html)');
} }
// 완료 신청 관련 함수들
let selectedCompletionIssueId = null;
let completionPhotoBase64 = null;
function openCompletionRequestModal(issueId) {
selectedCompletionIssueId = issueId;
document.getElementById('completionRequestModal').classList.remove('hidden');
// 폼 초기화
document.getElementById('completionRequestForm').reset();
document.getElementById('photoPreview').classList.add('hidden');
document.getElementById('photoUploadArea').classList.remove('hidden');
completionPhotoBase64 = null;
}
function closeCompletionRequestModal() {
selectedCompletionIssueId = null;
completionPhotoBase64 = null;
document.getElementById('completionRequestModal').classList.add('hidden');
}
function handleCompletionPhotoUpload(event) {
const file = event.target.files[0];
if (!file) return;
// 파일 크기 체크 (5MB 제한)
if (file.size > 5 * 1024 * 1024) {
alert('파일 크기는 5MB 이하여야 합니다.');
return;
}
// 이미지 파일 체크
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다.');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
completionPhotoBase64 = e.target.result;
// 미리보기 표시
document.getElementById('previewImage').src = e.target.result;
document.getElementById('photoUploadArea').classList.add('hidden');
document.getElementById('photoPreview').classList.remove('hidden');
};
reader.readAsDataURL(file);
}
// 완료 신청 폼 제출 처리
document.addEventListener('DOMContentLoaded', function() {
const completionForm = document.getElementById('completionRequestForm');
if (completionForm) {
completionForm.addEventListener('submit', async function(e) {
e.preventDefault();
if (!selectedCompletionIssueId) {
alert('선택된 이슈가 없습니다.');
return;
}
if (!completionPhotoBase64) {
alert('완료 사진을 업로드해주세요.');
return;
}
const comment = document.getElementById('completionComment').value.trim();
try {
const response = await fetch(`/api/issues/${selectedCompletionIssueId}/completion-request`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
completion_photo: completionPhotoBase64,
completion_comment: comment
})
});
if (response.ok) {
alert('완료 신청이 성공적으로 제출되었습니다.');
closeCompletionRequestModal();
// 현황판 새로고침
refreshDashboard();
} else {
const error = await response.json();
alert(`완료 신청 실패: ${error.detail || '알 수 없는 오류'}`);
}
} catch (error) {
console.error('완료 신청 오류:', error);
alert('완료 신청 중 오류가 발생했습니다.');
}
});
}
});
// API 스크립트 동적 로드 // API 스크립트 동적 로드
const script = document.createElement('script'); const script = document.createElement('script');
script.src = '/static/js/api.js?v=' + Date.now(); script.src = '/static/js/api.js?v=' + Date.now();
script.onload = initializeDashboardApp; script.onload = initializeDashboardApp;
document.body.appendChild(script); document.body.appendChild(script);
</script> </script>
<!-- 완료 신청 모달 -->
<div id="completionRequestModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<!-- 모달 헤더 -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
완료 신청
</h3>
<button onclick="closeCompletionRequestModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<form id="completionRequestForm" class="space-y-4">
<!-- 완료 사진 업로드 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-camera text-green-500 mr-1"></i>
완료 사진 (필수)
</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-green-400 transition-colors">
<input type="file" id="completionPhoto" accept="image/*" class="hidden" onchange="handleCompletionPhotoUpload(event)">
<div id="photoUploadArea" onclick="document.getElementById('completionPhoto').click()" class="cursor-pointer">
<i class="fas fa-cloud-upload-alt text-gray-400 text-2xl mb-2"></i>
<p class="text-sm text-gray-600">클릭하여 완료 사진을 업로드하세요</p>
<p class="text-xs text-gray-500 mt-1">JPG, PNG 파일만 가능</p>
</div>
<div id="photoPreview" class="hidden mt-3">
<img id="previewImage" class="max-w-full h-32 object-cover rounded-lg mx-auto">
<p class="text-sm text-green-600 mt-2">✓ 사진이 업로드되었습니다</p>
</div>
</div>
</div>
<!-- 완료 코멘트 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-comment text-blue-500 mr-1"></i>
완료 코멘트 (선택사항)
</label>
<textarea id="completionComment" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 resize-none"
placeholder="완료 상황에 대한 간단한 설명을 입력하세요..."></textarea>
</div>
<!-- 안내 메시지 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-3">
<div class="flex items-start">
<i class="fas fa-info-circle text-green-500 mt-0.5 mr-2"></i>
<div class="text-sm text-green-700">
<p class="font-medium mb-1">완료 신청 안내</p>
<p>완료 사진과 함께 신청하시면 관리자 승인 후 완료 처리됩니다.</p>
</div>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3 pt-4">
<button type="button" onclick="closeCompletionRequestModal()"
class="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-paper-plane mr-2"></i>완료 신청
</button>
</div>
</form>
</div>
</div>
</div>
</body> </body>
</html> </html>