- API URL 생성 로직에서 localhost 환경 감지 개선 - 모든 페이지에서 하드코딩된 API URL 제거 - ManagementAPI, InboxAPI 추가로 API 호출 통합 - ProjectsAPI 사용으로 프로젝트 로드 통일 - permissions.js에서 API URL 동적 생성 적용
2670 lines
129 KiB
HTML
2670 lines
129 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>
|
|
.fade-in { opacity: 0; animation: fadeIn 0.5s ease-in forwards; }
|
|
@keyframes fadeIn { to { opacity: 1; } }
|
|
|
|
.header-fade-in { opacity: 0; animation: headerFadeIn 0.6s ease-out forwards; }
|
|
@keyframes headerFadeIn { to { opacity: 1; transform: translateY(0); } from { transform: translateY(-10px); } }
|
|
|
|
.content-fade-in { opacity: 0; animation: contentFadeIn 0.7s ease-out 0.2s forwards; }
|
|
@keyframes contentFadeIn { to { opacity: 1; transform: translateY(0); } from { transform: translateY(20px); } }
|
|
|
|
/* 대시보드 카드 스타일 */
|
|
.dashboard-card {
|
|
transition: all 0.3s ease;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
}
|
|
|
|
.dashboard-card:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
/* 이슈 카드 스타일 (세련된 모던 스타일) */
|
|
.issue-card {
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
border-left: 4px solid transparent;
|
|
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
|
}
|
|
|
|
.issue-card:hover {
|
|
transform: translateY(-8px) scale(1.02);
|
|
border-left-color: #3b82f6;
|
|
box-shadow:
|
|
0 25px 50px -12px rgba(0, 0, 0, 0.15),
|
|
0 0 0 1px rgba(59, 130, 246, 0.1),
|
|
0 0 20px rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
.issue-card label {
|
|
font-weight: 600;
|
|
color: #374151;
|
|
}
|
|
|
|
.issue-card .bg-gray-50 {
|
|
background-color: #f9fafb;
|
|
border: 1px solid #e5e7eb;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.issue-card .bg-gray-50:hover {
|
|
background-color: #f3f4f6;
|
|
}
|
|
|
|
.issue-card .fas.fa-image:hover {
|
|
transform: scale(1.2);
|
|
color: #3b82f6;
|
|
}
|
|
|
|
/* 진행 중 애니메이션 */
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.animate-pulse {
|
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
}
|
|
|
|
/* 날짜 그룹 스타일 */
|
|
.date-group {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.date-header {
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.date-header:hover {
|
|
background-color: #f3f4f6 !important;
|
|
}
|
|
|
|
.collapse-content {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.progress-bar {
|
|
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
|
|
transition: width 0.8s ease;
|
|
}
|
|
|
|
/* 반응형 그리드 */
|
|
.dashboard-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 1.5rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-50 min-h-screen">
|
|
<!-- 로딩 스크린 -->
|
|
<div id="loadingScreen" class="fixed inset-0 bg-white z-50 flex items-center justify-center">
|
|
<div class="text-center">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p class="text-gray-600">현황판을 불러오는 중...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 로그인 스크린 -->
|
|
<div id="loginScreen" class="hidden fixed inset-0 bg-gray-100 z-40 flex items-center justify-center">
|
|
<div class="bg-white p-8 rounded-xl shadow-lg max-w-md w-full mx-4">
|
|
<h2 class="text-2xl font-bold text-center mb-6 text-gray-800">로그인</h2>
|
|
<form id="loginForm">
|
|
<div class="mb-4">
|
|
<input type="text" id="username" placeholder="사용자명" required
|
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
</div>
|
|
<div class="mb-6">
|
|
<input type="password" id="password" placeholder="비밀번호" required
|
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
</div>
|
|
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors">
|
|
로그인
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 메인 콘텐츠 -->
|
|
<div id="mainContent" class="min-h-screen">
|
|
<!-- 공통 헤더 -->
|
|
<div id="commonHeader"></div>
|
|
|
|
<!-- 메인 콘텐츠 -->
|
|
<main class="container mx-auto px-4 py-8 content-fade-in" style="padding-top: 80px;">
|
|
<!-- 페이지 헤더 -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
|
<i class="fas fa-chart-line text-blue-500 mr-3"></i>
|
|
부적합 현황판
|
|
</h1>
|
|
<p class="text-gray-600 mt-1">진행 중인 부적합 사항을 프로젝트별로 한눈에 확인하세요</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 전체 통계 대시보드 -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
|
<div class="dashboard-card text-white p-6 rounded-xl">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-blue-100 text-sm flex items-center space-x-1">
|
|
<span>전체 진행 중</span>
|
|
<div class="w-1.5 h-1.5 bg-blue-200 rounded-full animate-pulse"></div>
|
|
</p>
|
|
<p class="text-3xl font-bold" id="totalInProgress">0</p>
|
|
</div>
|
|
<i class="fas fa-tasks text-4xl text-blue-200"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-gradient-to-br from-green-400 to-green-600 text-white p-6 rounded-xl dashboard-card">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-green-100 text-sm flex items-center space-x-1">
|
|
<span>오늘 신규</span>
|
|
<div class="w-1.5 h-1.5 bg-green-200 rounded-full animate-pulse"></div>
|
|
</p>
|
|
<p class="text-3xl font-bold" id="todayNew">0</p>
|
|
</div>
|
|
<i class="fas fa-plus-circle text-4xl text-green-200"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-gradient-to-br from-purple-400 to-purple-600 text-white p-6 rounded-xl dashboard-card">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-purple-100 text-sm flex items-center space-x-1">
|
|
<span>완료 대기</span>
|
|
<div class="w-1.5 h-1.5 bg-purple-200 rounded-full animate-pulse"></div>
|
|
</p>
|
|
<p class="text-3xl font-bold" id="pendingCompletion">0</p>
|
|
</div>
|
|
<i class="fas fa-hourglass-half text-4xl text-purple-200"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-gradient-to-br from-red-400 to-red-600 text-white p-6 rounded-xl dashboard-card">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-red-100 text-sm flex items-center space-x-1">
|
|
<span>지연 중</span>
|
|
<div class="w-1.5 h-1.5 bg-red-200 rounded-full animate-pulse"></div>
|
|
</p>
|
|
<p class="text-3xl font-bold" id="overdue">0</p>
|
|
</div>
|
|
<i class="fas fa-clock text-4xl text-red-200"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 프로젝트 선택 및 필터 -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
|
|
<div class="flex items-center space-x-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">프로젝트 선택</label>
|
|
<select id="projectFilter" class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-[200px]" onchange="filterByProject()">
|
|
<option value="">전체 프로젝트</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<button onclick="refreshDashboard()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
|
<i class="fas fa-sync-alt mr-2"></i>새로고침
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 프로젝트별 현황 -->
|
|
<div id="projectDashboard" class="space-y-6">
|
|
<!-- 동적으로 생성될 프로젝트 카드들 -->
|
|
</div>
|
|
|
|
<!-- 빈 상태 -->
|
|
<div id="emptyState" class="hidden text-center py-12">
|
|
<i class="fas fa-chart-line text-6xl text-gray-300 mb-4"></i>
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">진행 중인 부적합이 없습니다</h3>
|
|
<p class="text-gray-500">새로운 부적합이 등록되면 여기에 표시됩니다.</p>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- 스크립트 -->
|
|
<script src="/static/js/core/permissions.js?v=1.1"></script>
|
|
<script src="/static/js/components/common-header.js?v=1.1"></script>
|
|
<script src="/static/js/core/page-manager.js?v=1.1"></script>
|
|
<script src="/static/js/core/auth-manager.js?v=1.1"></script>
|
|
|
|
<script>
|
|
let currentUser = null;
|
|
let allIssues = [];
|
|
let projects = [];
|
|
let filteredIssues = [];
|
|
|
|
// 애니메이션 함수들
|
|
function animateHeaderAppearance() {
|
|
const header = document.getElementById('commonHeader');
|
|
if (header) {
|
|
header.classList.add('header-fade-in');
|
|
}
|
|
}
|
|
|
|
function animateContentAppearance() {
|
|
const content = document.querySelector('.content-fade-in');
|
|
if (content) {
|
|
content.classList.add('visible');
|
|
}
|
|
}
|
|
|
|
// 페이지 초기화
|
|
async function initializeDashboard() {
|
|
try {
|
|
// 인증 확인
|
|
currentUser = await window.authManager.checkAuth();
|
|
if (!currentUser) {
|
|
showLoginScreen();
|
|
return;
|
|
}
|
|
|
|
// 페이지 권한 확인
|
|
window.pagePermissionManager.setUser(currentUser);
|
|
await window.pagePermissionManager.loadPagePermissions();
|
|
|
|
if (!window.pagePermissionManager.canAccessPage('issues_dashboard')) {
|
|
alert('현황판 접근 권한이 없습니다.');
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
// 공통 헤더 초기화
|
|
if (window.commonHeader) {
|
|
await window.commonHeader.init(currentUser, 'issues_dashboard');
|
|
setTimeout(() => animateHeaderAppearance(), 100);
|
|
}
|
|
|
|
// 데이터 로드
|
|
await Promise.all([
|
|
loadProjects(),
|
|
loadInProgressIssues()
|
|
]);
|
|
|
|
updateDashboard();
|
|
hideLoadingScreen();
|
|
|
|
} catch (error) {
|
|
console.error('대시보드 초기화 실패:', error);
|
|
alert('대시보드를 불러오는데 실패했습니다.');
|
|
hideLoadingScreen();
|
|
}
|
|
}
|
|
|
|
// 로딩 스크린 관리
|
|
function hideLoadingScreen() {
|
|
document.getElementById('loadingScreen').style.display = 'none';
|
|
}
|
|
|
|
function showLoginScreen() {
|
|
document.getElementById('loadingScreen').style.display = 'none';
|
|
document.getElementById('loginScreen').classList.remove('hidden');
|
|
}
|
|
|
|
// 데이터 로드 함수들
|
|
async function loadProjects() {
|
|
try {
|
|
const apiUrl = window.API_BASE_URL || (() => {
|
|
const hostname = window.location.hostname;
|
|
if (hostname === 'm.hyungi.net') {
|
|
return 'https://m-api.hyungi.net/api';
|
|
}
|
|
return '/api';
|
|
})();
|
|
// ProjectsAPI 사용
|
|
projects = await ProjectsAPI.getAll(false);
|
|
updateProjectFilter();
|
|
} catch (error) {
|
|
console.error('프로젝트 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
async function loadInProgressIssues() {
|
|
try {
|
|
const apiUrl = window.API_BASE_URL || (() => {
|
|
const hostname = window.location.hostname;
|
|
if (hostname === 'm.hyungi.net') {
|
|
return 'https://m-api.hyungi.net/api';
|
|
}
|
|
return '/api';
|
|
})();
|
|
// ManagementAPI 사용하여 관리함 이슈 로드
|
|
const managementData = await ManagementAPI.getAll();
|
|
// 진행 중 상태만 필터링
|
|
allIssues = managementData.filter(issue => issue.review_status === 'in_progress');
|
|
filteredIssues = [...allIssues];
|
|
} catch (error) {
|
|
console.error('부적합 로드 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 프로젝트 필터 업데이트
|
|
function updateProjectFilter() {
|
|
const projectFilter = document.getElementById('projectFilter');
|
|
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
|
|
|
|
projects.forEach(project => {
|
|
const option = document.createElement('option');
|
|
option.value = project.id;
|
|
option.textContent = project.project_name;
|
|
projectFilter.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// 대시보드 업데이트
|
|
function updateDashboard() {
|
|
updateStatistics();
|
|
updateProjectCards();
|
|
}
|
|
|
|
// 통계 업데이트
|
|
function updateStatistics() {
|
|
const today = new Date().toDateString();
|
|
|
|
// 오늘 신규 (오늘 수신함에서 진행중으로 넘어온 것들)
|
|
const todayIssues = allIssues.filter(issue =>
|
|
issue.reviewed_at && new Date(issue.reviewed_at).toDateString() === today
|
|
);
|
|
|
|
// 완료 대기 (완료 신청이 된 것들)
|
|
const pendingCompletionIssues = allIssues.filter(issue =>
|
|
issue.completion_requested_at && issue.review_status === 'in_progress'
|
|
);
|
|
|
|
// 지연 중 (마감일이 지난 것들)
|
|
const overdueIssues = allIssues.filter(issue => {
|
|
if (!issue.expected_completion_date) return false;
|
|
const expectedDate = new Date(issue.expected_completion_date);
|
|
const now = new Date();
|
|
return expectedDate < now; // 마감일 지남
|
|
});
|
|
|
|
document.getElementById('totalInProgress').textContent = allIssues.length;
|
|
document.getElementById('todayNew').textContent = todayIssues.length;
|
|
document.getElementById('pendingCompletion').textContent = pendingCompletionIssues.length;
|
|
document.getElementById('overdue').textContent = overdueIssues.length;
|
|
}
|
|
|
|
// 이슈 카드 업데이트 (관리함 스타일 - 날짜별 그룹화)
|
|
function updateProjectCards() {
|
|
const container = document.getElementById('projectDashboard');
|
|
const emptyState = document.getElementById('emptyState');
|
|
|
|
if (filteredIssues.length === 0) {
|
|
container.innerHTML = '';
|
|
emptyState.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
emptyState.classList.add('hidden');
|
|
|
|
// 날짜별로 그룹화 (관리함 진입일 기준)
|
|
const groupedByDate = {};
|
|
const dateObjects = {}; // 정렬용 Date 객체 저장
|
|
|
|
filteredIssues.forEach(issue => {
|
|
// reviewed_at이 있으면 관리함 진입일, 없으면 report_date 사용
|
|
const dateToUse = issue.reviewed_at || issue.report_date;
|
|
const dateObj = new Date(dateToUse);
|
|
const dateKey = dateObj.toLocaleDateString('ko-KR');
|
|
|
|
if (!groupedByDate[dateKey]) {
|
|
groupedByDate[dateKey] = [];
|
|
dateObjects[dateKey] = dateObj;
|
|
}
|
|
groupedByDate[dateKey].push(issue);
|
|
});
|
|
|
|
// 날짜별 그룹 생성
|
|
const dateGroups = Object.keys(groupedByDate)
|
|
.sort((a, b) => dateObjects[b] - dateObjects[a]) // 최신순
|
|
.map(dateKey => {
|
|
const issues = groupedByDate[dateKey];
|
|
const formattedDate = dateObjects[dateKey].toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit'
|
|
}).replace(/\./g, '. ').trim();
|
|
|
|
return `
|
|
<div class="date-group mb-6">
|
|
<div class="date-header flex items-center justify-between p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors" onclick="toggleDateGroup('${dateKey}')">
|
|
<div class="flex items-center space-x-3">
|
|
<i class="fas fa-chevron-down transition-transform duration-200" id="chevron-${dateKey}"></i>
|
|
<span class="font-semibold text-gray-800">${formattedDate}</span>
|
|
<span class="text-sm text-gray-500">(${issues.length}건)</span>
|
|
<span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">관리함 진입일</span>
|
|
</div>
|
|
</div>
|
|
<div class="collapse-content mt-4" id="content-${dateKey}">
|
|
<div class="grid grid-cols-1 gap-4">
|
|
${issues.map(issue => createIssueCard(issue)).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
container.innerHTML = dateGroups;
|
|
}
|
|
|
|
// 부적합명 추출 (첫 번째 줄)
|
|
function getIssueTitle(issue) {
|
|
const description = issue.description || issue.final_description || '';
|
|
const lines = description.split('\n');
|
|
return lines[0] || '부적합명 없음';
|
|
}
|
|
|
|
// 상세 내용 추출 (두 번째 줄부터)
|
|
function getIssueDetail(issue) {
|
|
const description = issue.description || issue.final_description || '';
|
|
const lines = description.split('\n');
|
|
return lines.slice(1).join('\n') || '상세 내용 없음';
|
|
}
|
|
|
|
// 이슈 카드 생성 (관리함 진행 중 스타일, 읽기 전용)
|
|
function createIssueCard(issue) {
|
|
const project = projects.find(p => p.id === issue.project_id);
|
|
const projectName = project ? project.project_name : '미지정';
|
|
|
|
// 부서 텍스트 변환
|
|
const getDepartmentText = (dept) => {
|
|
const deptMap = {
|
|
'production': '생산',
|
|
'quality': '품질',
|
|
'purchasing': '구매',
|
|
'design': '설계',
|
|
'sales': '영업'
|
|
};
|
|
return dept ? deptMap[dept] || dept : '-';
|
|
};
|
|
|
|
// 카테고리 텍스트 변환
|
|
const getCategoryText = (category) => {
|
|
const categoryMap = {
|
|
'material_missing': '자재 누락',
|
|
'design_error': '설계 오류',
|
|
'incoming_defect': '반입 불량',
|
|
'inspection_miss': '검사 누락',
|
|
'etc': '기타'
|
|
};
|
|
return category ? categoryMap[category] || category : '-';
|
|
};
|
|
|
|
// 완료 반려 내용 포맷팅
|
|
const formatRejectionContent = (issue) => {
|
|
// 1. 새 필드에서 확인
|
|
if (issue.completion_rejection_reason) {
|
|
const rejectedAt = issue.completion_rejected_at
|
|
? new Date(issue.completion_rejected_at).toLocaleString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
: '';
|
|
|
|
return rejectedAt
|
|
? `[${rejectedAt}] ${issue.completion_rejection_reason}`
|
|
: issue.completion_rejection_reason;
|
|
}
|
|
|
|
// 2. 기존 데이터에서 패턴 추출 (마이그레이션 전 데이터용)
|
|
if (issue.management_comment) {
|
|
const rejectionPattern = /\[완료 반려[^\]]*\][^\n]*/g;
|
|
const rejections = issue.management_comment.match(rejectionPattern);
|
|
return rejections ? rejections.join('\n') : '';
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
// solution 파싱 및 카드 형식으로 표시
|
|
const parseSolutionOpinions = (solution, issue) => {
|
|
let html = '';
|
|
|
|
// 1. 수신함/관리함 내용 표시 - 항상 표시
|
|
let rawManagementContent = issue.management_comment || issue.final_description || '';
|
|
|
|
// 기존 데이터에서 완료 반려 패턴 제거 (마이그레이션용)
|
|
rawManagementContent = rawManagementContent.replace(/\[완료 반려[^\]]*\][^\n]*\n*/g, '').trim();
|
|
|
|
// 기본 텍스트들 필터링 (카테고리 이름, "상세 내용 없음" 등)
|
|
const defaultTexts = [
|
|
'중복작업 신고용',
|
|
'상세 내용 없음',
|
|
'자재 누락',
|
|
'설계 오류',
|
|
'반입 불량',
|
|
'검사 누락',
|
|
'기타',
|
|
'부적합명',
|
|
'상세내용',
|
|
'상세 내용'
|
|
];
|
|
|
|
// 줄바꿈으로 나눠서 기본 텍스트가 아닌 실제 내용만 추출
|
|
const filteredLines = rawManagementContent.split('\n').filter(line => {
|
|
const trimmed = line.trim();
|
|
// 빈 줄이거나 기본 텍스트인 경우 제외
|
|
if (!trimmed) return false;
|
|
// 기본 텍스트와 정확히 일치하면 제외
|
|
if (defaultTexts.includes(trimmed)) return false;
|
|
return true;
|
|
});
|
|
|
|
const managementContent = filteredLines.join('\n').trim();
|
|
|
|
const displayContent = managementContent ? managementContent : '확정된 해결 방안 없음';
|
|
const contentStyle = managementContent ? 'text-red-700' : 'text-red-400 italic';
|
|
|
|
html += `
|
|
<div class="mb-2 bg-gradient-to-r from-red-50 to-pink-50 border-red-300 rounded-lg border-l-4 p-2 shadow-sm">
|
|
<div class="text-sm ${contentStyle} leading-relaxed px-2 whitespace-pre-wrap">
|
|
${displayContent}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 2. 해결 방안 의견들 표시
|
|
if (!solution || solution.trim() === '') {
|
|
return html;
|
|
}
|
|
|
|
// 구분선으로 의견들을 분리
|
|
const opinions = solution.split(/─{30,}/);
|
|
|
|
html += opinions.map((opinion, opinionIndex) => {
|
|
const trimmed = opinion.trim();
|
|
if (!trimmed) return '';
|
|
|
|
// [작성자] (날짜시간) 패턴 매칭
|
|
const headerMatch = trimmed.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
|
|
|
|
if (headerMatch) {
|
|
const author = headerMatch[1];
|
|
const datetime = headerMatch[2];
|
|
|
|
// 댓글과 본문 분리 (들여쓰기로 시작하는 줄은 댓글/대댓글)
|
|
const lines = trimmed.substring(headerMatch[0].length).trim().split('\n');
|
|
let mainContent = '';
|
|
let comments = [];
|
|
let currentCommentIndex = -1;
|
|
|
|
for (const line of lines) {
|
|
// 댓글 (└ 기호)
|
|
if (line.match(/^\s*└/)) {
|
|
const commentMatch = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
|
if (commentMatch) {
|
|
comments.push({
|
|
author: commentMatch[1],
|
|
datetime: commentMatch[2],
|
|
content: commentMatch[3],
|
|
replies: []
|
|
});
|
|
currentCommentIndex = comments.length - 1;
|
|
}
|
|
}
|
|
// 대댓글 (↳ 기호)
|
|
else if (line.match(/^\s*↳/)) {
|
|
const replyMatch = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
|
if (replyMatch && currentCommentIndex >= 0) {
|
|
comments[currentCommentIndex].replies.push({
|
|
author: replyMatch[1],
|
|
datetime: replyMatch[2],
|
|
content: replyMatch[3]
|
|
});
|
|
}
|
|
} else {
|
|
mainContent += (mainContent ? '\n' : '') + line;
|
|
}
|
|
}
|
|
|
|
// 색상 스킴 (최신 의견일수록 진한 색)
|
|
const colorSchemes = [
|
|
'bg-gradient-to-r from-green-50 to-emerald-50 border-green-300',
|
|
'bg-gradient-to-r from-blue-50 to-cyan-50 border-blue-300',
|
|
'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-300',
|
|
'bg-gradient-to-r from-orange-50 to-yellow-50 border-orange-300',
|
|
'bg-gradient-to-r from-indigo-50 to-violet-50 border-indigo-300'
|
|
];
|
|
const colorScheme = colorSchemes[opinionIndex % colorSchemes.length];
|
|
|
|
// 본인 의견인지 확인
|
|
const isOwnOpinion = currentUser && (author === currentUser.full_name || author === currentUser.username);
|
|
|
|
return `
|
|
<div class="mb-1.5 ${colorScheme} rounded-lg border-l-4 p-2 shadow-sm hover:shadow-md transition-shadow">
|
|
<div class="flex items-start justify-between mb-1">
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-5 h-5 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white text-xs font-bold shadow">
|
|
${author.charAt(0)}
|
|
</div>
|
|
<div>
|
|
<span class="text-xs font-semibold text-gray-800">${author}</span>
|
|
<div class="flex items-center gap-1 text-xs text-gray-500">
|
|
<i class="fas fa-clock text-xs"></i>
|
|
<span class="text-xs">${datetime}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<button onclick="openCommentModal(${issue.id}, ${opinionIndex})"
|
|
class="bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded text-xs transition-colors flex items-center gap-1"
|
|
title="댓글 달기">
|
|
<i class="fas fa-comment text-xs"></i>
|
|
<span>댓글</span>
|
|
</button>
|
|
${isOwnOpinion ? `
|
|
<button onclick="editOpinion(${issue.id}, ${opinionIndex})"
|
|
class="bg-green-100 hover:bg-green-200 text-green-700 px-2 py-1 rounded text-xs transition-colors flex items-center gap-1"
|
|
title="수정">
|
|
<i class="fas fa-edit text-xs"></i>
|
|
<span>수정</span>
|
|
</button>
|
|
<button onclick="deleteOpinion(${issue.id}, ${opinionIndex})"
|
|
class="bg-red-100 hover:bg-red-200 text-red-700 px-2 py-1 rounded text-xs transition-colors flex items-center gap-1"
|
|
title="삭제">
|
|
<i class="fas fa-trash text-xs"></i>
|
|
<span>삭제</span>
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="text-sm text-gray-700 leading-relaxed pl-7 whitespace-pre-wrap">${mainContent}</div>
|
|
|
|
${comments.length > 0 ? `
|
|
<div class="mt-2 pl-7">
|
|
<button onclick="toggleComments(${issue.id}, ${opinionIndex})"
|
|
class="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1.5 rounded-lg text-xs flex items-center gap-2 transition-colors">
|
|
<i class="fas fa-chevron-down transition-transform text-xs" id="comment-chevron-${issue.id}-${opinionIndex}"></i>
|
|
<i class="fas fa-comments text-xs"></i>
|
|
<span class="font-medium">댓글 ${comments.length}개 보기</span>
|
|
</button>
|
|
<div id="comments-${issue.id}-${opinionIndex}" class="hidden mt-2 space-y-1.5">
|
|
${comments.map((comment, commentIndex) => {
|
|
const isOwnComment = currentUser && (comment.author === currentUser.full_name || comment.author === currentUser.username);
|
|
return `
|
|
<div class="bg-white bg-opacity-80 rounded-lg p-2 text-xs border border-gray-200">
|
|
<div class="flex items-start justify-between mb-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<div class="w-4 h-4 bg-gray-400 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
|
${comment.author.charAt(0)}
|
|
</div>
|
|
<span class="font-semibold text-gray-800">${comment.author}</span>
|
|
<span class="text-gray-400">·</span>
|
|
<span class="text-gray-500">${comment.datetime}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<button onclick="openReplyModal(${issue.id}, ${opinionIndex}, ${commentIndex})"
|
|
class="bg-blue-50 hover:bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded text-xs transition-colors flex items-center gap-0.5"
|
|
title="답글">
|
|
<i class="fas fa-reply text-xs"></i>
|
|
<span>답글</span>
|
|
</button>
|
|
${isOwnComment ? `
|
|
<button onclick="editComment(${issue.id}, ${opinionIndex}, ${commentIndex})"
|
|
class="bg-green-50 hover:bg-green-100 text-green-600 px-1.5 py-0.5 rounded text-xs transition-colors"
|
|
title="수정">
|
|
<i class="fas fa-edit text-xs"></i>
|
|
</button>
|
|
<button onclick="deleteComment(${issue.id}, ${opinionIndex}, ${commentIndex})"
|
|
class="bg-red-50 hover:bg-red-100 text-red-600 px-1.5 py-0.5 rounded text-xs transition-colors"
|
|
title="삭제">
|
|
<i class="fas fa-trash text-xs"></i>
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="text-gray-700 pl-5">${comment.content}</div>
|
|
|
|
${comment.replies && comment.replies.length > 0 ? `
|
|
<div class="mt-2 ml-4 space-y-1 border-l-2 border-blue-200 pl-2">
|
|
${comment.replies.map((reply, replyIndex) => {
|
|
const isOwnReply = currentUser && (reply.author === currentUser.full_name || reply.author === currentUser.username);
|
|
return `
|
|
<div class="bg-blue-50 bg-opacity-50 rounded p-1.5 text-xs">
|
|
<div class="flex items-start justify-between mb-0.5">
|
|
<div class="flex items-center gap-1">
|
|
<i class="fas fa-reply text-blue-400 text-xs"></i>
|
|
<div class="w-3 h-3 bg-blue-400 rounded-full flex items-center justify-center text-white" style="font-size: 8px;">
|
|
${reply.author.charAt(0)}
|
|
</div>
|
|
<span class="font-semibold text-gray-800">${reply.author}</span>
|
|
<span class="text-gray-400">·</span>
|
|
<span class="text-gray-500">${reply.datetime}</span>
|
|
</div>
|
|
${isOwnReply ? `
|
|
<div class="flex items-center gap-0.5">
|
|
<button onclick="editReply(${issue.id}, ${opinionIndex}, ${commentIndex}, ${replyIndex})"
|
|
class="bg-green-50 hover:bg-green-100 text-green-600 px-1 py-0.5 rounded text-xs transition-colors"
|
|
title="수정">
|
|
<i class="fas fa-edit" style="font-size: 9px;"></i>
|
|
</button>
|
|
<button onclick="deleteReply(${issue.id}, ${opinionIndex}, ${commentIndex}, ${replyIndex})"
|
|
class="bg-red-50 hover:bg-red-100 text-red-600 px-1 py-0.5 rounded text-xs transition-colors"
|
|
title="삭제">
|
|
<i class="fas fa-trash" style="font-size: 9px;"></i>
|
|
</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<div class="text-gray-600 pl-4">${reply.content}</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
} else {
|
|
// 형식이 맞지 않는 경우 그냥 텍스트로 표시
|
|
return `
|
|
<div class="mb-1.5 bg-gray-50 rounded-lg border-l-4 border-gray-300 p-2">
|
|
<div class="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">${trimmed}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}).join('');
|
|
|
|
return html;
|
|
};
|
|
|
|
// 날짜 포맷팅
|
|
const formatKSTDate = (dateStr) => {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit'
|
|
});
|
|
};
|
|
|
|
// 상태 체크 함수들
|
|
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">
|
|
<!-- 헤더 -->
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div class="flex flex-col space-y-2 flex-1">
|
|
<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 shadow-sm"></div>
|
|
<span class="text-sm text-gray-600">${projectName}</span>
|
|
</div>
|
|
<div class="bg-blue-50 px-3 py-1.5 rounded-lg border-l-4 border-blue-400">
|
|
<h3 class="text-base font-bold text-blue-900">${getIssueTitle(issue)}</h3>
|
|
</div>
|
|
</div>
|
|
<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-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>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<span class="inline-flex items-center bg-gradient-to-r from-yellow-400 to-orange-400 text-white px-2 py-1 rounded-full text-xs font-medium shadow-sm">
|
|
<i class="fas fa-tag mr-1"></i>
|
|
${getCategoryText(issue.category || issue.final_category)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 2x2 그리드 -->
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<!-- 첫 번째 행: 상세 내용 | 신고자/담당/마감 -->
|
|
<div>
|
|
<div class="flex items-center mb-2">
|
|
<i class="fas fa-align-left text-gray-500 mr-2"></i>
|
|
<span class="text-gray-600 font-medium text-sm">상세 내용</span>
|
|
</div>
|
|
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200 min-h-[80px] max-h-[120px] overflow-y-auto">
|
|
<div class="text-gray-600 text-sm leading-relaxed italic">
|
|
${getIssueDetail(issue)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center mb-2">
|
|
<i class="fas fa-user-clock text-purple-500 mr-2"></i>
|
|
<span class="text-gray-600 font-medium text-sm">신고자 & 담당 & 마감</span>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<div class="bg-blue-50 rounded-lg p-2 border border-blue-200">
|
|
<div class="flex items-center justify-center mb-1">
|
|
<i class="fas fa-user-edit text-blue-500 text-xs mr-1"></i>
|
|
<span class="text-blue-600 text-xs font-medium">신고자</span>
|
|
</div>
|
|
<div class="text-blue-800 font-semibold text-xs text-center">${issue.reporter?.full_name || issue.reporter?.username || '-'}</div>
|
|
</div>
|
|
<div class="bg-purple-50 rounded-lg p-2 border border-purple-200">
|
|
<div class="flex items-center justify-center mb-1">
|
|
<i class="fas fa-user text-purple-500 text-xs mr-1"></i>
|
|
<span class="text-purple-600 text-xs font-medium">담당자</span>
|
|
</div>
|
|
<div class="text-purple-800 font-semibold text-xs text-center">${issue.responsible_person || '-'}</div>
|
|
</div>
|
|
<div class="bg-orange-50 rounded-lg p-2 border border-orange-200 relative">
|
|
<div class="flex items-center justify-center mb-1">
|
|
<i class="fas fa-calendar-alt text-orange-500 text-xs mr-1"></i>
|
|
<span class="text-orange-600 text-xs font-medium">마감</span>
|
|
</div>
|
|
<div class="text-orange-800 font-semibold text-xs text-center">${formatKSTDate(issue.expected_completion_date)}</div>
|
|
</div>
|
|
</div>
|
|
${currentStatus === 'in_progress' || currentStatus === 'urgent' || currentStatus === 'overdue' ? `
|
|
<div class="flex justify-end">
|
|
<button onclick="openCompletionRequestModal(${issue.id})"
|
|
class="mt-2 bg-green-500 hover:bg-green-600 text-white text-xs px-4 py-1.5 rounded-lg transition-colors shadow-sm">
|
|
<i class="fas fa-check text-xs mr-1"></i>완료신청
|
|
</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
|
|
<!-- 두 번째 행: 해결 방안 | 이미지 -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center">
|
|
<i class="fas fa-lightbulb text-green-500 mr-2"></i>
|
|
<span class="text-gray-600 font-medium text-sm">해결 방안</span>
|
|
</div>
|
|
<button onclick="openOpinionModal(${issue.id})" class="bg-green-500 hover:bg-green-600 text-white text-xs px-3 py-1 rounded-full transition-colors shadow-sm">
|
|
<i class="fas fa-comment-medical text-xs mr-1"></i>의견 제시
|
|
</button>
|
|
</div>
|
|
<div class="bg-white rounded-lg p-3 border border-green-200 min-h-[180px] max-h-[280px] overflow-y-auto">
|
|
${parseSolutionOpinions(issue.solution, issue)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center mb-2">
|
|
<i class="fas fa-camera text-blue-500 mr-2"></i>
|
|
<span class="text-gray-600 font-medium text-sm">이미지</span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-3 justify-center">
|
|
${(() => {
|
|
const photos = [
|
|
issue.photo_path,
|
|
issue.photo_path2,
|
|
issue.photo_path3,
|
|
issue.photo_path4,
|
|
issue.photo_path5
|
|
].filter(p => p);
|
|
|
|
if (photos.length === 0) {
|
|
return `
|
|
<div class="w-40 h-40 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-gray-200 flex items-center justify-center">
|
|
<i class="fas fa-image text-gray-400 text-2xl opacity-50"></i>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return photos.map((path, idx) => `
|
|
<div class="relative w-40 h-40 rounded-lg border-2 border-blue-200 overflow-hidden cursor-pointer hover:shadow-lg hover:scale-105 transition-all duration-200" onclick="openPhotoModal('${path}')">
|
|
<img src="${path}" alt="부적합 사진 ${idx + 1}" class="w-full h-full object-cover" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
|
<div class="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center" style="display: none;">
|
|
<i class="fas fa-image text-blue-500 text-2xl"></i>
|
|
</div>
|
|
<div class="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white"></div>
|
|
</div>
|
|
`).join('');
|
|
})()}
|
|
</div>
|
|
|
|
<!-- 완료 반려 내용 표시 -->
|
|
${(() => {
|
|
const rejection = formatRejectionContent(issue);
|
|
if (!rejection) return '';
|
|
|
|
return `
|
|
<div class="mt-2 bg-gradient-to-r from-orange-50 to-red-50 border-orange-400 rounded-lg border-l-4 p-2 shadow-sm">
|
|
<div class="flex items-center mb-1">
|
|
<div class="w-5 h-5 bg-gradient-to-br from-orange-500 to-red-600 rounded-full flex items-center justify-center text-white text-xs font-bold shadow">
|
|
<i class="fas fa-exclamation-triangle text-xs"></i>
|
|
</div>
|
|
<span class="text-xs font-semibold text-orange-800 ml-2">완료 반려 내역</span>
|
|
</div>
|
|
<div class="text-sm text-orange-700 leading-relaxed pl-7 whitespace-pre-wrap">${rejection}</div>
|
|
</div>
|
|
`;
|
|
})()}
|
|
|
|
${currentStatus === 'pending_completion' ? `
|
|
<div class="mt-2 flex justify-center">
|
|
<button onclick="openRejectionModal(${issue.id})"
|
|
class="bg-red-500 hover:bg-red-600 text-white text-xs px-4 py-1.5 rounded-lg transition-colors shadow-sm">
|
|
<i class="fas fa-times-circle text-xs mr-1"></i>완료 신청 반려
|
|
</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 사진 모달 열기
|
|
function openPhotoModal(photoPath) {
|
|
let modal = document.getElementById('photoModal');
|
|
|
|
if (!modal) {
|
|
// 모달이 없으면 생성
|
|
const modalHTML = `
|
|
<div id="photoModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
|
<div class="relative max-w-4xl max-h-full p-4">
|
|
<img id="modalPhoto" src="" alt="부적합 사진" class="max-w-full max-h-full object-contain rounded-lg">
|
|
<button onclick="closePhotoModal()" class="absolute top-4 right-4 text-white text-2xl hover:text-gray-300">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
|
modal = document.getElementById('photoModal');
|
|
}
|
|
|
|
document.getElementById('modalPhoto').src = photoPath;
|
|
modal.classList.remove('hidden');
|
|
}
|
|
|
|
// 사진 모달 닫기
|
|
function closePhotoModal() {
|
|
const modal = document.getElementById('photoModal');
|
|
if (modal) {
|
|
modal.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// ESC 키로 모달 닫기
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
closePhotoModal();
|
|
closeRejectionModal();
|
|
closeOpinionModal();
|
|
closeCompletionRequestModal();
|
|
closeCommentModal();
|
|
closeEditOpinionModal();
|
|
closeReplyModal();
|
|
closeEditCommentModal();
|
|
closeEditReplyModal();
|
|
}
|
|
});
|
|
|
|
// 날짜 그룹 토글 기능
|
|
function toggleDateGroup(dateKey) {
|
|
const content = document.getElementById(`content-${dateKey}`);
|
|
const chevron = document.getElementById(`chevron-${dateKey}`);
|
|
|
|
if (content.style.display === 'none') {
|
|
content.style.display = 'block';
|
|
chevron.classList.remove('fa-chevron-right');
|
|
chevron.classList.add('fa-chevron-down');
|
|
} else {
|
|
content.style.display = 'none';
|
|
chevron.classList.remove('fa-chevron-down');
|
|
chevron.classList.add('fa-chevron-right');
|
|
}
|
|
}
|
|
|
|
// 필터 및 정렬 함수들
|
|
function filterByProject() {
|
|
const projectId = document.getElementById('projectFilter').value;
|
|
|
|
if (projectId) {
|
|
filteredIssues = allIssues.filter(issue => issue.project_id == projectId);
|
|
} else {
|
|
filteredIssues = [...allIssues];
|
|
}
|
|
|
|
updateProjectCards();
|
|
}
|
|
|
|
|
|
// 유틸리티 함수들
|
|
function getDepartmentText(department) {
|
|
const departments = {
|
|
'production': '생산',
|
|
'quality': '품질',
|
|
'purchasing': '구매',
|
|
'design': '설계',
|
|
'sales': '영업'
|
|
};
|
|
return departments[department] || department;
|
|
}
|
|
|
|
function viewIssueDetail(issueId) {
|
|
window.location.href = `/issues-management.html#issue-${issueId}`;
|
|
}
|
|
|
|
function refreshDashboard() {
|
|
location.reload();
|
|
}
|
|
|
|
// 로그인 처리
|
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById('username').value;
|
|
const password = document.getElementById('password').value;
|
|
|
|
try {
|
|
const user = await window.authManager.login(username, password);
|
|
if (user) {
|
|
document.getElementById('loginScreen').classList.add('hidden');
|
|
await initializeDashboard();
|
|
}
|
|
} catch (error) {
|
|
alert('로그인에 실패했습니다.');
|
|
}
|
|
});
|
|
|
|
// 페이지 로드 시 초기화
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// AuthManager 로드 대기
|
|
const checkAuthManager = () => {
|
|
if (window.authManager) {
|
|
initializeDashboard();
|
|
} else {
|
|
setTimeout(checkAuthManager, 100);
|
|
}
|
|
};
|
|
checkAuthManager();
|
|
});
|
|
</script>
|
|
|
|
<!-- API 스크립트 동적 로드 -->
|
|
<script>
|
|
function initializeDashboardApp() {
|
|
console.log('✅ API 스크립트 로드 완료 (issues-dashboard.html)');
|
|
}
|
|
|
|
// 현황판 새로고침 함수
|
|
function refreshDashboard() {
|
|
// 현재 선택된 프로젝트 기준으로 다시 로드
|
|
const selectedProject = document.getElementById('projectFilter').value;
|
|
if (selectedProject) {
|
|
filterByProject();
|
|
} else {
|
|
initializeDashboard();
|
|
}
|
|
}
|
|
|
|
// 완료 신청 관련 함수들
|
|
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');
|
|
}
|
|
|
|
// 완료 신청 반려 관련 함수들
|
|
let selectedRejectionIssueId = null;
|
|
|
|
function openRejectionModal(issueId) {
|
|
selectedRejectionIssueId = issueId;
|
|
document.getElementById('rejectionModal').classList.remove('hidden');
|
|
document.getElementById('rejectionReason').value = '';
|
|
document.getElementById('rejectionReason').focus();
|
|
}
|
|
|
|
function closeRejectionModal() {
|
|
selectedRejectionIssueId = null;
|
|
document.getElementById('rejectionModal').classList.add('hidden');
|
|
}
|
|
|
|
async function submitRejection(event) {
|
|
event.preventDefault();
|
|
|
|
if (!selectedRejectionIssueId) {
|
|
alert('이슈 ID가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const rejectionReason = document.getElementById('rejectionReason').value.trim();
|
|
if (!rejectionReason) {
|
|
alert('반려 사유를 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/issues/${selectedRejectionIssueId}/reject-completion`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
rejection_reason: rejectionReason
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('완료 신청이 반려되었습니다.');
|
|
closeRejectionModal();
|
|
// 현황판 새로고침
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`반려 처리 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('반려 처리 오류:', error);
|
|
alert('반려 처리 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 댓글 토글 기능
|
|
function toggleComments(issueId, opinionIndex) {
|
|
const commentsDiv = document.getElementById(`comments-${issueId}-${opinionIndex}`);
|
|
const chevron = document.getElementById(`comment-chevron-${issueId}-${opinionIndex}`);
|
|
|
|
if (commentsDiv.classList.contains('hidden')) {
|
|
commentsDiv.classList.remove('hidden');
|
|
chevron.classList.add('fa-rotate-180');
|
|
} else {
|
|
commentsDiv.classList.add('hidden');
|
|
chevron.classList.remove('fa-rotate-180');
|
|
}
|
|
}
|
|
|
|
// 의견 제시 모달 관련
|
|
let selectedOpinionIssueId = null;
|
|
|
|
function openOpinionModal(issueId) {
|
|
selectedOpinionIssueId = issueId;
|
|
document.getElementById('opinionModal').classList.remove('hidden');
|
|
document.getElementById('opinionText').value = '';
|
|
document.getElementById('opinionText').focus();
|
|
}
|
|
|
|
function closeOpinionModal() {
|
|
selectedOpinionIssueId = null;
|
|
document.getElementById('opinionModal').classList.add('hidden');
|
|
}
|
|
|
|
// 댓글 추가 모달 관련
|
|
let selectedCommentIssueId = null;
|
|
let selectedCommentOpinionIndex = null;
|
|
|
|
function openCommentModal(issueId, opinionIndex) {
|
|
selectedCommentIssueId = issueId;
|
|
selectedCommentOpinionIndex = opinionIndex;
|
|
document.getElementById('commentModal').classList.remove('hidden');
|
|
document.getElementById('commentText').value = '';
|
|
document.getElementById('commentText').focus();
|
|
}
|
|
|
|
function closeCommentModal() {
|
|
selectedCommentIssueId = null;
|
|
selectedCommentOpinionIndex = null;
|
|
document.getElementById('commentModal').classList.add('hidden');
|
|
}
|
|
|
|
// 로그 기록 함수
|
|
async function logModification(issueId, action, details) {
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${issueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (issueResponse.ok) {
|
|
const issue = await issueResponse.json();
|
|
const modificationLog = issue.modification_log || [];
|
|
|
|
modificationLog.push({
|
|
timestamp: new Date().toISOString(),
|
|
user: currentUser.full_name || currentUser.username,
|
|
user_id: currentUser.id,
|
|
action: action,
|
|
details: details
|
|
});
|
|
|
|
// modification_log 업데이트는 solution 업데이트와 함께 처리됨
|
|
return modificationLog;
|
|
}
|
|
} catch (error) {
|
|
console.error('로그 기록 오류:', error);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function submitComment(event) {
|
|
event.preventDefault();
|
|
|
|
if (!selectedCommentIssueId || selectedCommentOpinionIndex === null) {
|
|
alert('대상 의견이 선택되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
const commentText = document.getElementById('commentText').value.trim();
|
|
if (!commentText) {
|
|
alert('댓글을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${selectedCommentIssueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) {
|
|
throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
}
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
|
|
if (selectedCommentOpinionIndex >= opinions.length) {
|
|
throw new Error('잘못된 의견 인덱스입니다.');
|
|
}
|
|
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
const newComment = ` └ [${currentUser.full_name || currentUser.username}] (${dateStr}): ${commentText}`;
|
|
|
|
opinions[selectedCommentOpinionIndex] = opinions[selectedCommentOpinionIndex].trim() + '\n' + newComment;
|
|
const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n');
|
|
|
|
const response = await fetch(`/api/issues/${selectedCommentIssueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
solution: updatedSolution
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
await logModification(selectedCommentIssueId, 'comment_added', {
|
|
opinion_index: selectedCommentOpinionIndex,
|
|
comment: commentText
|
|
});
|
|
alert('댓글이 추가되었습니다.');
|
|
closeCommentModal();
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`댓글 추가 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('댓글 추가 오류:', error);
|
|
alert('댓글 추가 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 답글(대댓글) 관련
|
|
let selectedReplyIssueId = null;
|
|
let selectedReplyOpinionIndex = null;
|
|
let selectedReplyCommentIndex = null;
|
|
|
|
function openReplyModal(issueId, opinionIndex, commentIndex) {
|
|
selectedReplyIssueId = issueId;
|
|
selectedReplyOpinionIndex = opinionIndex;
|
|
selectedReplyCommentIndex = commentIndex;
|
|
document.getElementById('replyModal').classList.remove('hidden');
|
|
document.getElementById('replyText').value = '';
|
|
document.getElementById('replyText').focus();
|
|
}
|
|
|
|
function closeReplyModal() {
|
|
selectedReplyIssueId = null;
|
|
selectedReplyOpinionIndex = null;
|
|
selectedReplyCommentIndex = null;
|
|
document.getElementById('replyModal').classList.add('hidden');
|
|
}
|
|
|
|
async function submitReply(event) {
|
|
event.preventDefault();
|
|
|
|
if (!selectedReplyIssueId || selectedReplyOpinionIndex === null || selectedReplyCommentIndex === null) {
|
|
alert('대상 댓글이 선택되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
const replyText = document.getElementById('replyText').value.trim();
|
|
if (!replyText) {
|
|
alert('답글을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${selectedReplyIssueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) {
|
|
throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
}
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
|
|
if (selectedReplyOpinionIndex >= opinions.length) {
|
|
throw new Error('잘못된 의견 인덱스입니다.');
|
|
}
|
|
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
// 의견을 라인별로 분리하여 해당 댓글 뒤에 답글 추가
|
|
const lines = opinions[selectedReplyOpinionIndex].trim().split('\n');
|
|
const newReply = ` ↳ [${currentUser.full_name || currentUser.username}] (${dateStr}): ${replyText}`;
|
|
|
|
let commentCount = -1;
|
|
let insertIndex = -1;
|
|
|
|
for (let 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');
|
|
}
|
|
|
|
const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n');
|
|
|
|
const response = await fetch(`/api/issues/${selectedReplyIssueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
solution: updatedSolution
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
await logModification(selectedReplyIssueId, 'reply_added', {
|
|
opinion_index: selectedReplyOpinionIndex,
|
|
comment_index: selectedReplyCommentIndex,
|
|
reply: replyText
|
|
});
|
|
alert('답글이 추가되었습니다.');
|
|
closeReplyModal();
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`답글 추가 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('답글 추가 오류:', error);
|
|
alert('답글 추가 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 의견 수정 관련
|
|
let selectedEditIssueId = null;
|
|
let selectedEditOpinionIndex = null;
|
|
|
|
async function editOpinion(issueId, opinionIndex) {
|
|
selectedEditIssueId = issueId;
|
|
selectedEditOpinionIndex = opinionIndex;
|
|
|
|
try {
|
|
// 현재 이슈 정보 가져오기
|
|
const issueResponse = await fetch(`/api/issues/${issueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) {
|
|
throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
}
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
|
|
if (opinionIndex >= opinions.length) {
|
|
throw new Error('잘못된 의견 인덱스입니다.');
|
|
}
|
|
|
|
// 의견에서 헤더와 본문 분리
|
|
const opinion = opinions[opinionIndex].trim();
|
|
const headerMatch = opinion.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
|
|
|
|
if (headerMatch) {
|
|
const lines = opinion.substring(headerMatch[0].length).trim().split('\n');
|
|
let mainContent = '';
|
|
|
|
for (const line of lines) {
|
|
if (!line.match(/^\s*[└├]/)) {
|
|
mainContent += (mainContent ? '\n' : '') + line;
|
|
}
|
|
}
|
|
|
|
document.getElementById('editOpinionText').value = mainContent;
|
|
document.getElementById('editOpinionModal').classList.remove('hidden');
|
|
document.getElementById('editOpinionText').focus();
|
|
}
|
|
} catch (error) {
|
|
console.error('의견 수정 준비 오류:', error);
|
|
alert('의견을 불러오는 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function closeEditOpinionModal() {
|
|
selectedEditIssueId = null;
|
|
selectedEditOpinionIndex = null;
|
|
document.getElementById('editOpinionModal').classList.add('hidden');
|
|
}
|
|
|
|
async function submitEditOpinion(event) {
|
|
event.preventDefault();
|
|
|
|
if (!selectedEditIssueId || selectedEditOpinionIndex === null) {
|
|
alert('대상 의견이 선택되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
const newText = document.getElementById('editOpinionText').value.trim();
|
|
if (!newText) {
|
|
alert('의견 내용을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${selectedEditIssueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) {
|
|
throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
}
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
|
|
if (selectedEditOpinionIndex >= opinions.length) {
|
|
throw new Error('잘못된 의견 인덱스입니다.');
|
|
}
|
|
|
|
// 기존 헤더와 댓글 유지, 본문만 변경
|
|
const opinion = opinions[selectedEditOpinionIndex].trim();
|
|
const headerMatch = opinion.match(/^\[([^\]]+)\]\s*\(([^)]+)\)/);
|
|
|
|
if (headerMatch) {
|
|
const lines = opinion.substring(headerMatch[0].length).trim().split('\n');
|
|
let comments = [];
|
|
|
|
for (const line of lines) {
|
|
if (line.match(/^\s*[└├]/)) {
|
|
comments.push(line);
|
|
}
|
|
}
|
|
|
|
// 새 의견 = 헤더 + 새 본문 + 기존 댓글들
|
|
opinions[selectedEditOpinionIndex] = headerMatch[0] + '\n' + newText +
|
|
(comments.length > 0 ? '\n' + comments.join('\n') : '');
|
|
}
|
|
|
|
const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n');
|
|
|
|
const response = await fetch(`/api/issues/${selectedEditIssueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
solution: updatedSolution
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('의견이 수정되었습니다.');
|
|
closeEditOpinionModal();
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`의견 수정 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('의견 수정 오류:', error);
|
|
alert('의견 수정 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 의견 삭제
|
|
async function deleteOpinion(issueId, opinionIndex) {
|
|
if (!confirm('이 의견을 삭제하시겠습니까? (댓글도 함께 삭제됩니다)')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${issueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) {
|
|
throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
}
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
|
|
if (opinionIndex >= opinions.length) {
|
|
throw new Error('잘못된 의견 인덱스입니다.');
|
|
}
|
|
|
|
const deletedOpinion = opinions[opinionIndex];
|
|
opinions.splice(opinionIndex, 1);
|
|
|
|
const updatedSolution = opinions.length > 0 ? opinions.join('\n' + '─'.repeat(50) + '\n') : '';
|
|
|
|
const response = await fetch(`/api/issues/${issueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
solution: updatedSolution || null
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
await logModification(issueId, 'opinion_deleted', {
|
|
opinion_index: opinionIndex,
|
|
deleted_content: deletedOpinion
|
|
});
|
|
alert('의견이 삭제되었습니다.');
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`의견 삭제 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('의견 삭제 오류:', error);
|
|
alert('의견 삭제 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 댓글 수정
|
|
let selectedEditCommentIssueId = null;
|
|
let selectedEditCommentOpinionIndex = null;
|
|
let selectedEditCommentIndex = null;
|
|
|
|
async function editComment(issueId, opinionIndex, commentIndex) {
|
|
selectedEditCommentIssueId = issueId;
|
|
selectedEditCommentOpinionIndex = opinionIndex;
|
|
selectedEditCommentIndex = commentIndex;
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${issueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
const lines = opinions[opinionIndex].trim().split('\n');
|
|
|
|
let commentCount = -1;
|
|
for (const line of lines) {
|
|
if (line.match(/^\s*└/)) {
|
|
commentCount++;
|
|
if (commentCount === commentIndex) {
|
|
const match = line.match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
|
if (match) {
|
|
document.getElementById('editCommentText').value = match[3];
|
|
document.getElementById('editCommentModal').classList.remove('hidden');
|
|
document.getElementById('editCommentText').focus();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('댓글 수정 준비 오류:', error);
|
|
alert('댓글을 불러오는 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function closeEditCommentModal() {
|
|
selectedEditCommentIssueId = null;
|
|
selectedEditCommentOpinionIndex = null;
|
|
selectedEditCommentIndex = null;
|
|
document.getElementById('editCommentModal').classList.add('hidden');
|
|
}
|
|
|
|
async function submitEditComment(event) {
|
|
event.preventDefault();
|
|
|
|
const newText = document.getElementById('editCommentText').value.trim();
|
|
if (!newText) {
|
|
alert('댓글 내용을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${selectedEditCommentIssueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
const lines = opinions[selectedEditCommentOpinionIndex].trim().split('\n');
|
|
|
|
let commentCount = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].match(/^\s*└/)) {
|
|
commentCount++;
|
|
if (commentCount === selectedEditCommentIndex) {
|
|
const match = lines[i].match(/└\s*\[([^\]]+)\]\s*\(([^)]+)\):/);
|
|
if (match) {
|
|
lines[i] = ` └ [${match[1]}] (${match[2]}): ${newText}`;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
opinions[selectedEditCommentOpinionIndex] = lines.join('\n');
|
|
const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n');
|
|
|
|
const response = await fetch(`/api/issues/${selectedEditCommentIssueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ solution: updatedSolution })
|
|
});
|
|
|
|
if (response.ok) {
|
|
await logModification(selectedEditCommentIssueId, 'comment_edited', {
|
|
opinion_index: selectedEditCommentOpinionIndex,
|
|
comment_index: selectedEditCommentIndex,
|
|
new_content: newText
|
|
});
|
|
alert('댓글이 수정되었습니다.');
|
|
closeEditCommentModal();
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`댓글 수정 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('댓글 수정 오류:', error);
|
|
alert('댓글 수정 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 댓글 삭제
|
|
async function deleteComment(issueId, opinionIndex, commentIndex) {
|
|
if (!confirm('이 댓글을 삭제하시겠습니까? (답글도 함께 삭제됩니다)')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${issueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
const lines = opinions[opinionIndex].trim().split('\n');
|
|
|
|
let commentCount = -1;
|
|
let deleteStart = -1;
|
|
let deleteEnd = -1;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].match(/^\s*└/)) {
|
|
commentCount++;
|
|
if (commentCount === commentIndex) {
|
|
deleteStart = i;
|
|
deleteEnd = i + 1;
|
|
// 이 댓글의 답글들도 찾기
|
|
while (deleteEnd < lines.length && lines[deleteEnd].match(/^\s*↳/)) {
|
|
deleteEnd++;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (deleteStart >= 0) {
|
|
const deletedContent = lines.slice(deleteStart, deleteEnd).join('\n');
|
|
lines.splice(deleteStart, deleteEnd - deleteStart);
|
|
opinions[opinionIndex] = lines.join('\n');
|
|
}
|
|
|
|
const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n');
|
|
|
|
const response = await fetch(`/api/issues/${issueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ solution: updatedSolution })
|
|
});
|
|
|
|
if (response.ok) {
|
|
await logModification(issueId, 'comment_deleted', {
|
|
opinion_index: opinionIndex,
|
|
comment_index: commentIndex
|
|
});
|
|
alert('댓글이 삭제되었습니다.');
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`댓글 삭제 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('댓글 삭제 오류:', error);
|
|
alert('댓글 삭제 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 대댓글(답글) 수정
|
|
let selectedEditReplyIssueId = null;
|
|
let selectedEditReplyOpinionIndex = null;
|
|
let selectedEditReplyCommentIndex = null;
|
|
let selectedEditReplyIndex = null;
|
|
|
|
async function editReply(issueId, opinionIndex, commentIndex, replyIndex) {
|
|
selectedEditReplyIssueId = issueId;
|
|
selectedEditReplyOpinionIndex = opinionIndex;
|
|
selectedEditReplyCommentIndex = commentIndex;
|
|
selectedEditReplyIndex = replyIndex;
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${issueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
const lines = opinions[opinionIndex].trim().split('\n');
|
|
|
|
let commentCount = -1;
|
|
let replyCount = -1;
|
|
|
|
for (const line of lines) {
|
|
if (line.match(/^\s*└/)) {
|
|
commentCount++;
|
|
replyCount = -1;
|
|
} else if (line.match(/^\s*↳/) && commentCount === commentIndex) {
|
|
replyCount++;
|
|
if (replyCount === replyIndex) {
|
|
const match = line.match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):\s*(.+)/);
|
|
if (match) {
|
|
document.getElementById('editReplyText').value = match[3];
|
|
document.getElementById('editReplyModal').classList.remove('hidden');
|
|
document.getElementById('editReplyText').focus();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('답글 수정 준비 오류:', error);
|
|
alert('답글을 불러오는 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function closeEditReplyModal() {
|
|
selectedEditReplyIssueId = null;
|
|
selectedEditReplyOpinionIndex = null;
|
|
selectedEditReplyCommentIndex = null;
|
|
selectedEditReplyIndex = null;
|
|
document.getElementById('editReplyModal').classList.add('hidden');
|
|
}
|
|
|
|
async function submitEditReply(event) {
|
|
event.preventDefault();
|
|
|
|
const newText = document.getElementById('editReplyText').value.trim();
|
|
if (!newText) {
|
|
alert('답글 내용을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${selectedEditReplyIssueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
const lines = opinions[selectedEditReplyOpinionIndex].trim().split('\n');
|
|
|
|
let commentCount = -1;
|
|
let replyCount = -1;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].match(/^\s*└/)) {
|
|
commentCount++;
|
|
replyCount = -1;
|
|
} else if (lines[i].match(/^\s*↳/) && commentCount === selectedEditReplyCommentIndex) {
|
|
replyCount++;
|
|
if (replyCount === selectedEditReplyIndex) {
|
|
const match = lines[i].match(/↳\s*\[([^\]]+)\]\s*\(([^)]+)\):/);
|
|
if (match) {
|
|
lines[i] = ` ↳ [${match[1]}] (${match[2]}): ${newText}`;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
opinions[selectedEditReplyOpinionIndex] = lines.join('\n');
|
|
const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n');
|
|
|
|
const response = await fetch(`/api/issues/${selectedEditReplyIssueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ solution: updatedSolution })
|
|
});
|
|
|
|
if (response.ok) {
|
|
await logModification(selectedEditReplyIssueId, 'reply_edited', {
|
|
opinion_index: selectedEditReplyOpinionIndex,
|
|
comment_index: selectedEditReplyCommentIndex,
|
|
reply_index: selectedEditReplyIndex,
|
|
new_content: newText
|
|
});
|
|
alert('답글이 수정되었습니다.');
|
|
closeEditReplyModal();
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`답글 수정 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('답글 수정 오류:', error);
|
|
alert('답글 수정 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 대댓글(답글) 삭제
|
|
async function deleteReply(issueId, opinionIndex, commentIndex, replyIndex) {
|
|
if (!confirm('이 답글을 삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const issueResponse = await fetch(`/api/issues/${issueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
|
|
const issue = await issueResponse.json();
|
|
const opinions = issue.solution ? issue.solution.split(/─{30,}/) : [];
|
|
const lines = opinions[opinionIndex].trim().split('\n');
|
|
|
|
let commentCount = -1;
|
|
let replyCount = -1;
|
|
let deleteIndex = -1;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].match(/^\s*└/)) {
|
|
commentCount++;
|
|
replyCount = -1;
|
|
} else if (lines[i].match(/^\s*↳/) && commentCount === commentIndex) {
|
|
replyCount++;
|
|
if (replyCount === replyIndex) {
|
|
deleteIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (deleteIndex >= 0) {
|
|
lines.splice(deleteIndex, 1);
|
|
opinions[opinionIndex] = lines.join('\n');
|
|
}
|
|
|
|
const updatedSolution = opinions.join('\n' + '─'.repeat(50) + '\n');
|
|
|
|
const response = await fetch(`/api/issues/${issueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ solution: updatedSolution })
|
|
});
|
|
|
|
if (response.ok) {
|
|
await logModification(issueId, 'reply_deleted', {
|
|
opinion_index: opinionIndex,
|
|
comment_index: commentIndex,
|
|
reply_index: replyIndex
|
|
});
|
|
alert('답글이 삭제되었습니다.');
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`답글 삭제 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('답글 삭제 오류:', error);
|
|
alert('답글 삭제 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function submitOpinion(event) {
|
|
event.preventDefault();
|
|
|
|
if (!selectedOpinionIssueId) {
|
|
alert('이슈 ID가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const opinionText = document.getElementById('opinionText').value.trim();
|
|
if (!opinionText) {
|
|
alert('의견을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 현재 이슈 정보 가져오기
|
|
const issueResponse = await fetch(`/api/issues/${selectedOpinionIssueId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
|
}
|
|
});
|
|
|
|
if (!issueResponse.ok) {
|
|
throw new Error('이슈 정보를 가져올 수 없습니다.');
|
|
}
|
|
|
|
const issue = await issueResponse.json();
|
|
|
|
// 새 의견 형식: [작성자] (날짜시간)\n내용
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
const newOpinion = `[${currentUser.full_name || currentUser.username}] (${dateStr})\n${opinionText}`;
|
|
|
|
// 기존 solution에 추가 (최신이 위로)
|
|
const updatedSolution = issue.solution
|
|
? `${newOpinion}\n${'─'.repeat(50)}\n${issue.solution}`
|
|
: newOpinion;
|
|
|
|
// 백엔드 업데이트
|
|
const response = await fetch(`/api/issues/${selectedOpinionIssueId}/management`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
solution: updatedSolution
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('의견이 추가되었습니다.');
|
|
closeOpinionModal();
|
|
// 페이지 새로고침
|
|
await initializeDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`의견 추가 실패: ${error.detail || '알 수 없는 오류'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('의견 추가 오류:', error);
|
|
alert('의견 추가 중 오류가 발생했습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
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="replyModal" 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">
|
|
<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-reply text-blue-500 mr-2"></i>
|
|
답글 추가
|
|
</h3>
|
|
<button onclick="closeReplyModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
<form onsubmit="submitReply(event)" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
<i class="fas fa-pen text-blue-500 mr-1"></i>
|
|
답글 내용
|
|
</label>
|
|
<textarea id="replyText" rows="3"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
|
placeholder="댓글에 대한 답글을 입력하세요..."
|
|
required></textarea>
|
|
</div>
|
|
<div class="flex space-x-3 pt-4">
|
|
<button type="button" onclick="closeReplyModal()"
|
|
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-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
|
<i class="fas fa-paper-plane mr-2"></i>답글 추가
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 댓글 수정 모달 -->
|
|
<div id="editCommentModal" 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">
|
|
<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-edit text-green-500 mr-2"></i>
|
|
댓글 수정
|
|
</h3>
|
|
<button onclick="closeEditCommentModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
<form onsubmit="submitEditComment(event)" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
<i class="fas fa-lightbulb text-green-500 mr-1"></i>
|
|
댓글 내용
|
|
</label>
|
|
<textarea id="editCommentText" 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="수정할 댓글 내용을 입력하세요..."
|
|
required></textarea>
|
|
</div>
|
|
<div class="flex space-x-3 pt-4">
|
|
<button type="button" onclick="closeEditCommentModal()"
|
|
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-save mr-2"></i>수정 완료
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 답글 수정 모달 -->
|
|
<div id="editReplyModal" 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">
|
|
<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-edit text-green-500 mr-2"></i>
|
|
답글 수정
|
|
</h3>
|
|
<button onclick="closeEditReplyModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
<form onsubmit="submitEditReply(event)" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
<i class="fas fa-lightbulb text-green-500 mr-1"></i>
|
|
답글 내용
|
|
</label>
|
|
<textarea id="editReplyText" 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="수정할 답글 내용을 입력하세요..."
|
|
required></textarea>
|
|
</div>
|
|
<div class="flex space-x-3 pt-4">
|
|
<button type="button" onclick="closeEditReplyModal()"
|
|
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-save mr-2"></i>수정 완료
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 댓글 추가 모달 -->
|
|
<div id="commentModal" 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">
|
|
<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-comment text-blue-500 mr-2"></i>
|
|
댓글 추가
|
|
</h3>
|
|
<button onclick="closeCommentModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 모달 내용 -->
|
|
<form onsubmit="submitComment(event)" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
<i class="fas fa-pen text-blue-500 mr-1"></i>
|
|
댓글 내용
|
|
</label>
|
|
<textarea id="commentText" rows="3"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
|
placeholder="의견에 대한 댓글을 입력하세요..."
|
|
required></textarea>
|
|
</div>
|
|
|
|
<!-- 버튼 -->
|
|
<div class="flex space-x-3 pt-4">
|
|
<button type="button" onclick="closeCommentModal()"
|
|
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-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
|
<i class="fas fa-paper-plane mr-2"></i>댓글 추가
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 의견 수정 모달 -->
|
|
<div id="editOpinionModal" 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">
|
|
<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-edit text-green-500 mr-2"></i>
|
|
의견 수정
|
|
</h3>
|
|
<button onclick="closeEditOpinionModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 모달 내용 -->
|
|
<form onsubmit="submitEditOpinion(event)" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
<i class="fas fa-lightbulb text-green-500 mr-1"></i>
|
|
의견 내용
|
|
</label>
|
|
<textarea id="editOpinionText" rows="5"
|
|
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="수정할 의견 내용을 입력하세요..."
|
|
required></textarea>
|
|
</div>
|
|
|
|
<!-- 안내 메시지 -->
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<div class="flex items-start">
|
|
<i class="fas fa-info-circle text-blue-500 mt-0.5 mr-2"></i>
|
|
<div class="text-sm text-blue-700">
|
|
<p>댓글은 유지되며 본문만 수정됩니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 버튼 -->
|
|
<div class="flex space-x-3 pt-4">
|
|
<button type="button" onclick="closeEditOpinionModal()"
|
|
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-save mr-2"></i>수정 완료
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 의견 제시 모달 -->
|
|
<div id="opinionModal" 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">
|
|
<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-comment-medical text-green-500 mr-2"></i>
|
|
해결 방안 의견 제시
|
|
</h3>
|
|
<button onclick="closeOpinionModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 모달 내용 -->
|
|
<form onsubmit="submitOpinion(event)" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
<i class="fas fa-lightbulb text-green-500 mr-1"></i>
|
|
의견 내용
|
|
</label>
|
|
<textarea id="opinionText" rows="5"
|
|
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="해결 방안에 대한 의견을 입력하세요..."
|
|
required></textarea>
|
|
</div>
|
|
|
|
<!-- 안내 메시지 -->
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<div class="flex items-start">
|
|
<i class="fas fa-info-circle text-blue-500 mt-0.5 mr-2"></i>
|
|
<div class="text-sm text-blue-700">
|
|
<p class="font-medium mb-1">의견 제시 안내</p>
|
|
<p>입력하신 의견은 <strong>[작성자] (날짜시간)</strong> 형식으로 해결 방안에 추가됩니다.</p>
|
|
<p class="mt-1">최신 의견이 맨 위에 표시됩니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 버튼 -->
|
|
<div class="flex space-x-3 pt-4">
|
|
<button type="button" onclick="closeOpinionModal()"
|
|
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>
|
|
|
|
<!-- 완료 신청 반려 모달 -->
|
|
<div id="rejectionModal" 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">
|
|
<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-times-circle text-red-500 mr-2"></i>
|
|
완료 신청 반려
|
|
</h3>
|
|
<button onclick="closeRejectionModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 모달 내용 -->
|
|
<form onsubmit="submitRejection(event)" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
<i class="fas fa-exclamation-triangle text-red-500 mr-1"></i>
|
|
반려 사유
|
|
</label>
|
|
<textarea id="rejectionReason" rows="5"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 resize-none"
|
|
placeholder="완료 신청을 반려하는 사유를 입력하세요..."
|
|
required></textarea>
|
|
</div>
|
|
|
|
<!-- 안내 메시지 -->
|
|
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
|
|
<div class="flex items-start">
|
|
<i class="fas fa-info-circle text-red-500 mt-0.5 mr-2"></i>
|
|
<div class="text-sm text-red-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="closeRejectionModal()"
|
|
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-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
|
|
<i class="fas fa-times-circle mr-2"></i>반려하기
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 완료 신청 모달 -->
|
|
<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>
|