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:
37
backend/migrations/019_add_completion_request_fields.sql
Normal file
37
backend/migrations/019_add_completion_request_fields.sql
Normal 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 $$;
|
||||
@@ -517,15 +517,63 @@
|
||||
});
|
||||
};
|
||||
|
||||
// 긴급도 체크 (예상완료일 기준)
|
||||
const isUrgent = () => {
|
||||
if (!issue.expected_completion_date) return false;
|
||||
const expectedDate = new Date(issue.expected_completion_date);
|
||||
const now = new Date();
|
||||
const diffDays = (expectedDate - now) / (1000 * 60 * 60 * 24);
|
||||
return diffDays <= 3; // 3일 이내 또는 지연
|
||||
// 상태 체크 함수들
|
||||
const getIssueStatus = () => {
|
||||
if (issue.review_status === 'completed') return 'completed';
|
||||
if (issue.completion_requested_at) return 'pending_completion'; // 완료 대기
|
||||
|
||||
if (issue.expected_completion_date) {
|
||||
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 `
|
||||
<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 class="flex flex-col items-end space-y-2 ml-4">
|
||||
<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 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 bg-white rounded-full animate-pulse"></div>
|
||||
<span class="text-xs font-bold">진행 중</span>
|
||||
<i class="fas fa-cog fa-spin text-xs"></i>
|
||||
<div class="flex items-center space-x-2 ${statusConfig.bgColor} text-white px-4 py-1.5 rounded-full shadow-md">
|
||||
<div class="w-2 h-2 ${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 class="bg-gray-100 px-3 py-1 rounded-full">
|
||||
<span class="text-xs text-gray-600 font-medium">발생: ${formatKSTDate(issue.report_date)}</span>
|
||||
@@ -635,12 +682,18 @@
|
||||
</div>
|
||||
<div class="text-purple-800 font-semibold text-sm mt-1">${issue.responsible_person || '-'}</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">
|
||||
<span class="text-orange-600 text-xs font-medium">마감시간</span>
|
||||
<i class="fas fa-calendar-alt text-orange-500 text-xs"></i>
|
||||
</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>
|
||||
@@ -775,11 +828,185 @@
|
||||
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 스크립트 동적 로드
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/api.js?v=' + Date.now();
|
||||
script.onload = initializeDashboardApp;
|
||||
document.body.appendChild(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>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user